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 ( +
+ +
+ ( + + {t("registered_name")} + + + + + + )} + /> + + ( + + {t("user_friendly_name")} + + + + + + )} + /> + + ( + + {t("status")} + + + + )} + /> + + ( + + {t("availability_status")} + + + + )} + /> + + ( + + {t("identifier")} + + + + + + )} + /> + + ( + + {t("manufacturer")} + + + + + + )} + /> + + ( + + {t("manufacture_date")} + + + + + + )} + /> + + ( + + {t("expiration_date")} + + + + + + )} + /> + + ( + + {t("lot_number")} + + + + + + )} + /> + + ( + + {t("serial_number")} + + + + + + )} + /> + + ( + + {t("model_number")} + + + + + + )} + /> + + ( + + {t("part_number")} + + + + + + )} + /> +
+ +
+
+

+ {t("contact_points")} +

+ +
+ + {fields.map((field, index) => ( +
+ ( + + + + + )} + /> + + { + const system = form.watch(`contact.${index}.system`); + return ( + + + {system === "phone" || + system === "fax" || + system === "sms" ? ( + + ) : ( + + )} + + + + ); + }} + /> + + ( + + )} + /> + + +
+ ))} +
+ +
+ +
+
+ + ); +} 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;