diff --git a/src/Common/constants.tsx b/src/Common/constants.tsx index 307e912844f..70b5bc78e23 100644 --- a/src/Common/constants.tsx +++ b/src/Common/constants.tsx @@ -1,4 +1,7 @@ -import { PatientCategory } from "../Components/Facility/models"; +import { + PatientCategory, + SpokeRelationship, +} from "../Components/Facility/models"; import { SortOption } from "../Components/Common/SortDropdown"; import { dateQueryString } from "../Utils/utils"; import { IconName } from "../CAREUI/icons/CareIcon"; @@ -1517,6 +1520,17 @@ export const DEFAULT_ALLOWED_EXTENSIONS = [ "application/vnd.oasis.opendocument.spreadsheet,application/pdf", ]; +export const SPOKE_RELATION_TYPES = [ + { + text: "Regular", + value: SpokeRelationship.REGULAR, + }, + { + text: "Tele ICU", + value: SpokeRelationship.TELE_ICU, + }, +]; + export const HumanBodyPaths = { anterior: [ { diff --git a/src/Components/Common/FacilitySelect.tsx b/src/Components/Common/FacilitySelect.tsx index d718ef3e781..d91b9a7f8fc 100644 --- a/src/Components/Common/FacilitySelect.tsx +++ b/src/Components/Common/FacilitySelect.tsx @@ -23,6 +23,8 @@ interface FacilitySelectProps { selected?: FacilityModel | FacilityModel[] | null; setSelected: (selected: FacilityModel | FacilityModel[] | null) => void; allowNone?: boolean; + placeholder?: string; + filter?: (facilities: FacilityModel) => boolean; } export const FacilitySelect = (props: FacilitySelectProps) => { @@ -44,6 +46,8 @@ export const FacilitySelect = (props: FacilitySelectProps) => { allowNone = false, freeText = false, errors = "", + placeholder, + filter, } = props; const facilitySearch = useCallback( @@ -82,6 +86,7 @@ export const FacilitySelect = (props: FacilitySelectProps) => { return ( { compareBy="id" className={className} error={errors} + filter={filter} /> ); }; diff --git a/src/Components/Facility/FacilityBlock.tsx b/src/Components/Facility/FacilityBlock.tsx new file mode 100644 index 00000000000..64c0a24d78d --- /dev/null +++ b/src/Components/Facility/FacilityBlock.tsx @@ -0,0 +1,34 @@ +import { Link } from "raviger"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import { FacilityModel } from "./models"; + +export default function FacilityBlock(props: { facility: FacilityModel }) { + const { facility } = props; + + return ( + +
+ {facility.read_cover_image_url ? ( + + ) : ( + <> + + + )} +
+
+ {facility.name} +

+ {facility.address} {facility.local_body_object?.name} +

+
+ + ); +} diff --git a/src/Components/Facility/FacilityCreate.tsx b/src/Components/Facility/FacilityCreate.tsx index 1fe9d2e207e..e9e95fbcdfc 100644 --- a/src/Components/Facility/FacilityCreate.tsx +++ b/src/Components/Facility/FacilityCreate.tsx @@ -62,6 +62,7 @@ import routes from "../../Redux/api.js"; import useQuery from "../../Utils/request/useQuery.js"; import { RequestResult } from "../../Utils/request/types.js"; import useAuthUser from "../../Common/hooks/useAuthUser"; +import SpokeFacilityEditor from "./SpokeFacilityEditor.js"; import careConfig from "@careConfig"; const Loading = lazy(() => import("../Common/Loading")); @@ -247,7 +248,7 @@ export const FacilityCreate = (props: FacilityProps) => { }, ); - useQuery(routes.getPermittedFacility, { + const facilityQuery = useQuery(routes.getPermittedFacility, { pathParams: { id: facilityId!, }, @@ -850,6 +851,14 @@ export const FacilityCreate = (props: FacilityProps) => { required types={["mobile", "landline"]} /> +
+

{t("spokes")}

+ {facilityId && ( + + )} +
{ }); }; + const spokesQuery = useQuery(routes.getFacilitySpokes, { + pathParams: { + id: facilityId, + }, + silent: true, + }); + if (isLoading) { return ; } @@ -289,6 +297,20 @@ export const FacilityHome = ({ facilityId }: Props) => { />
+ {!!spokesQuery.data?.results.length && ( +
+
+

+ {t("spokes")} +

+
+ {spokesQuery.data?.results.map((spoke) => ( + + ))} +
+
+
+ )} diff --git a/src/Components/Facility/SpokeFacilityEditor.tsx b/src/Components/Facility/SpokeFacilityEditor.tsx new file mode 100644 index 00000000000..197d68da2bf --- /dev/null +++ b/src/Components/Facility/SpokeFacilityEditor.tsx @@ -0,0 +1,154 @@ +import routes from "../../Redux/api"; +import request from "../../Utils/request/request"; +import useQuery from "../../Utils/request/useQuery"; +import { SelectFormField } from "../Form/FormFields/SelectFormField"; +import { + FacilityModel, + FacilitySpokeErrors, + FacilitySpokeModel, + FacilitySpokeRequest, + SpokeRelationship, +} from "./models"; +import ModelCrudEditor from "../Form/ModelCrudEditor"; +import { FacilitySelect } from "../Common/FacilitySelect"; +import { useEffect, useState } from "react"; +import { SPOKE_RELATION_TYPES } from "../../Common/constants"; +import FacilityBlock from "./FacilityBlock"; +import { useTranslation } from "react-i18next"; + +export interface SpokeFacilityEditorProps { + facility: Omit & { id: string }; +} + +export default function SpokeFacilityEditor(props: SpokeFacilityEditorProps) { + const { facility } = props; + + const { t } = useTranslation(); + + const spokesQuery = useQuery(routes.getFacilitySpokes, { + pathParams: { + id: facility.id, + }, + }); + + const spokes = spokesQuery.data?.results; + + const createSpoke = (body: FacilitySpokeRequest) => + request(routes.createFacilitySpoke, { + body, + pathParams: { + id: facility.id, + }, + onResponse: ({ res }) => { + if (res?.ok) { + spokesQuery.refetch(); + } + }, + }); + + const deleteSpoke = (spokeFacilityId: string) => + request(routes.deleteFacilitySpoke, { + pathParams: { + id: facility.id, + spoke_id: spokeFacilityId, + }, + onResponse: ({ res }) => { + if (res?.ok) { + spokesQuery.refetch(); + } + }, + }); + + const updateSpoke = (spokeFacilityId: string, body: FacilitySpokeRequest) => + request(routes.updateFacilitySpokes, { + pathParams: { + id: facility.id, + spoke_id: spokeFacilityId, + }, + body, + onResponse: ({ res }) => { + if (res?.ok) { + spokesQuery.refetch(); + } + }, + }); + + const FormRender = ( + item: FacilitySpokeModel | FacilitySpokeRequest, + setItem: (item: FacilitySpokeModel | FacilitySpokeRequest) => void, + processing: boolean, + ) => { + const [selectedFacility, setSelectedFacility] = useState(); + + useEffect(() => { + setItem({ ...item, spoke: selectedFacility?.id }); + }, [selectedFacility]); + + return ( +
+ {"id" in item ? ( +
+ +
+ ) : ( + + v && !Array.isArray(v) && setSelectedFacility(v) + } + errors="" + className="w-full" + disabled={processing} + filter={(f) => + !!f.id && + facility.id !== f.id && + !spokes?.flatMap((s) => s.spoke_object.id).includes(f.id) + } + /> + )} + v.text} + optionValue={(v) => v.value} + value={item.relationship} + onChange={(v) => setItem({ ...item, relationship: v.value })} + errorClassName="hidden" + className="w-full shrink-0 md:w-auto" + disabled={processing} + /> +
+ ); + }; + + return ( + <> + + items={spokes} + onCreate={createSpoke} + onUpdate={updateSpoke} + onDelete={deleteSpoke} + loading={spokesQuery.loading} + errors={{}} + emptyText={"No Spokes"} + empty={{ + spoke: "", + relationship: SpokeRelationship.REGULAR, + }} + createText="Add Spoke" + allowCreate={(item) => !item.relationship || !item.spoke} + > + {FormRender} + + + ); +} diff --git a/src/Components/Facility/models.tsx b/src/Components/Facility/models.tsx index 7756bf73f69..97d81674658 100644 --- a/src/Components/Facility/models.tsx +++ b/src/Components/Facility/models.tsx @@ -12,6 +12,7 @@ import { AssignedToObjectModel, BloodPressure, DailyRoundsModel, + FacilityNameModel, FileUploadModel, } from "../Patient/models"; import { EncounterSymptom } from "../Symptoms/types"; @@ -79,8 +80,32 @@ export interface FacilityModel { local_body?: number; ward?: number; pincode?: string; + latitude?: string; + longitude?: string; + kasp_empanelled?: boolean; + patient_count?: string; + bed_count?: string; +} + +export enum SpokeRelationship { + REGULAR = 1, + TELE_ICU = 2, +} + +export interface FacilitySpokeModel { + id: string; + hub_object: FacilityNameModel; + spoke_object: FacilityNameModel; + relationship: SpokeRelationship; } +export interface FacilitySpokeRequest { + spoke?: string; + relationship?: SpokeRelationship; +} + +export interface FacilitySpokeErrors {} + export interface CapacityModal { id?: number; room_type?: number; @@ -588,13 +613,7 @@ export type IUserFacilityRequest = { facility: string; }; -export type FacilityRequest = Omit & { - latitude?: string; - longitude?: string; - kasp_empanelled?: boolean; - patient_count?: string; - bed_count?: string; -}; +export type FacilityRequest = Omit; export type InventorySummaryResponse = { id: string; diff --git a/src/Components/Form/AutoCompleteAsync.tsx b/src/Components/Form/AutoCompleteAsync.tsx index 18bffb0e11c..f362918dfc2 100644 --- a/src/Components/Form/AutoCompleteAsync.tsx +++ b/src/Components/Form/AutoCompleteAsync.tsx @@ -36,6 +36,7 @@ interface Props { required?: boolean; onBlur?: () => void; onFocus?: () => void; + filter?: (data: any) => boolean; } const AutoCompleteAsync = (props: Props) => { @@ -56,6 +57,7 @@ const AutoCompleteAsync = (props: Props) => { disabled = false, required = false, error, + filter, } = props; const [data, setData] = useState([]); const [query, setQuery] = useState(""); @@ -69,7 +71,9 @@ const AutoCompleteAsync = (props: Props) => { () => debounce(async (query: string) => { setLoading(true); - const data = (await fetchData(query)) || []; + const data = ((await fetchData(query)) || [])?.filter((d: any) => + filter ? filter(d) : true, + ); if (showNOptions !== undefined) { setData(data.slice(0, showNOptions)); diff --git a/src/Components/Form/ModelCrudEditor.tsx b/src/Components/Form/ModelCrudEditor.tsx new file mode 100644 index 00000000000..3ce4b15eba3 --- /dev/null +++ b/src/Components/Form/ModelCrudEditor.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import { classNames } from "../../Utils/utils"; +import ButtonV2 from "../Common/components/ButtonV2"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import { useTranslation } from "react-i18next"; + +interface Identifier { + id: string; +} + +export interface ModelCrudEditorProps { + onCreate?: (req: TReq) => Promise; + onUpdate?: (itemId: string, req: TReq) => Promise; + onDelete?: (itemId: string) => Promise; + items?: TRes[]; + children: ( + item: TRes | TReq, + setItem: (item: TRes | TReq) => void, + processing: boolean, + errors?: TErr, + ) => React.ReactNode; + loading: boolean; + errors: TErr; + emptyText?: React.ReactNode; + empty: TReq; + createText?: React.ReactNode; + allowCreate?: (item: TReq) => boolean; +} + +export default function ModelCrudEditor( + props: ModelCrudEditorProps, +) { + const { t } = useTranslation(); + + const { + onCreate, + onUpdate, + onDelete, + items, + children, + loading, + errors, + emptyText, + empty, + createText, + allowCreate, + } = props; + const [creating, setCreating] = useState(false); + const [updating, setUpdating] = useState(null); + + const handleUpdate = async (itemId: string, item: TReq) => { + if (!onUpdate) return; + setUpdating(itemId); + await onUpdate(itemId, item); + setUpdating(null); + }; + + const handleDelete = async (itemId: string) => { + if (!onDelete) return; + setUpdating(itemId); + await onDelete(itemId); + setUpdating(null); + }; + + const handleCreate = async (item: TReq) => { + if (!onCreate) return; + setCreating(true); + await onCreate(item); + setCreating(false); + }; + + type FormProps = + | { + type: "creating"; + item: TReq; + } + | { + type: "updating"; + item: TRes; + }; + + const Form = (props: FormProps) => { + const [item, setItem] = useState(props.item); + const processing = + props.type === "creating" ? creating : props.item.id === updating; + + useEffect(() => { + if ( + props.type === "updating" && + JSON.stringify(item) !== JSON.stringify(props.item) + ) { + const timeout = setTimeout(() => { + handleUpdate(props.item.id, item as TReq); + }, 1000); + return () => clearTimeout(timeout); + } + }, [item]); + + return ( +
+ {children(item, setItem, processing, errors)} + {props.type === "creating" && ( + handleCreate(item as TReq)} + > + {createText || "Create"} + + )} + {props.type === "updating" && onDelete && ( + + )} +
+ ); + }; + + return ( +
+
    + {items?.map((item, i) => ( +
  • +
    +
  • + ))} + + {items?.length === 0 && ( +
    + {emptyText} +
    + )} +
+
+ +
+
+ ); +} diff --git a/src/Locale/en/Common.json b/src/Locale/en/Common.json index 661a1872cca..7f0b9f51802 100644 --- a/src/Locale/en/Common.json +++ b/src/Locale/en/Common.json @@ -175,6 +175,9 @@ "summary": "Summary", "report": "Report", "treating_doctor": "Treating Doctor", + "hubs": "Hub Facilities", + "spokes": "Spoke Facilities", + "add_spoke" : "Add Spoke Facility", "ration_card__NO_CARD": "Non-card holder", "ration_card__BPL": "BPL", "ration_card__APL": "APL", diff --git a/src/Redux/api.tsx b/src/Redux/api.tsx index 0243db57df7..0bedb12dca5 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -26,6 +26,8 @@ import { DoctorModal, FacilityModel, FacilityRequest, + FacilitySpokeModel, + FacilitySpokeRequest, IFacilityNotificationRequest, IFacilityNotificationResponse, IUserFacilityRequest, @@ -366,7 +368,7 @@ const routes = { getPermittedFacility: { path: "/api/v1/facility/{id}/", method: "GET", - TRes: Type(), + TRes: Type(), }, getAnyFacility: { @@ -389,6 +391,38 @@ const routes = { TBody: Type>(), }, + getFacilitySpokes: { + path: "/api/v1/facility/{id}/spokes/", + method: "GET", + TRes: Type>(), + }, + + updateFacilitySpokes: { + path: "/api/v1/facility/{id}/spokes/{spoke_id}/", + method: "PATCH", + TRes: Type(), + TBody: Type(), + }, + + getFacilitySpoke: { + path: "/api/v1/facility/{id}/spokes/{spoke_id}/", + method: "GET", + TRes: Type(), + }, + + createFacilitySpoke: { + path: "/api/v1/facility/{id}/spokes/", + method: "POST", + TRes: Type(), + TBody: Type>(), + }, + + deleteFacilitySpoke: { + path: "/api/v1/facility/{id}/spokes/{spoke_id}/", + method: "DELETE", + TRes: Type>(), + }, + deleteFacilityCoverImage: { path: "/api/v1/facility/{id}/cover_image/", method: "DELETE",