diff --git a/package-lock.json b/package-lock.json index 827b32e..518402b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", - "hi-profiles": "^2.2.0", + "hi-profiles": "^2.3.0", "input-otp": "^1.4.2", "lucide-react": "^0.487.0", "qrcode.react": "^4.2.0", @@ -3811,9 +3811,9 @@ } }, "node_modules/hi-profiles": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hi-profiles/-/hi-profiles-2.2.0.tgz", - "integrity": "sha512-uRf8ISkB6BRbwG58Z/gx2eRCLUf9EhH5CuIxpC+dxvs2XuOp9iHXcmBVFtb6avbAHNMX5dfGv+ZvfiOyYitAJQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hi-profiles/-/hi-profiles-2.3.0.tgz", + "integrity": "sha512-vz0f0dmSKYeyKE+suO7dJjpAYMiswJOh3Y+qWUBBqZcudpxhEZo97qtNpLDS8h0lk1XFAeTPfpfnUS5C6xpExg==", "dependencies": { "react-icons": "^5.5.0" }, diff --git a/package.json b/package.json index 400f10c..93c164c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", - "hi-profiles": "^2.2.0", + "hi-profiles": "^2.3.0", "input-otp": "^1.4.2", "lucide-react": "^0.487.0", "qrcode.react": "^4.2.0", @@ -56,8 +56,6 @@ "@tailwindcss/vite": "^4.1.3", "@tanstack/eslint-plugin-query": "^5.72.2", "@types/node": "^22.14.1", - "react": "^19.1.0", - "react-dom": "^19.1.0", "@types/react": "^19.1.1", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.3.4", @@ -65,6 +63,8 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "tailwindcss": "^4.1.3", "typescript": "~5.8.3", "typescript-eslint": "^8.29.1", diff --git a/public/locale/en.json b/public/locale/en.json index 0585099..cbba47e 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -35,6 +35,16 @@ "consent__status__GRANTED": "Granted", "consent__status__REQUESTED": "Requested", "consent__status__REVOKED": "Revoked", + "valid_till": "Valid until {{date}}", + "consent_records_date_range": "Records requested from {{from}} to {{to}}", + "hi_types": "HI Types", + "artefact_status__waiting": "Waiting", + "artefact_status__fetched": "Fetched", + "unknown_facility": "Unknown Facility", + "view": "View", + "status": "Status", + "expand": "Expand", + "collapse": "Collapse", "consent_request__date_range": "Health Records Date Range", "consent_request__expiry": "Consent Expiry Date", "consent_request__hi_types": "Health Information Types", @@ -55,6 +65,7 @@ "abha__disclaimer_5": "I, {{user}}, confirm that I have duly informed and explained the beneficiary of the contents of consent for aforementioned purposes.", "abha__disclaimer_6": "I, , have been explained about the consent as stated above and hereby provide my consent for the aforementioned purposes.", "abha__qr_scanning_error": "Error scanning QR code, Invalid QR code", + "abha_account": "ABHA Account", "abha_address": "ABHA Address", "abha_address_created_error": "Failed to create Abha Address. Please try again later.", "abha_address_created_success": "Abha Address has been created successfully.", @@ -81,6 +92,8 @@ "abha_number_exists_description": "There is an ABHA Number already linked with the given Aadhaar Number, Do you want to create a new ABHA Address?", "abha_number_linked_successfully": "ABHA Number has been linked successfully.", "abha_profile": "ABHA Profile", + "choose_abha_account": "Choose an ABHA Account", + "choose_abha_account_description": "Multiple ABHA accounts are linked. Select the one you want to link.", "send_otp": "Send OTP", "resend_otp": "Resend OTP", "verify_otp": "Verify OTP", @@ -114,6 +127,7 @@ "checking_consent_status": "Consent request status is being checked!", "async_operation_warning": "This operation may take some time. Please check back later.", "loading_health_information": "Loading Health Information", + "back": "Back", "configure_health_facility": "Configure Health Facility", "configure_health_facility_description": "Configure the health facility, link your ABDM health facility with care and register it as a service in bridge", "generate_scan_and_share_qr": "Generate Scan and Share QR", diff --git a/src/apis/index.ts b/src/apis/index.ts index a7f4ff3..ec1864d 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -7,7 +7,7 @@ import { } from "@/types/consent"; import { queryString, request } from "./request"; -import { AbhaNumber } from "@/types/abhaNumber"; +import { AbhaLoginAccount, AbhaNumber } from "@/types/abhaNumber"; import { GovtOrganization } from "@/types/govtOrganization"; import { HealthFacility } from "@/types/healthFacility"; import { HealthInformation } from "@/types/healthInformation"; @@ -22,6 +22,7 @@ export const apis = { list: async (query?: { facility?: string; patient?: string; + encounter?: string; ordering?: string; }) => { return await request>( @@ -31,6 +32,7 @@ export const apis = { create: async (body: { patient_abha: string; + encounter: string; hi_types: ConsentHIType[]; purpose: ConsentPurpose; from_time: Date | string; @@ -347,11 +349,24 @@ export const apis = { otp: string; transaction_id: string; otp_system: "abdm" | "aadhaar"; + }) => { + return await request<{ + transaction_id: string; + accounts: AbhaLoginAccount[]; + }>("/api/abdm/v3/health_id/login/verify_otp/", { + method: "POST", + body: JSON.stringify(body), + }); + }, + + abhaLoginVerifyUser: async (body: { + transaction_id: string; + account_id: number; }) => { return await request<{ abha_number: AbhaNumber; created: boolean; - }>("/api/abdm/v3/health_id/login/verify_otp/", { + }>("/api/abdm/v3/health_id/login/verify_user/", { method: "POST", body: JSON.stringify(body), }); diff --git a/src/components/CreateConsentRequestForm.tsx b/src/components/CreateConsentRequestForm.tsx index a405317..051d76e 100644 --- a/src/components/CreateConsentRequestForm.tsx +++ b/src/components/CreateConsentRequestForm.tsx @@ -1,11 +1,8 @@ -import dayjs from "@/lib/dayjs"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; -import { FC } from "react"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { z } from "zod"; +import { + CONSENT_HI_TYPES, + CONSENT_PURPOSES, + ConsentRequest, +} from "@/types/consent"; import { Form, FormControl, @@ -14,16 +11,6 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { toast } from "@/lib/utils"; -import { apis } from "@/apis"; -import { AbhaNumber } from "@/types/abhaNumber"; -import { - CONSENT_HI_TYPES, - CONSENT_PURPOSES, - ConsentRequest, -} from "@/types/consent"; import { Select, SelectContent, @@ -31,13 +18,28 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { DatePickerWithRange } from "./ui/date-range-picker"; + +import { AbhaNumber } from "@/types/abhaNumber"; +import { Button } from "@/components/ui/button"; import { DatePicker } from "./ui/date-picker"; -import { MultiSelect } from "./ui/multi-select"; +import { DatePickerWithRange } from "./ui/date-range-picker"; +import { Encounter } from "@/types/encounter"; +import { FC } from "react"; import { I18NNAMESPACE } from "@/lib/constants"; +import { Input } from "@/components/ui/input"; +import { MultiSelect } from "./ui/multi-select"; +import { apis } from "@/apis"; +import dayjs from "@/lib/dayjs"; +import { toast } from "@/lib/utils"; +import { useForm } from "react-hook-form"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; type CreateConsentRequestFormProps = { abhaNumber?: AbhaNumber; + encounter: Encounter; onSuccess?: (consentRequest: ConsentRequest) => void; }; @@ -58,6 +60,7 @@ type CreateConsentRequestFormValues = z.infer< const CreateConsentRequestForm: FC = ({ abhaNumber, + encounter, onSuccess, }) => { const { t } = useTranslation(I18NNAMESPACE); @@ -76,7 +79,7 @@ const CreateConsentRequestForm: FC = ({ }, }); - const createConsentRequestOtpMutation = useMutation({ + const createConsentRequestMutation = useMutation({ mutationFn: apis.consent.create, onSuccess: (data) => { toast.success(t("consent_requested_successfully")); @@ -85,9 +88,10 @@ const CreateConsentRequestForm: FC = ({ }); function onSubmit(values: CreateConsentRequestFormValues) { - createConsentRequestOtpMutation.mutate({ + createConsentRequestMutation.mutate({ ...values, patient_abha: form.getValues("patient_abha"), + encounter: encounter.id, from_time: values.time_range.from, to_time: values.time_range.to, }); @@ -184,7 +188,7 @@ const CreateConsentRequestForm: FC = ({ render={({ field }) => ( {t("consent_request__expiry")} - + )} @@ -193,7 +197,7 @@ const CreateConsentRequestForm: FC = ({ diff --git a/src/components/LinkAbhaNumber/LinkAbhaForm.tsx b/src/components/LinkAbhaNumber/LinkAbhaForm.tsx index 4038540..16b72fd 100644 --- a/src/components/LinkAbhaNumber/LinkAbhaForm.tsx +++ b/src/components/LinkAbhaNumber/LinkAbhaForm.tsx @@ -42,7 +42,7 @@ import { Trans, useTranslation } from "react-i18next"; import useMultiStepForm, { InjectedStepProps } from "./useMultiStepForm"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AbhaNumber } from "@/types/abhaNumber"; +import { AbhaLoginAccount, AbhaNumber } from "@/types/abhaNumber"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { QRCodeSVG } from "qrcode.react"; @@ -77,6 +77,7 @@ type FormMemory = { id: string; idType: IdType; otpSystem: "aadhaar" | "abdm"; + loginAccounts?: AbhaLoginAccount[]; }; const normalizeId = (id: string) => @@ -107,6 +108,12 @@ export const LinkAbhaForm: FC = ({ onSuccess }) => { id: "verify-id", element: , }, + { + id: "choose-abha-account", + element: ( + + ), + }, { id: "verify-aadhaar-with-otp", element: ( @@ -163,7 +170,7 @@ export const LinkAbhaForm: FC = ({ onSuccess }) => { id: "", idType: "aadhaar", otpSystem: "aadhaar", - } + }, ); return
{currentStep}
; @@ -589,7 +596,12 @@ const verifyIdFormSchema = z.object({ type VerifyIdFormValues = z.infer; -const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { +const VerifyId: FC = ({ + memory, + setMemory, + goTo, + onSuccess, +}) => { const { t } = useTranslation(I18NNAMESPACE); const form = useForm({ @@ -601,8 +613,8 @@ const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { }, }); - const verifyOtpMutation = useMutation({ - mutationFn: apis.healthId.abhaLoginVerifyOtp, + const verifyUserMutation = useMutation({ + mutationFn: apis.healthId.abhaLoginVerifyUser, onSuccess: (data) => { if (data) { toast.success(t("otp_verified_successfully")); @@ -611,6 +623,31 @@ const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { }, }); + const verifyOtpMutation = useMutation({ + mutationFn: apis.healthId.abhaLoginVerifyOtp, + onSuccess: (data) => { + if (!data) return; + + const accounts = data.accounts ?? []; + + setMemory((prev) => ({ + ...prev, + transactionId: data.transaction_id, + loginAccounts: accounts, + })); + + if (accounts.length > 1) { + goTo("choose-abha-account"); + return; + } + + verifyUserMutation.mutate({ + transaction_id: data.transaction_id, + account_id: accounts[0]?.id ?? 0, + }); + }, + }); + const resendOtpMutation = useMutation({ mutationFn: apis.healthId.abhaLoginSendOtp, onSuccess: (data) => { @@ -693,7 +730,7 @@ const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { form.setValue( "_resendOtpCount", - form.getValues("_resendOtpCount") + 1 + form.getValues("_resendOtpCount") + 1, ); resendOtpMutation.mutate({ value: memory.id, @@ -710,7 +747,7 @@ const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { @@ -719,6 +756,92 @@ const VerifyId: FC = ({ memory, setMemory, onSuccess }) => { ); }; +type ChooseAbhaAccountProps = InjectedStepProps & { + onSuccess: (abhaNumber: AbhaNumber) => void; +}; + +const ChooseAbhaAccount: FC = ({ + memory, + onSuccess, +}) => { + const { t } = useTranslation(I18NNAMESPACE); + + const accounts = memory?.loginAccounts ?? []; + + const verifyUserMutation = useMutation({ + mutationFn: apis.healthId.abhaLoginVerifyUser, + onSuccess: (data) => { + if (data) { + toast.success(t("otp_verified_successfully")); + onSuccess(data.abha_number); + } + }, + }); + + return ( +
+
+

+ {t("choose_abha_account")} +

+

+ {t("choose_abha_account_description")} +

+
+ +
+ {accounts.map((account) => ( + + ))} +
+
+ ); +}; + type VerifyAadhaarWithOtpProps = InjectedStepProps; const verifyAadhaarWithOtpFormSchema = z.object({ diff --git a/src/components/encounter-tabs/Abdm.tsx b/src/components/encounter-tabs/Abdm.tsx index f30cc36..b6cb410 100644 --- a/src/components/encounter-tabs/Abdm.tsx +++ b/src/components/encounter-tabs/Abdm.tsx @@ -1,18 +1,43 @@ -import { FC } from "react"; -import { EncounterTabProps } from "."; -import { apis } from "@/apis"; -import { useTranslation } from "react-i18next"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { ConsentArtefact, ConsentRequest } from "@/types/consent"; -import { toast } from "@/lib/utils"; -import dayjs from "@/lib/dayjs"; +import { ChevronDownIcon, Loader2Icon, RefreshCcwIcon } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { APIError } from "@/apis/request"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Loader2Icon, RefreshCcwIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Link } from "raviger"; +import { EncounterTabProps } from "."; +import { Encounter } from "@/types/encounter"; +import { FC, useState } from "react"; import { I18NNAMESPACE } from "@/lib/constants"; +import { Link } from "raviger"; +import { Patient } from "@/types/patient"; +import { apis } from "@/apis"; +import { cn } from "@/lib/utils"; +import dayjs from "@/lib/dayjs"; +import { healthInformationPath } from "@/lib/paths"; +import { toast } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; -export const AbdmEncounterTab: FC = ({ patient }) => { +function getConsentStatusBadgeClass(status: ConsentRequest["status"]) { + switch (status) { + case "GRANTED": + return "border-transparent bg-green-100 text-green-800 hover:bg-green-100"; + case "REQUESTED": + return "border-transparent bg-amber-100 text-amber-800 hover:bg-amber-100"; + case "EXPIRED": + return "border-transparent bg-secondary-100 text-secondary-700 hover:bg-secondary-100"; + case "DENIED": + case "REVOKED": + return "border-transparent bg-red-100 text-red-800 hover:bg-red-100"; + default: + return "border-transparent bg-secondary-100 text-secondary-700 hover:bg-secondary-100"; + } +} + +export const AbdmEncounterTab: FC = ({ + patient, + encounter, +}) => { const { t } = useTranslation(I18NNAMESPACE); const { data, isLoading } = useQuery({ @@ -20,6 +45,7 @@ export const AbdmEncounterTab: FC = ({ patient }) => { queryFn: () => apis.consent.list({ patient: patient.id, + encounter: encounter.id, ordering: "-created_date", }), enabled: !!patient.id, @@ -27,8 +53,8 @@ export const AbdmEncounterTab: FC = ({ patient }) => { if (isLoading) { return ( -
- +
+

{t("loading_consent_requests")}

@@ -38,7 +64,7 @@ export const AbdmEncounterTab: FC = ({ patient }) => { if (!data?.results.length) { return ( -
+

{t("no_records_found")}

@@ -50,9 +76,16 @@ export const AbdmEncounterTab: FC = ({ patient }) => { } return ( -
+
{data?.results.map((record) => { - return ; + return ( + + ); })}
); @@ -60,60 +93,76 @@ export const AbdmEncounterTab: FC = ({ patient }) => { interface IConsentArtefactCardProps { artefact: ConsentArtefact; + facilityId: string; + patientId: string; + encounterId: string; } -function ConsentArtefactCard({ artefact }: IConsentArtefactCardProps) { +function ConsentArtefactCard({ + artefact, + facilityId, + patientId, + encounterId, +}: IConsentArtefactCardProps) { const { t } = useTranslation(I18NNAMESPACE); + const { isLoading, error } = useQuery({ + queryKey: ["healthInformation", "status", artefact.id], + queryFn: () => apis.healthInformation.get(artefact.id), + retry: false, + staleTime: 60_000, + }); + + const isWaiting = + !isLoading && (error as APIError | null)?.status === 404; + const recordStatus = isWaiting + ? t("artefact_status__waiting") + : t("artefact_status__fetched"); + return ( - -
-
-
- {artefact.hip} -
-

- {t("created_on")} {dayjs(artefact.created_date).fromNow()} -

-
-
-
- {artefact.status} -
-
- {dayjs(artefact.from_time).format("MMM DD YYYY")} -{" "} - {dayjs(artefact.to_time).format("MMM DD YYYY")} -
-

- {t("expires_on")} {dayjs(artefact.expiry).fromNow()} -

-
-
-
- {artefact.hi_types.map((hiType) => { - return ( -
- {t(`consent__hi_type__${hiType}`)} -
- ); - })} +
+
+
+ {artefact.hip ?? t("unknown_facility")} +
+

+ {t("status")}:{" "} + {isLoading ? ( + + ) : ( + {recordStatus} + )} +

- + +
); } interface IConsentRequestCardProps { consent: ConsentRequest; + encounter: Encounter; + patient: Patient; } -function ConsentRequestCard({ consent }: IConsentRequestCardProps) { +function ConsentRequestCard({ + consent, + encounter, + patient, +}: IConsentRequestCardProps) { const { t } = useTranslation(I18NNAMESPACE); + const [expanded, setExpanded] = useState(false); const checkStatusMutation = useMutation({ mutationFn: apis.consent.checkStatus, @@ -124,76 +173,128 @@ function ConsentRequestCard({ consent }: IConsentRequestCardProps) { }); return ( -
-
-
-
- {t(`consent__purpose__${consent.purpose}`)} -
-
- {[consent.requester.first_name, consent.requester.last_name] - .filter(Boolean) - .join(" ")} -
-
-
-
- {dayjs(consent.from_time).format("MMM DD YYYY")} -{" "} - {dayjs(consent.to_time).format("MMM DD YYYY")} -
-

- {t("expires_on")} {dayjs(consent.expiry).fromNow()} -

-
-
- -

- {t("created_on")} {dayjs(consent.created_date).fromNow()} -

-

- {t("modified_on")} {dayjs(consent.modified_date).fromNow()} -

+
+
+ + {t(`consent__purpose__${consent.purpose}`)} + + + {t(`consent__status__${consent.status}`, { + defaultValue: consent.status, + })} + +
+

+ {t("valid_till", { + date: dayjs(consent.expiry).format("MMM D, YYYY"), + })} +

+

+ {t("consent_records_date_range", { + from: dayjs(consent.from_time).format("MMM D, YYYY"), + to: dayjs(consent.to_time).format("MMM D, YYYY"), + })} +

+
+ +
+

+ {t("hi_types")}: +

+
+ {consent.hi_types.map((hiType) => ( + + {t(`consent__hi_type__${hiType}`)} + + ))} +
+
+ +
+ + {t("created_on")}:{" "} + {dayjs(consent.created_date).format("MMM DD, YYYY HH:mm")} + + + {t("modified_on")}:{" "} + {dayjs(consent.modified_date).format("MMM DD, YYYY HH:mm")} + +
+ + +
+ + +
- {consent.consent_artefacts?.length ? ( -
- {consent.consent_artefacts?.map((artefact) => ( - - ))} -
- ) : ( -
-

- {consent.status === "REQUESTED" - ? t("consent_request_waiting_approval") - : t("consent_request_rejected")} -

+ + {expanded && ( +
+ {consent.consent_artefacts?.length ? ( +
+ {consent.consent_artefacts.map((artefact) => ( + + ))} +
+ ) : ( +

+ {consent.status === "REQUESTED" + ? t("consent_request_waiting_approval") + : t("consent_request_rejected")} +

+ )}
)} -
- {consent.hi_types.map((hiType) => { - return ( -
- {t(`consent__hi_type__${hiType}`)} -
- ); - })} -
); } diff --git a/src/components/pages/HealthInformation.tsx b/src/components/pages/HealthInformation.tsx index b6fa4e7..e4ce579 100644 --- a/src/components/pages/HealthInformation.tsx +++ b/src/components/pages/HealthInformation.tsx @@ -1,18 +1,33 @@ -import { FC } from "react"; +import { FC, useMemo } from "react"; import { HIProfile } from "hi-profiles"; -import { I18NNAMESPACE } from "@/lib/constants"; -import { Loader2Icon } from "lucide-react"; -import Page from "@/components/ui/page"; -import { apis } from "@/apis"; +import { ArrowLeftIcon, Loader2Icon } from "lucide-react"; +import { Link } from "raviger"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { apis } from "@/apis"; +import { Button } from "@/components/ui/button"; +import Page from "@/components/ui/page"; +import { I18NNAMESPACE } from "@/lib/constants"; +import { encounterPath } from "@/lib/paths"; +// TEMPORARY PATCH: Remove when HIP matching is handled server-side. +import { shouldIncludeHiBundle } from "@/lib/patches/filterHiBundleByConsentHip"; + interface HealthInformationProps { artefactId: string; + facilityId: string; + patientId: string; + encounterId: string; } -const HealthInformation: FC = ({ artefactId }) => { +const HealthInformation: FC = ({ + artefactId, + facilityId, + patientId, + encounterId, +}) => { const { t } = useTranslation(I18NNAMESPACE); + const encounterUrl = encounterPath(facilityId, patientId, encounterId, "abdm"); const { data, @@ -24,18 +39,35 @@ const HealthInformation: FC = ({ artefactId }) => { enabled: !!artefactId, }); - const error: any = errorT; // eslint-disable-line @typescript-eslint/no-explicit-any Intentionally typecasting to any + // TEMPORARY PATCH: Remove when MedicationRequest is changed from date based to prescription based. + const { data: consentsData } = useQuery({ + queryKey: ["consents", patientId, encounterId], + queryFn: () => + apis.consent.list({ + patient: patientId, + encounter: encounterId, + }), + enabled: !!patientId && !!encounterId, + }); - if (isLoading) { - return ( -
- -

- {t("loading_health_information")} -

-
- ); - } + const consentHip = useMemo(() => { + if (!consentsData) { + return undefined; + } + + for (const consent of consentsData.results) { + const artefact = consent.consent_artefacts?.find( + (item) => item.id === artefactId, + ); + if (artefact) { + return artefact.hip; + } + } + + return null; + }, [consentsData, artefactId]); + + const error: any = errorT; // eslint-disable-line @typescript-eslint/no-explicit-any Intentionally typecasting to any const parseData = (data: string) => { try { @@ -47,8 +79,56 @@ const HealthInformation: FC = ({ artefactId }) => { } }; + // TEMPORARY PATCH: Remove when MedicationRequest is changed from date based to prescription based. + const filteredItems = useMemo(() => { + if (!data?.data) { + return []; + } + + if (consentHip === undefined) { + return data.data; + } + + return data.data.filter((item) => + shouldIncludeHiBundle(parseData(item.content), consentHip), + ); + }, [consentHip, data?.data]); + + const pageHeader = ( +
+ +

+ {t("hi__page_title")} +

+
+ ); + + if (isLoading) { + return ( + + {pageHeader} +
+ +

+ {t("loading_health_information")} +

+
+
+ ); + } + return ( - + + {pageHeader}
{!!error?.is_archived && ( <> @@ -78,7 +158,7 @@ const HealthInformation: FC = ({ artefactId }) => { )} - {data?.data.map((item) => ( + {filteredItems.map((item) => ( > = ({ variant="ghost" className={cn( "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-gray-100 focus:text-gray-900 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 dark:focus:bg-gray-800 dark:focus:text-gray-50", - className + className, )} > {t("hi__fetch_records")} - + {t("hi__fetch_records")} @@ -72,6 +72,7 @@ const EncounterActions: FC> = ({
{ queryClient.invalidateQueries({ queryKey: ["consents", encounter.patient.id], diff --git a/src/components/pluggables/FacilityHomeActions.tsx b/src/components/pluggables/FacilityHomeActions.tsx index 54928ac..d0d6854 100644 --- a/src/components/pluggables/FacilityHomeActions.tsx +++ b/src/components/pluggables/FacilityHomeActions.tsx @@ -42,7 +42,7 @@ const FacilityHomeActions: FC> = ({ + + + + ); +} + +function CaptionLabel({ + children, + showYearSwitcher, + navView, + setNavView, + displayYears, + ...props +}: { + showYearSwitcher?: boolean; + navView: NavView; + setNavView: React.Dispatch>; + displayYears: { from: number; to: number }; +} & React.HTMLAttributes) { + if (!showYearSwitcher) return {children}; + return ( + + ); +} + +function MonthGrid({ + className, + children, + displayYears, + startMonth, + endMonth, + navView, + setNavView, + ...props +}: { + className?: string; + children: React.ReactNode; + displayYears: { from: number; to: number }; + startMonth?: Date; + endMonth?: Date; + navView: NavView; + setNavView: React.Dispatch>; +} & React.TableHTMLAttributes) { + if (navView === "years") { + return ( + + ); + } + return ( + + {children} +
+ ); +} + +function YearGrid({ + className, + displayYears, + startMonth, + endMonth, + setNavView, + navView, + ...props +}: { + className?: string; + displayYears: { from: number; to: number }; + startMonth?: Date; + endMonth?: Date; + setNavView: React.Dispatch>; + navView: NavView; +} & React.HTMLAttributes) { + const { goToMonth, selected, dayPickerProps } = useDayPicker(); + let selectedDate: Date | undefined; + switch (dayPickerProps.mode) { + case "single": + selectedDate = selected as Date | undefined; + break; + case "range": + selectedDate = (selected as DateRange | undefined)?.from; + break; + case "multiple": + selectedDate = (selected as Date[] | undefined)?.[0]; + break; + } + + return ( +
+ {Array.from( + { length: displayYears.to - displayYears.from + 1 }, + (_, i) => { + const isBefore = + differenceInCalendarDays( + new Date(displayYears.from + i, 11, 31), + startMonth!, + ) < 0; + + const isAfter = + differenceInCalendarDays( + new Date(displayYears.from + i, 0, 0), + endMonth!, + ) > 0; + + const isDisabled = isBefore || isAfter; + return ( + + ); + }, + )} +
+ ); } -Calendar.displayName = "Calendar" -export { Calendar } +export { Calendar }; diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index efd1855..b9491cb 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -1,7 +1,10 @@ import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; + import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { @@ -10,38 +13,70 @@ import { PopoverTrigger, } from "@/components/ui/popover"; -type DatePickerProps = { - value?: Date; +interface DatePickerProps { + date?: Date; onChange?: (date?: Date) => void; - placeholder?: string; -}; + disabled?: (date: Date) => boolean; + className?: string; + disablePicker?: boolean; + dateFormat?: string; +} + +export function DatePicker({ + date, + onChange, + disabled, + className, + disablePicker, + dateFormat = "PPP", +}: DatePickerProps) { + const { t } = useTranslation(); + + const [open, setOpen] = useState(false); -export function DatePicker({ value, onChange, placeholder }: DatePickerProps) { return ( - + - + { + onChange?.(date); + setOpen(false); + }} + captionLayout="dropdown" + endMonth={new Date(2100, 11, 31)} + autoFocus + disabled={disabled} /> diff --git a/src/components/ui/date-range-picker.tsx b/src/components/ui/date-range-picker.tsx index cd9879a..79d853e 100644 --- a/src/components/ui/date-range-picker.tsx +++ b/src/components/ui/date-range-picker.tsx @@ -1,68 +1,255 @@ -import * as React from "react"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; +import { format, isBefore, isSameDay, isToday } from "date-fns"; +import { CalendarIcon, ChevronDown } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { DateRange } from "react-day-picker"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; + import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; -type DatePickerWithRangeProps = React.HTMLAttributes & { +interface DatePickerWithRangeProps { value?: DateRange; onChange?: (date?: DateRange) => void; + className?: string; placeholder?: string; -}; + disablePicker?: boolean; +} + +function normalizeToDate(date: Date | undefined): Date | undefined { + if (!date) return undefined; + return isToday(date) ? new Date() : date; +} + +function normalizeDateRange(date: DateRange | undefined): DateRange | undefined { + if (!date) return date; + return { + ...date, + to: normalizeToDate(date.to), + }; +} + +function formatDateRange(value?: DateRange) { + if (!value?.from) return null; + + if (value.to && !isSameDay(value.from, value.to)) { + const needsYear = value.from.getFullYear() !== value.to.getFullYear(); + return [value.from, value.to] + .map((date) => format(date, needsYear ? "d MMM yy" : "d MMM")) + .join(" - "); + } + + const presentDate = value.to ?? value.from; + return format(presentDate, "d MMM yyyy"); +} export function DatePickerWithRange({ value, onChange, className, placeholder, + disablePicker, }: DatePickerWithRangeProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [showManualInputs, setShowManualInputs] = useState(false); + const [dateFrom, setDateFrom] = useState(value?.from); + const [dateTo, setDateTo] = useState(value?.to); + const fromInputRef = useRef(null); + + useEffect(() => { + setDateFrom(value?.from); + setDateTo(value?.to); + }, [value]); + + useEffect(() => { + if (showManualInputs) { + fromInputRef.current?.focus(); + } + }, [showManualInputs]); + + const handleDateChange = (date: DateRange | undefined) => { + const normalized = normalizeDateRange(date); + setDateFrom(normalized?.from); + setDateTo(normalized?.to); + onChange?.(normalized); + }; + + const formattedRange = formatDateRange(value); + return ( -
- - + { + setOpen(nextOpen); + if (!nextOpen) { + setShowManualInputs(false); + } + }} + modal={!showManualInputs} + > + + + + e.preventDefault()} + onPointerDownOutside={(e) => { + if (showManualInputs) { + e.preventDefault(); + } + }} + onFocusOutside={(e) => { + if (showManualInputs) { + e.preventDefault(); + } + }} + onInteractOutside={(e) => { + if (showManualInputs) { + e.preventDefault(); + } + }} + onWheel={(e) => e.stopPropagation()} + > + {!showManualInputs ? ( + { + if (date) { + handleDateChange(date); + } + }} + styles={{ + day: { + width: "40px", + }, + weekdays: { + width: "100%", + justifyContent: "space-between", + }, + nav: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + padding: "0.5rem", + }, + }} + className="w-full" + captionLayout="dropdown" + endMonth={new Date(2100, 11, 31)} + monthCaptionClassName="self-center" + rangeMiddleClassName="bg-primary/10 [&>button]:rounded-md" + /> + ) : ( +
e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > +
+ + { + const nextFrom = e.target.value + ? new Date(e.target.value) + : undefined; + setDateFrom(nextFrom); + onChange?.({ from: nextFrom, to: dateTo }); + }} + placeholder={t("start_date")} + className="text-sm" + /> +
+
+ + { + const nextTo = normalizeToDate( + e.target.value ? new Date(e.target.value) : undefined, + ); + setDateTo(nextTo); + onChange?.({ from: dateFrom, to: nextTo }); + }} + placeholder={t("end_date")} + className="text-sm" + /> +
+
+ )} + +
- - - + + + +
+ +
+
); } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 267829b..3c6e837 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -3,15 +3,26 @@ import * as React from "react" import { cn } from "@/lib/utils" const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { + ({ className, type, onClick, onFocus, onWheel, ...props }, ref) => { return ( { + if (type === "date") { + e.currentTarget.showPicker(); + } + onClick?.(e); + }} + onFocus={onFocus} + onWheel={(e) => { + e.currentTarget.blur(); + onWheel?.(e); + }} {...props} /> ) diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 92de977..4d0ee0e 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -14,16 +14,18 @@ const PopoverContent = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - +
+ +
)) PopoverContent.displayName = PopoverPrimitive.Content.displayName diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index c3f40f4..4e51054 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -69,29 +69,31 @@ function SelectContent({ }: React.ComponentProps) { return ( - - - + - {children} - - - + + + {children} + + + +
); } diff --git a/src/lib/patches/filterHiBundleByConsentHip.ts b/src/lib/patches/filterHiBundleByConsentHip.ts new file mode 100644 index 0000000..15a0397 --- /dev/null +++ b/src/lib/patches/filterHiBundleByConsentHip.ts @@ -0,0 +1,77 @@ +// TEMPORARY PATCH: Remove when MedicationRequest is changed from date based to prescription based. + +const ORGANIZATION_PROFILE = + "https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization"; +const FACILITY_ID_CODE = "FI"; + +type FhirIdentifier = { + system?: string; + value?: string; + type?: { + coding?: Array<{ code?: string; display?: string; system?: string }>; + text?: string; + }; +}; + +type FhirResource = { + resourceType?: string; + meta?: { profile?: string[] }; + identifier?: FhirIdentifier | FhirIdentifier[]; +}; + +type FhirBundleEntry = { + resource?: FhirResource; +}; + +export type FhirBundle = { + entry?: FhirBundleEntry[]; +}; + +function getOrganizationFromBundle( + bundle: FhirBundle, +): FhirResource | undefined { + return bundle.entry?.find( + (entry) => + entry.resource?.resourceType === "Organization" || + entry.resource?.meta?.profile?.includes(ORGANIZATION_PROFILE), + )?.resource; +} + +function getFacilityIdIdentifier( + organization: FhirResource, +): FhirIdentifier | undefined { + const identifiers = Array.isArray(organization.identifier) + ? organization.identifier + : organization.identifier + ? [organization.identifier] + : []; + + return identifiers.find((identifier) => + identifier.type?.coding?.some((coding) => coding.code === FACILITY_ID_CODE), + ); +} + +/** + * Skip bundles whose Organization Facility ID differs from the consent HIP. + * Allow when the identifier is missing or matches the consent HIP. + */ +export function shouldIncludeHiBundle( + bundle: FhirBundle, + consentHip: string | null | undefined, +): boolean { + if (!consentHip) { + return true; + } + + const organization = getOrganizationFromBundle(bundle); + if (!organization) { + return true; + } + + const facilityIdentifier = getFacilityIdIdentifier(organization); + if (!facilityIdentifier?.value) { + return true; + } + + return facilityIdentifier.value === consentHip; +} diff --git a/src/lib/paths.ts b/src/lib/paths.ts new file mode 100644 index 0000000..9bce59e --- /dev/null +++ b/src/lib/paths.ts @@ -0,0 +1,17 @@ +export function healthInformationPath( + facilityId: string, + patientId: string, + encounterId: string, + artefactId: string, +) { + return `/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}/healthInformation/${artefactId}`; +} + +export function encounterPath( + facilityId: string, + patientId: string, + encounterId: string, + tab = "updates", +) { + return `/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}/${tab}`; +} diff --git a/src/routes.tsx b/src/routes.tsx index 52f1cdf..7a60246 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,9 +1,15 @@ import HealthInformation from "@/components/pages/HealthInformation"; const routes = { - "/abdm/health-information/:id": ({ id }: { id: string }) => ( - - ), + "/facility/:facilityId/patient/:patientId/encounter/:encounterId/healthInformation/:id": + ({ facilityId, patientId, encounterId, id }: { facilityId: string, patientId: string, encounterId: string, id: string }) => ( + + ), }; export default routes; diff --git a/src/types/abhaNumber.ts b/src/types/abhaNumber.ts index fdd0af7..e9688cb 100644 --- a/src/types/abhaNumber.ts +++ b/src/types/abhaNumber.ts @@ -22,3 +22,13 @@ export type AbhaNumber = { patient: string | null; patient_object: unknown | null; // FIXME: Patient type }; + +export type AbhaLoginAccount = { + id: number; + abha_number: string | null; + preferred_abha_address: string | null; + name: string | null; + gender: "F" | "M" | "O" | null; + date_of_birth: string | null; + profile_photo: string | null; +};