diff --git a/public/locale/en.json b/public/locale/en.json
index 11f81a52fbc..9aba8fe806e 100644
--- a/public/locale/en.json
+++ b/public/locale/en.json
@@ -289,8 +289,12 @@
"add_attachments": "Add Attachments",
"add_beds": "Add Bed(s)",
"add_beds_to_configure_presets": "Add beds to this location to configure presets for them.",
+ "add_consultation": "Add consultation",
+ "add_consultation_update": "Add Consultation Update",
+ "add_contact_point": "Add Contact Point",
"add_department_team": "Add Department/Team",
"add_details_of_patient": "Add Details of Patient",
+ "add_device": "Add Device",
"add_encounter": "Add Encounter",
"add_exception": "Add Exception",
"add_facility": "Add Facility",
@@ -418,6 +422,10 @@
"assigned_facility": "Facility assigned",
"assigned_to": "Assigned to",
"assigned_volunteer": "Assigned Volunteer",
+ "associate": "Associate",
+ "associate_location": "Associate Location",
+ "associate_location_description": "Select a location to associate with this device",
+ "associating": "Associating...",
"at_least_one_department_is_required": "At least one department is required",
"at_time": "at {{time}}",
"atypical_presentation_details": "Atypical presentation details",
@@ -441,6 +449,7 @@
"auto_generated_for_care": "Auto Generated for Care",
"autofilled_fields": "Autofilled Fields",
"availabilities": "Availabilities",
+ "availability_status": "Availability Status",
"available_features": "Available Features",
"available_in": "Available in",
"available_time_slots": "Available Time Slots",
@@ -512,9 +521,11 @@
"category_description": "Choose the category ",
"caution": "Caution",
"central_nursing_station": "Central Nursing Station",
+ "change": "Change",
"change_avatar": "Change Avatar",
"change_avatar_note": "JPG, GIF or PNG. 1MB max.",
"change_file": "Change File",
+ "change_location": "Change Location",
"change_phone_number": "Change Phone Number",
"change_status": "Change Status",
"chat_on_whatsapp": "Chat on Whatsapp",
@@ -619,6 +630,26 @@
"contact_person_number": "Contact person number",
"contact_phone": "Contact Person Number",
"contact_phone_description": "Phone number to reach the contact person.",
+ "contact_point_placeholder__email": "Enter email address",
+ "contact_point_placeholder__fax": "Enter fax number",
+ "contact_point_placeholder__other": "Enter contact value",
+ "contact_point_placeholder__pager": "Enter pager number",
+ "contact_point_placeholder__phone": "Enter phone number",
+ "contact_point_placeholder__sms": "Enter SMS number",
+ "contact_point_placeholder__url": "Enter URL",
+ "contact_points": "Contact Points",
+ "contact_system_email": "Email",
+ "contact_system_fax": "Fax",
+ "contact_system_other": "Other",
+ "contact_system_pager": "Pager",
+ "contact_system_phone": "Phone",
+ "contact_system_sms": "SMS",
+ "contact_system_url": "URL",
+ "contact_use_home": "Home",
+ "contact_use_mobile": "Mobile",
+ "contact_use_old": "Old",
+ "contact_use_temp": "Temporary",
+ "contact_use_work": "Work",
"contact_with_confirmed_carrier": "Contact with confirmed carrier",
"contact_with_suspected_carrier": "Contact with suspected carrier",
"contact_your_admin_to_add_facilities": "Contact your admin to add facilities",
@@ -672,6 +703,7 @@
"criticality": "Criticality",
"csv_file_in_the_specified_format": "Select a CSV file in the specified format",
"current_address": "Current Address",
+ "current_location_description": "The current location of this device",
"current_organizations": "Current Organizations",
"current_password": "Current Password",
"current_role": "Current Role",
@@ -698,12 +730,15 @@
"date_of_return": "Date of Return",
"date_of_test": "Date of sample collection for Covid testing",
"date_range": "Date Range",
+ "dates_and_identifiers": "Dates & Identifiers",
"day": "Day",
"death_report": "Death Report",
"delete": "Delete",
"delete_account": "Delete account",
"delete_account_btn": "Yes, delete this account",
"delete_account_note": "Deleting this account will remove all associated data and cannot be undone.",
+ "delete_device": "Delete Device",
+ "delete_device_confirmation": "Are you sure you want to delete this device? This action cannot be undone.",
"delete_facility": "Delete Facility",
"delete_facility_confirmation": "Are you sure you want to delete {{name}}? This action cannot be undone.",
"delete_item": "Delete {{name}}",
@@ -723,6 +758,17 @@
"details_of_origin_facility": "Details of origin facility",
"details_of_patient": "Details of patient",
"details_of_shifting_approving_facility": "Details of shifting approving facility",
+ "device_availability_status_available": "Available",
+ "device_availability_status_damaged": "Damaged",
+ "device_availability_status_destroyed": "Destroyed",
+ "device_availability_status_lost": "Lost",
+ "device_contact_description": "Contact points associated with this device",
+ "device_information": "Device Information",
+ "device_not_found": "Device not found",
+ "device_status_active": "Active",
+ "device_status_entered_in_error": "Entered in Error",
+ "device_status_inactive": "Inactive",
+ "devices": "Devices",
"diagnoses": "Diagnoses",
"diagnosis": "Diagnosis",
"diagnosis__confirmed": "Confirmed",
@@ -802,6 +848,8 @@
"edit_avatar_permission_error": "You do not have permissions to edit the avatar of this user",
"edit_caution_note": "A new prescription will be added to the consultation with the edited details and the current prescription will be discontinued.",
"edit_cover_photo": "Edit Cover Photo",
+ "edit_device": "Edit Device",
+ "edit_device_description": "Edit the details of the device",
"edit_facility": "Edit Facility",
"edit_facility_details": "Edit Facility Details",
"edit_history": "Edit History",
@@ -950,20 +998,29 @@
"end_time_before_start_error": "End time cannot be before start time",
"end_time_future_error": "End time cannot be in the future",
"ended": "Ended",
+ "enter_contact_value": "Enter contact value",
"enter_department_team_description": "Enter department/team description (optional)",
"enter_department_team_name": "Enter department/team name",
"enter_dosage_instructions": "Enter Dosage Instructions",
"enter_file_name": "Enter File Name",
+ "enter_identifier": "Enter device identifier",
+ "enter_lot_number": "Enter lot number",
+ "enter_manufacturer": "Enter manufacturer name",
"enter_message": "Start typing...",
"enter_mobile_number": "Enter Mobile Number",
"enter_mobile_otp": "Enter OTP sent to the given mobile number",
+ "enter_model_number": "Enter model number",
"enter_otp": "Enter OTP sent to the registered mobile with the respective ID",
+ "enter_part_number": "Enter part number",
"enter_phone_number": "Enter phone number",
"enter_phone_number_to_login_register": "Enter phone number to login/register",
+ "enter_registered_name": "Enter the registered name of the device",
+ "enter_serial_number": "Enter serial number",
"enter_tag_name": "Enter tag name",
"enter_tag_slug": "Enter tag slug",
"enter_the_file_name": "Enter the file name",
"enter_the_verification_code": "Enter the verification code sent to your phone",
+ "enter_user_friendly_name": "Enter a user friendly name for the device",
"enter_valid_age": "Please Enter Valid Age",
"enter_valid_dob": "Enter a valid date of birth",
"enter_valid_dob_age": "Please enter an age greater than 15 years",
@@ -999,8 +1056,10 @@
"exceptions": "Exceptions",
"expand_sidebar": "Expand Sidebar",
"expected_burn_rate": "Expected Burn Rate",
+ "expiration_date": "Expiration Date",
"expired": "Expired",
"expired_on": "Expired On",
+ "expires": "Expires",
"expires_on": "Expires On",
"export": "Export",
"export_live_patients": "Export Live Patients",
@@ -1145,6 +1204,7 @@
"i_declare": "I hereby declare that:",
"icd11_as_recommended": "As per ICD-11 recommended by WHO",
"icmr_specimen_referral_form": "ICMR Specimen Referral Form",
+ "identifier": "Identifier",
"immunisation-records": "Immunisation",
"in_consultation": "In-Consultation",
"in_progress": "In Progress",
@@ -1276,12 +1336,34 @@
"local_ip_address": "Local IP Address",
"local_ip_address_example": "e.g. 192.168.0.123",
"location": "Location",
+ "location_associated_successfully": "Location associated successfully",
"location_beds_empty": "No beds available in this location",
"location_created": "Location Created",
+ "location_description": "Location Description",
"location_details": "Location Details",
"location_form": "Location Form",
+ "location_form__area": "Area",
+ "location_form__bd": "Bed",
+ "location_form__bu": "Building",
+ "location_form__ca": "Cabinet",
+ "location_form__co": "Corridor",
+ "location_form__ho": "House",
+ "location_form__jdn": "Jurisdiction",
+ "location_form__lvl": "Level",
+ "location_form__rd": "Road",
+ "location_form__ro": "Room",
+ "location_form__si": "Site",
+ "location_form__ve": "Vehicle",
+ "location_form__vi": "Virtual",
+ "location_form__wa": "Ward",
+ "location_form__wi": "Wing",
"location_history": "Location History",
"location_management": "Location Management",
+ "location_name": "Location Name",
+ "location_status": "Location Status",
+ "location_status__active": "Active",
+ "location_status__inactive": "Inactive",
+ "location_status__unknown": "Unknown",
"location_updated": "Location Updated",
"location_updated_successfully": "Location updated successfully",
"locations": "Locations",
@@ -1295,6 +1377,7 @@
"logout": "Log Out",
"longitude": "Longitude",
"longitude_invalid": "Longitude must be between -180 and 180",
+ "lot_number": "Lot Number",
"low": "Low",
"lsg": "Lsg",
"make_facility_public": "Make this facility public",
@@ -1311,6 +1394,8 @@
"manage_tags": "Manage Tags",
"manage_tags_description": "Add or remove tags for this questionnaire",
"manage_user": "Manage User",
+ "manufacture_date": "Manufacture Date",
+ "manufactured": "Manufactured",
"manufacturer": "Manufacturer",
"map_acronym": "M.A.P.",
"mark_active": "Mark Active",
@@ -1367,6 +1452,7 @@
"mobile_otp_send_success": "OTP has been sent to the given mobile number.",
"mobile_otp_verify_error": "Failed to verify mobile number. Please try again later.",
"mobile_otp_verify_success": "Mobile number has been verified successfully.",
+ "model_number": "Model Number",
"moderate": "Moderate",
"modification_caution_note": "No modifications possible once added",
"modified": "Modified",
@@ -1422,6 +1508,8 @@
"no_country_found": "No country found",
"no_data_found": "No data found",
"no_departments_teams_found": "No Departments or Teams found",
+ "no_devices_available": "No devices available",
+ "no_devices_found": "No devices found",
"no_diagnoses_recorded": "No diagnoses recorded",
"no_discharge_summaries_found": "No Discharge Summaries found",
"no_doctors_found": "No Doctors Found",
@@ -1437,6 +1525,8 @@
"no_investigation": "No investigation Reports found",
"no_investigation_suggestions": "No Investigation Suggestions",
"no_linked_facilities": "No Linked Facilities",
+ "no_location": "No location assigned",
+ "no_location_description": "This device is not currently assigned to any location",
"no_locations_available": "No locations available",
"no_locations_found": "No locations found",
"no_log_update_delta": "No changes since previous log update",
@@ -1563,6 +1653,7 @@
"page_not_found": "Page Not Found",
"pain": "Pain",
"pain_chart_description": "Mark region and intensity of pain",
+ "part_number": "Part Number",
"participants": "Participants",
"passport_number": "Passport Number",
"password": "Password",
@@ -1798,6 +1889,7 @@
"register_hospital": "Register Hospital",
"register_page_title": "Register As Hospital Administrator",
"register_patient": "Register Patient",
+ "registered_name": "Registered Name",
"reject": "Reject",
"rejected": "Rejected",
"relapse": "Relapse",
@@ -1943,7 +2035,7 @@
"search_by_department_team_name": "Search by department/team name",
"search_by_emergency_contact_phone_number": "Search by Emergency Contact Phone Number",
"search_by_emergency_phone_number": "Search by Emergency Phone Number",
- "search_by_name": "Search by Name",
+ "search_by_name": "Search by name",
"search_by_patient_name": "Search by Patient Name",
"search_by_patient_no": "Search by Patient Number",
"search_by_phone_number": "Search by Phone Number",
@@ -1951,6 +2043,7 @@
"search_by_user_name": "Search by user name",
"search_by_username": "Search by username",
"search_country": "Search country...",
+ "search_devices": "Search Devices",
"search_encounters": "Search Encounters",
"search_for_allergies_to_add": "Search for allergies to add",
"search_for_diagnoses_to_add": "Search for diagnoses to add",
@@ -1978,8 +2071,11 @@
"select_additional_instructions": "Select additional instructions",
"select_admit_source": "Select Admit Source",
"select_all": "Select All",
+ "select_availability_status": "Select availability status",
"select_category": "Select a category",
"select_class": "Select Class",
+ "select_contact_system": "Select contact type",
+ "select_contact_use": "Select contact use",
"select_date": "Select date",
"select_department": "Select Department",
"select_diet_preference": "Select diet preference",
@@ -2018,7 +2114,7 @@
"select_seven_day_period": "Select a seven day period",
"select_site": "Select site",
"select_skills": "Select and add some skills",
- "select_status": "Select Status",
+ "select_status": "Select status",
"select_sub_department": "Select sub-department",
"select_time": "Select time",
"select_time_slot": "Select time slot",
@@ -2264,6 +2360,7 @@
"update_available": "Update Available",
"update_bed": "Update Bed",
"update_department": "Update Department",
+ "update_device": "Update Device",
"update_encounter": "Update Encounter",
"update_encounter_details": "Update Encounter Details",
"update_existing_facility": "Update the details of the existing facility.",
@@ -2302,6 +2399,7 @@
"upload_headings__supporting_info": "Upload Supporting Info",
"upload_report": "Upload Report",
"uploading": "Uploading",
+ "use": "Use",
"use_address_as_permanent": "Use this address for permanent address",
"use_phone_number_for_emergency": "Use this phone number for emergency contact",
"user_add_error": "Error while adding User",
@@ -2312,6 +2410,7 @@
"user_details": "User Details",
"user_details_update_error": "Error while updating user details",
"user_details_update_success": "User details updated successfully",
+ "user_friendly_name": "User Friendly Name",
"user_management": "User Management",
"user_not_available_for_appointments": "This user is not available for appointments",
"user_qualifications": "Qualifications",
@@ -2342,6 +2441,7 @@
"valid_otp_found": "Valid OTP found, Navigating to Appointments",
"valid_to": "Valid Till",
"valid_year_of_birth": "Please enter a valid year of birth (YYYY)",
+ "value": "Value",
"value_set": "Value Set",
"valuesets": "Valuesets",
"vehicle_preference": "Vehicle preference",
diff --git a/src/components/Location/LocationSearch.tsx b/src/components/Location/LocationSearch.tsx
index a27d8e8d1ec..288a283773e 100644
--- a/src/components/Location/LocationSearch.tsx
+++ b/src/components/Location/LocationSearch.tsx
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import {
Command,
@@ -33,6 +34,7 @@ export function LocationSearch({
disabled,
value,
}: LocationSearchProps) {
+ const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -60,6 +62,7 @@ export function LocationSearch({
No locations found.
@@ -73,7 +76,15 @@ export function LocationSearch({
setOpen(false);
}}
>
- {location.name}
+ {location.name}
+
+ {t(`location_form__${location.form}`)}
+ {" in "}
+ {formatLocationParent(location)}
+
+
+ {t(`location_status__${location.status}`)}
+
))}
@@ -82,3 +93,12 @@ export function LocationSearch({
);
}
+
+const formatLocationParent = (location: LocationList) => {
+ const parents: string[] = [];
+ while (location.parent?.name) {
+ parents.push(location.parent?.name);
+ location = location.parent;
+ }
+ return parents.reverse().join(" > ");
+};
diff --git a/src/pages/Facility/settings/devices/CreateDevice.tsx b/src/pages/Facility/settings/devices/CreateDevice.tsx
new file mode 100644
index 00000000000..a12879af7dd
--- /dev/null
+++ b/src/pages/Facility/settings/devices/CreateDevice.tsx
@@ -0,0 +1,32 @@
+import { navigate } from "raviger";
+import { useTranslation } from "react-i18next";
+
+import { Separator } from "@/components/ui/separator";
+
+import PageTitle from "@/components/Common/PageTitle";
+
+import DeviceForm from "@/pages/Facility/settings/devices/components/DeviceForm";
+
+interface Props {
+ facilityId: string;
+}
+
+export default function CreateDevice({ facilityId }: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {
+ navigate(`/facility/${facilityId}/settings/devices`);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/DeviceDetail.tsx b/src/pages/Facility/settings/devices/DeviceDetail.tsx
new file mode 100644
index 00000000000..5f526a919fd
--- /dev/null
+++ b/src/pages/Facility/settings/devices/DeviceDetail.tsx
@@ -0,0 +1,377 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ExternalLink } from "lucide-react";
+import { Link, navigate } from "raviger";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { cn } from "@/lib/utils";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+
+import Loading from "@/components/Common/Loading";
+import PageTitle from "@/components/Common/PageTitle";
+
+import mutate from "@/Utils/request/mutate";
+import query from "@/Utils/request/query";
+import { ContactPoint } from "@/types/common/contactPoint";
+import deviceApi from "@/types/device/deviceApi";
+
+import AssociateLocationSheet from "./components/AssociateLocationSheet";
+
+interface Props {
+ facilityId: string;
+ deviceId: string;
+}
+
+export default function DeviceDetail({ facilityId, deviceId }: Props) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [isLocationSheetOpen, setIsLocationSheetOpen] = useState(false);
+
+ const { data: device, isLoading } = useQuery({
+ queryKey: ["device", facilityId, deviceId],
+ queryFn: query(deviceApi.retrieve, {
+ pathParams: { facility_id: facilityId, id: deviceId },
+ }),
+ });
+
+ const { mutate: deleteDevice, isPending: isDeleting } = useMutation({
+ mutationFn: mutate(deviceApi.delete, {
+ pathParams: { facility_id: facilityId, id: deviceId },
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["devices"] });
+ navigate(`/facility/${facilityId}/settings/devices`);
+ },
+ });
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!device) {
+ return null;
+ }
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "active":
+ return "bg-green-100 text-green-800 hover:bg-green-100/80";
+ case "inactive":
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ case "entered_in_error":
+ return "bg-red-100 text-red-800 hover:bg-red-100/80";
+ default:
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ }
+ };
+
+ const getAvailabilityStatusColor = (status: string) => {
+ switch (status) {
+ case "available":
+ return "bg-green-100 text-green-800 hover:bg-green-100/80";
+ case "lost":
+ return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80";
+ case "damaged":
+ case "destroyed":
+ return "bg-red-100 text-red-800 hover:bg-red-100/80";
+ default:
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ }
+ };
+
+ const renderContactInfo = (contact: ContactPoint) => {
+ const getContactLink = (system: string, value: string) => {
+ switch (system) {
+ case "phone":
+ case "fax":
+ return `tel:${value}`;
+ case "email":
+ return `mailto:${value}`;
+ case "url":
+ return value;
+ case "sms":
+ return `sms:${value}`;
+ default:
+ return null;
+ }
+ };
+
+ const link = getContactLink(contact.system, contact.value);
+
+ return (
+
+
+ {t(contact.system)} ({t(contact.use)})
+
+ {link ? (
+
+ {contact.value}
+
+ ) : (
+
{contact.value}
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("delete_device")}
+
+ {t("delete_device_confirmation")}
+
+
+
+ {t("cancel")}
+ deleteDevice()}
+ className={cn(buttonVariants({ variant: "destructive" }))}
+ disabled={isDeleting}
+ >
+ {isDeleting ? t("deleting") : t("delete")}
+
+
+
+
+
+
+
+
+
+
+ {t("device_information")}
+
+
+
+
+
+ {t("registered_name")}
+
+
{device.registered_name}
+
+ {device.user_friendly_name && (
+
+
+ {t("user_friendly_name")}
+
+
{device.user_friendly_name}
+
+ )}
+
+
+ {t("location")}
+
+
+ {device.current_location ? (
+ <>
+
+ {device.current_location.name}
+
+
+ >
+ ) : (
+ {t("no_location")}
+ )}
+
+
+
+
+
+ {t(`device_status_${device.status}`)}
+
+
+ {t(
+ `device_availability_status_${device.availability_status}`,
+ )}
+
+
+
+
+
+
+
+
+ {(device.identifier || device.lot_number) && (
+ <>
+ {device.identifier && (
+
+
+ {t("identifier")}
+
+
{device.identifier}
+
+ )}
+ {device.lot_number && (
+
+
+ {t("lot_number")}
+
+
{device.lot_number}
+
+ )}
+ >
+ )}
+
+
+
+ {(device.manufacturer || device.model_number) && (
+ <>
+ {device.manufacturer && (
+
+
+ {t("manufacturer")}
+
+
{device.manufacturer}
+
+ )}
+ {device.model_number && (
+
+
+ {t("model_number")}
+
+
{device.model_number}
+
+ )}
+ >
+ )}
+
+
+
+ {(device.serial_number || device.part_number) && (
+ <>
+ {device.serial_number && (
+
+
+ {t("serial_number")}
+
+
{device.serial_number}
+
+ )}
+ {device.part_number && (
+
+
+ {t("part_number")}
+
+
{device.part_number}
+
+ )}
+ >
+ )}
+
+
+
+ {(device.manufacture_date || device.expiration_date) && (
+ <>
+ {device.manufacture_date && (
+
+
+ {t("manufacture_date")}
+
+
+ {new Date(
+ device.manufacture_date,
+ ).toLocaleDateString()}
+
+
+ )}
+ {device.expiration_date && (
+
+
+ {t("expiration_date")}
+
+
+ {new Date(
+ device.expiration_date,
+ ).toLocaleDateString()}
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ {device.contact?.length > 0 && (
+
+
+ {t("contact_information")}
+
+ {t("device_contact_description")}
+
+
+
+
+ {device.contact.map(renderContactInfo)}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/DevicesList.tsx b/src/pages/Facility/settings/devices/DevicesList.tsx
new file mode 100644
index 00000000000..2eaa341f6b7
--- /dev/null
+++ b/src/pages/Facility/settings/devices/DevicesList.tsx
@@ -0,0 +1,88 @@
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "raviger";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import CareIcon from "@/CAREUI/icons/CareIcon";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+import PageTitle from "@/components/Common/PageTitle";
+import Pagination from "@/components/Common/Pagination";
+import { CardGridSkeleton } from "@/components/Common/SkeletonLoading";
+
+import query from "@/Utils/request/query";
+import DeviceCard from "@/pages/Facility/settings/devices/components/DeviceCard";
+import deviceApi from "@/types/device/deviceApi";
+
+interface Props {
+ facilityId: string;
+}
+
+export default function DevicesList({ facilityId }: Props) {
+ const { t } = useTranslation();
+ const [page, setPage] = useState(1);
+
+ const limit = 12;
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["devices", facilityId, page, limit],
+ queryFn: query.debounced(deviceApi.list, {
+ pathParams: { facility_id: facilityId },
+ queryParams: {
+ offset: (page - 1) * limit,
+ limit,
+ },
+ }),
+ });
+
+ return (
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ {data?.results?.length ? (
+ data.results.map((device) => (
+
+ ))
+ ) : (
+
+
+ {t("no_devices_available")}
+
+
+ )}
+
+ {data && data.count > limit && (
+
+
setPage(page)}
+ defaultPerPage={limit}
+ cPage={page}
+ />
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/UpdateDevice.tsx b/src/pages/Facility/settings/devices/UpdateDevice.tsx
new file mode 100644
index 00000000000..17ddb26a8ff
--- /dev/null
+++ b/src/pages/Facility/settings/devices/UpdateDevice.tsx
@@ -0,0 +1,57 @@
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "raviger";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+
+import Loading from "@/components/Common/Loading";
+import PageTitle from "@/components/Common/PageTitle";
+
+import query from "@/Utils/request/query";
+import deviceApi from "@/types/device/deviceApi";
+
+import DeviceForm from "./components/DeviceForm";
+
+interface Props {
+ facilityId: string;
+ deviceId: string;
+}
+
+export default function UpdateDevice({ facilityId, deviceId }: Props) {
+ const { t } = useTranslation();
+
+ const { data: device, isLoading } = useQuery({
+ queryKey: ["device", facilityId, deviceId],
+ queryFn: query(deviceApi.retrieve, {
+ pathParams: { facility_id: facilityId, id: deviceId },
+ }),
+ });
+
+ return (
+
+
+
+ {isLoading ? (
+
+ ) : device ? (
+
+ {
+ window.history.back();
+ }}
+ />
+
+ ) : (
+
+
{t("device_not_found")}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/components/AssociateLocationSheet.tsx b/src/pages/Facility/settings/devices/components/AssociateLocationSheet.tsx
new file mode 100644
index 00000000000..86b1c8c0761
--- /dev/null
+++ b/src/pages/Facility/settings/devices/components/AssociateLocationSheet.tsx
@@ -0,0 +1,87 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+
+import { LocationSearch } from "@/components/Location/LocationSearch";
+
+import mutate from "@/Utils/request/mutate";
+import deviceApi from "@/types/device/deviceApi";
+import { LocationList } from "@/types/location/location";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ facilityId: string;
+ deviceId: string;
+}
+
+export default function AssociateLocationSheet({
+ open,
+ onOpenChange,
+ facilityId,
+ deviceId,
+}: Props) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [selectedLocation, setSelectedLocation] = useState(
+ null,
+ );
+
+ const { mutate: associateLocation, isPending } = useMutation({
+ mutationFn: mutate(deviceApi.associateLocation, {
+ pathParams: { facility_id: facilityId, id: deviceId },
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["device", facilityId, deviceId],
+ });
+ toast.success(t("location_associated_successfully"));
+ onOpenChange(false);
+ setSelectedLocation(null);
+ },
+ });
+
+ const handleSubmit = () => {
+ if (!selectedLocation) return;
+ associateLocation({ location: selectedLocation.id });
+ };
+
+ return (
+
+
+
+ {t("associate_location")}
+
+ {t("associate_location_description")}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/components/DeviceCard.tsx b/src/pages/Facility/settings/devices/components/DeviceCard.tsx
new file mode 100644
index 00000000000..4bc53392872
--- /dev/null
+++ b/src/pages/Facility/settings/devices/components/DeviceCard.tsx
@@ -0,0 +1,85 @@
+import { Link } from "raviger";
+import { useTranslation } from "react-i18next";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+import { DeviceList } from "@/types/device/device";
+
+interface Props {
+ device: DeviceList;
+}
+
+export default function DeviceCard({ device }: Props) {
+ const { t } = useTranslation();
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "active":
+ return "bg-green-100 text-green-800 hover:bg-green-100/80";
+ case "inactive":
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ case "entered_in_error":
+ return "bg-red-100 text-red-800 hover:bg-red-100/80";
+ default:
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ }
+ };
+
+ const getAvailabilityStatusColor = (status: string) => {
+ switch (status) {
+ case "available":
+ return "bg-green-100 text-green-800 hover:bg-green-100/80";
+ case "lost":
+ return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80";
+ case "damaged":
+ case "destroyed":
+ return "bg-red-100 text-red-800 hover:bg-red-100/80";
+ default:
+ return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {device.registered_name}
+
+ {device.user_friendly_name && (
+
+ {device.user_friendly_name}
+
+ )}
+
+
+
+
+
+
+ {t(`device_status_${device.status}`)}
+
+
+ {t(`device_availability_status_${device.availability_status}`)}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Facility/settings/devices/components/DeviceForm.tsx b/src/pages/Facility/settings/devices/components/DeviceForm.tsx
new file mode 100644
index 00000000000..05769cd1ac7
--- /dev/null
+++ b/src/pages/Facility/settings/devices/components/DeviceForm.tsx
@@ -0,0 +1,439 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { t } from "i18next";
+import { useEffect } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+import { z } from "zod";
+
+import CareIcon from "@/CAREUI/icons/CareIcon";
+
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { PhoneInput } from "@/components/ui/phone-input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+import mutate from "@/Utils/request/mutate";
+import { dateQueryString } from "@/Utils/utils";
+import {
+ ContactPointSystems,
+ contactPointSchema,
+} from "@/types/common/contactPoint";
+import {
+ DeviceAvailabilityStatuses,
+ DeviceList,
+ DeviceStatuses,
+} from "@/types/device/device";
+import deviceApi from "@/types/device/deviceApi";
+
+const formSchema = z.object({
+ identifier: z.string().optional(),
+ status: z.enum(DeviceStatuses),
+ availability_status: z.enum(DeviceAvailabilityStatuses),
+ manufacturer: z.string().optional(),
+ manufacture_date: z.string().optional(),
+ expiration_date: z.string().optional(),
+ lot_number: z.string().optional(),
+ serial_number: z.string().optional(),
+ registered_name: z.string().min(1, { message: t("required") }),
+ user_friendly_name: z.string().optional(),
+ model_number: z.string().optional(),
+ part_number: z.string().optional(),
+ contact: z.array(contactPointSchema),
+});
+
+interface Props {
+ facilityId: string;
+ device?: DeviceList;
+ onSuccess?: () => void;
+}
+
+const defaultValues: z.infer = {
+ identifier: "",
+ status: "active",
+ availability_status: "available",
+ manufacturer: "",
+ manufacture_date: "",
+ registered_name: "",
+ contact: [],
+};
+
+export default function DeviceForm({ facilityId, device, onSuccess }: Props) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues,
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "contact",
+ });
+
+ const { mutate: submitForm, isPending } = useMutation({
+ mutationFn: device?.id
+ ? mutate(deviceApi.update, {
+ pathParams: { facility_id: facilityId, id: device.id },
+ })
+ : mutate(deviceApi.create, {
+ pathParams: { facility_id: facilityId },
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["devices"] });
+ onSuccess?.();
+ },
+ });
+
+ useEffect(() => {
+ if (device) {
+ form.reset({ ...device });
+ }
+ }, [device, form]);
+
+ useEffect(() => {
+ if (device?.manufacture_date) {
+ form.setValue(
+ "manufacture_date",
+ dateQueryString(device.manufacture_date),
+ );
+ }
+
+ if (device?.expiration_date) {
+ form.setValue("expiration_date", dateQueryString(device.expiration_date));
+ }
+ }, [device, form]);
+
+ function onSubmit(values: z.infer) {
+ submitForm({ ...values });
+ }
+
+ return (
+
+
+ );
+}
diff --git a/src/pages/Facility/settings/layout.tsx b/src/pages/Facility/settings/layout.tsx
index a2b7c1c58f3..6d256ec4cff 100644
--- a/src/pages/Facility/settings/layout.tsx
+++ b/src/pages/Facility/settings/layout.tsx
@@ -6,6 +6,11 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import ErrorPage from "@/components/ErrorPages/DefaultErrorPage";
+import CreateDevice from "@/pages/Facility/settings/devices/CreateDevice";
+import DeviceDetail from "@/pages/Facility/settings/devices/DeviceDetail";
+import DevicesList from "@/pages/Facility/settings/devices/DevicesList";
+import UpdateDevice from "@/pages/Facility/settings/devices/UpdateDevice";
+
import { GeneralSettings } from "./general/general";
import LocationList from "./locations/LocationList";
import LocationView from "./locations/LocationView";
@@ -30,6 +35,14 @@ const getRoutes = (facilityId: string) => ({
"/location/:id": ({ id }: { id: string }) => (
),
+ "/devices": () => ,
+ "/devices/create": () => ,
+ "/devices/:id": ({ id }: { id: string }) => (
+
+ ),
+ "/devices/:id/edit": ({ id }: { id: string }) => (
+
+ ),
"*": () => ,
});
@@ -59,6 +72,11 @@ export function SettingsLayout({ facilityId }: SettingsLayoutProps) {
label: t("locations"),
href: `${basePath}/locations`,
},
+ {
+ value: "devices",
+ label: t("devices"),
+ href: `${basePath}/devices`,
+ },
];
// Extract the current tab from the URL
diff --git a/src/pages/Facility/settings/locations/LocationForm.tsx b/src/pages/Facility/settings/locations/LocationForm.tsx
index 6f82f64228a..29b0546ef95 100644
--- a/src/pages/Facility/settings/locations/LocationForm.tsx
+++ b/src/pages/Facility/settings/locations/LocationForm.tsx
@@ -28,10 +28,10 @@ import { Textarea } from "@/components/ui/textarea";
import mutate from "@/Utils/request/mutate";
import query from "@/Utils/request/query";
import {
+ LocationFormOptions,
LocationWrite,
OperationalStatus,
Status,
- locationFormOptions,
} from "@/types/location/location";
import locationApi from "@/types/location/locationApi";
@@ -40,23 +40,7 @@ const formSchema = z.object({
description: z.string().optional(),
status: z.enum(["active", "inactive", "unknown"] as const),
operational_status: z.enum(["C", "H", "O", "U", "K", "I"] as const),
- form: z.enum([
- "si",
- "bu",
- "wi",
- "wa",
- "lvl",
- "co",
- "ro",
- "bd",
- "ve",
- "ho",
- "ca",
- "rd",
- "area",
- "jdn",
- "vi",
- ] as const),
+ form: z.enum(LocationFormOptions),
parent: z.string().optional().nullable(),
organizations: z.array(z.string()).default([]),
availability_status: z.enum(["available", "unavailable"] as const),
@@ -224,9 +208,9 @@ export default function LocationForm({
- {locationFormOptions.map((option) => (
-
- {option.label}
+ {LocationFormOptions.map((option) => (
+
+ {t(`location_form__${option}`)}
))}
diff --git a/src/pages/Facility/settings/locations/LocationView.tsx b/src/pages/Facility/settings/locations/LocationView.tsx
index 9bab726b813..52685711fa0 100644
--- a/src/pages/Facility/settings/locations/LocationView.tsx
+++ b/src/pages/Facility/settings/locations/LocationView.tsx
@@ -23,7 +23,7 @@ import { CardGridSkeleton } from "@/components/Common/SkeletonLoading";
import LinkDepartmentsSheet from "@/components/Patient/LinkDepartmentsSheet";
import query from "@/Utils/request/query";
-import { LocationList, getLocationFormLabel } from "@/types/location/location";
+import { LocationList } from "@/types/location/location";
import locationApi from "@/types/location/locationApi";
import LocationSheet from "./LocationSheet";
@@ -154,7 +154,7 @@ export default function LocationView({ id, facilityId }: Props) {
{t("locations")}
- {getLocationFormLabel(location?.form)}
+ {t(`location_form__${location?.form}`)}
- {getLocationFormLabel(location.form)}
+ {t(`location_form__${location.form}`)}
diff --git a/src/pages/Facility/settings/organizations/components/CreateFacilityOrganizationSheet.tsx b/src/pages/Facility/settings/organizations/components/CreateFacilityOrganizationSheet.tsx
index 741ec7aa046..533e69941a7 100644
--- a/src/pages/Facility/settings/organizations/components/CreateFacilityOrganizationSheet.tsx
+++ b/src/pages/Facility/settings/organizations/components/CreateFacilityOrganizationSheet.tsx
@@ -40,7 +40,7 @@ const ORG_TYPES = [
type OrgType = (typeof ORG_TYPES)[number]["value"];
-export default function CreateFacilityOrganizationSheet({
+export default function FacilityOrganizationSheet({
facilityId,
parentId,
}: Props) {
diff --git a/src/types/common/contactPoint.ts b/src/types/common/contactPoint.ts
new file mode 100644
index 00000000000..d66f42bf100
--- /dev/null
+++ b/src/types/common/contactPoint.ts
@@ -0,0 +1,73 @@
+import { z } from "zod";
+
+import validators from "@/Utils/validators";
+
+export const ContactPointSystems = [
+ "phone",
+ "fax",
+ "email",
+ "pager",
+ "url",
+ "sms",
+ "other",
+] as const;
+
+export type ContactPointSystem = (typeof ContactPointSystems)[number];
+
+export const ContactPointUses = ["home", "work", "temp", "mobile"] as const;
+
+export type ContactPointUse = (typeof ContactPointUses)[number];
+
+export interface ContactPoint {
+ system: ContactPointSystem;
+ value: string;
+ use: ContactPointUse;
+}
+
+export const contactPointSchema = z.discriminatedUnion("system", [
+ // Phone numbers
+ z.object({
+ system: z.literal("phone"),
+ value: validators().phoneNumber.required,
+ use: z.enum(ContactPointUses),
+ }),
+ // Fax numbers (also using phone validation since they follow same format)
+ z.object({
+ system: z.literal("fax"),
+ value: validators().phoneNumber.required,
+ use: z.enum(ContactPointUses),
+ }),
+ // Email addresses
+ z.object({
+ system: z.literal("email"),
+ value: z.string().email(),
+ use: z.enum(ContactPointUses),
+ }),
+ // URLs
+ z.object({
+ system: z.literal("url"),
+ value: z.string().url(),
+ use: z.enum(ContactPointUses),
+ }),
+ // SMS (also using phone validation)
+ z.object({
+ system: z.literal("sms"),
+ value: validators().phoneNumber.required,
+ use: z.enum(ContactPointUses),
+ }),
+ // Pager (typically numeric, but can vary)
+ z.object({
+ system: z.literal("pager"),
+ value: z
+ .string()
+ .min(1, { message: "Required" })
+ .max(20, { message: "Pager number too long" }),
+ use: z.enum(ContactPointUses),
+ }),
+ // Other (catch-all with basic validation)
+ z.object({
+ system: z.literal("other"),
+ value: z.string().min(1, { message: "Required" }),
+ use: z.enum(ContactPointUses),
+ }),
+]);
diff --git a/src/types/device/device.ts b/src/types/device/device.ts
new file mode 100644
index 00000000000..60ec9aa9ff8
--- /dev/null
+++ b/src/types/device/device.ts
@@ -0,0 +1,53 @@
+import { ContactPoint } from "@/types/common/contactPoint";
+import { Encounter } from "@/types/emr/encounter";
+import { LocationList } from "@/types/location/location";
+import { UserBase } from "@/types/user/user";
+
+export const DeviceStatuses = [
+ "active",
+ "inactive",
+ "entered_in_error",
+] as const;
+
+export type DeviceStatus = (typeof DeviceStatuses)[number];
+
+export const DeviceAvailabilityStatuses = [
+ "lost",
+ "damaged",
+ "destroyed",
+ "available",
+] as const;
+
+export type DeviceAvailabilityStatus =
+ (typeof DeviceAvailabilityStatuses)[number];
+
+export interface DeviceBase {
+ identifier?: string;
+ status: DeviceStatus;
+ availability_status: DeviceAvailabilityStatus;
+ manufacturer?: string;
+ manufacture_date?: string; // datetime
+ expiration_date?: string; // datetime
+ lot_number?: string;
+ serial_number?: string;
+ registered_name: string;
+ user_friendly_name?: string;
+ model_number?: string;
+ part_number?: string;
+ contact: ContactPoint[];
+ // care_type: string | undefined;
+}
+
+export interface DeviceDetail extends DeviceBase {
+ id: string;
+ current_encounter: Encounter | undefined;
+ current_location: LocationList | undefined;
+ created_by: UserBase;
+ updated_by: UserBase;
+}
+
+export interface DeviceList extends DeviceBase {
+ id: string;
+}
+
+export type DeviceWrite = DeviceBase;
diff --git a/src/types/device/deviceApi.ts b/src/types/device/deviceApi.ts
new file mode 100644
index 00000000000..929c13a25a6
--- /dev/null
+++ b/src/types/device/deviceApi.ts
@@ -0,0 +1,47 @@
+import { HttpMethod, Type } from "@/Utils/request/api";
+import { PaginatedResponse } from "@/Utils/request/types";
+
+import { DeviceDetail, DeviceList, DeviceWrite } from "./device";
+
+export default {
+ list: {
+ path: "/api/v1/facility/{facility_id}/device/",
+ method: HttpMethod.GET,
+ TRes: Type
>(),
+ },
+ create: {
+ path: "/api/v1/facility/{facility_id}/device/",
+ method: HttpMethod.POST,
+ TRes: Type(),
+ TBody: Type(),
+ },
+ retrieve: {
+ path: "/api/v1/facility/{facility_id}/device/{id}/",
+ method: HttpMethod.GET,
+ TRes: Type(),
+ },
+ update: {
+ path: "/api/v1/facility/{facility_id}/device/{id}/",
+ method: HttpMethod.PUT,
+ TRes: Type(),
+ TBody: Type(),
+ },
+ delete: {
+ path: "/api/v1/facility/{facility_id}/device/{id}/",
+ method: HttpMethod.DELETE,
+ TRes: Type(),
+ TBody: Type(),
+ },
+ upsert: {
+ path: "/api/v1/facility/{facility_id}/device/upsert/",
+ method: HttpMethod.POST,
+ TRes: Type(),
+ TBody: Type(),
+ },
+ associateLocation: {
+ path: "/api/v1/facility/{facility_id}/device/{id}/associate_location/",
+ method: HttpMethod.POST,
+ TRes: Type(),
+ TBody: Type<{ location: string }>(),
+ },
+};
diff --git a/src/types/location/location.ts b/src/types/location/location.ts
index 3738dce63f7..3c67a72e6cf 100644
--- a/src/types/location/location.ts
+++ b/src/types/location/location.ts
@@ -9,22 +9,7 @@ export type OperationalStatus = "C" | "H" | "O" | "U" | "K" | "I";
export type LocationMode = "instance" | "kind";
-export type LocationForm =
- | "si"
- | "bu"
- | "wi"
- | "wa"
- | "lvl"
- | "co"
- | "ro"
- | "bd"
- | "ve"
- | "ho"
- | "ca"
- | "rd"
- | "area"
- | "jdn"
- | "vi";
+export type LocationForm = (typeof LocationFormOptions)[number];
export interface LocationBase {
status: Status;
@@ -55,24 +40,20 @@ export interface LocationWrite extends LocationBase {
mode: LocationMode;
}
-export const locationFormOptions = [
- { value: "si", label: "Site" },
- { value: "bu", label: "Building" },
- { value: "wi", label: "Wing" },
- { value: "wa", label: "Ward" },
- { value: "lvl", label: "Level" },
- { value: "co", label: "Corridor" },
- { value: "ro", label: "Room" },
- { value: "bd", label: "Bed" },
- { value: "ve", label: "Vehicle" },
- { value: "ho", label: "House" },
- { value: "ca", label: "Cabinet" },
- { value: "rd", label: "Road" },
- { value: "area", label: "Area" },
- { value: "jdn", label: "Jurisdiction" },
- { value: "vi", label: "Virtual" },
-];
-
-export const getLocationFormLabel = (value: LocationForm) => {
- return locationFormOptions.find((option) => option.value === value)?.label;
-};
+export const LocationFormOptions = [
+ "si",
+ "bu",
+ "wi",
+ "wa",
+ "lvl",
+ "co",
+ "ro",
+ "bd",
+ "ve",
+ "ho",
+ "ca",
+ "rd",
+ "area",
+ "jdn",
+ "vi",
+] as const;