From d9a620c8f3e2e4758e1dce842d082e8f231146aa Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Tue, 10 Dec 2024 09:06:40 +0530 Subject: [PATCH 01/11] Reorder Patient Search Buttons (#9319) --- src/components/Patient/ManagePatients.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Patient/ManagePatients.tsx b/src/components/Patient/ManagePatients.tsx index 6fba4d6e734..65fc0e73933 100644 --- a/src/components/Patient/ManagePatients.tsx +++ b/src/components/Patient/ManagePatients.tsx @@ -749,14 +749,6 @@ export const PatientManager = () => { permittedFacilities?.count === 1 ? permittedFacilities.results[0] : null; const searchOptions = [ - { - key: "phone_number", - label: "Phone Number", - type: "phone" as const, - placeholder: "Search_by_phone_number", - value: qParams.phone_number || "", - shortcutKey: "p", - }, { key: "name", label: "Name", @@ -773,6 +765,14 @@ export const PatientManager = () => { value: qParams.patient_no || "", shortcutKey: "u", }, + { + key: "phone_number", + label: "Phone Number", + type: "phone" as const, + placeholder: "Search_by_phone_number", + value: qParams.phone_number || "", + shortcutKey: "p", + }, { key: "emergency_contact_number", label: "Emergency Contact Phone Number", From d5200183990c475b26ea7562b37c2ef4b5ebfb68 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Tue, 10 Dec 2024 09:07:33 +0530 Subject: [PATCH 02/11] Fix excessive vertical padding in all pages (#9331) --- src/components/Common/Page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Common/Page.tsx b/src/components/Common/Page.tsx index 32e763c45b9..558d1432182 100644 --- a/src/components/Common/Page.tsx +++ b/src/components/Common/Page.tsx @@ -33,7 +33,7 @@ export default function Page(props: PageProps) { let padding = ""; if (!props.noImplicitPadding) { - if (!props.hideBack || props.componentRight) padding = "py-3 md:p-6"; + if (!props.hideBack || props.componentRight) padding = "py-3 md:px-6"; else padding = "px-6 py-5"; } From a88b88134c79183346d4d8a56bc0554d182750d2 Mon Sep 17 00:00:00 2001 From: Swanand Shekhar Bhuskute <103440604+SwanandBhuskute@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:36:03 +0530 Subject: [PATCH 03/11] removed lodash imports and dependencies and wrote js equivalents (#9116) --- package-lock.json | 25 ------ package.json | 2 - public/locale/en.json | 8 ++ src/Utils/Notifications.js | 22 ++++- src/Utils/utils.ts | 15 ++++ .../Common/ExcelFIleDragAndDrop.tsx | 7 +- .../Facility/Investigations/Reports/index.tsx | 21 +++-- .../Facility/Investigations/Reports/utils.tsx | 84 ++++++++++++++----- .../Investigations/ShowInvestigation.tsx | 45 +++++++--- .../Facility/Investigations/Table.tsx | 20 ++++- src/components/Form/AutoCompleteAsync.tsx | 43 +++++----- src/components/Form/Form.tsx | 4 +- src/components/Patient/DiagnosesFilter.tsx | 11 ++- src/components/Patient/PatientRegister.tsx | 13 ++- src/components/Patient/SampleDetails.tsx | 15 ++-- src/components/Patient/SampleTestCard.tsx | 11 +-- src/hooks/useDebounce.ts | 29 +++++++ 17 files changed, 254 insertions(+), 121 deletions(-) create mode 100644 src/hooks/useDebounce.ts diff --git a/package-lock.json b/package-lock.json index 3d1b49ff3ad..84fc3844cb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^3.0.1", - "lodash-es": "^4.17.21", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -74,7 +73,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", @@ -5615,23 +5613,6 @@ "optional": true, "peer": true }, - "node_modules/@types/lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-jzqWo/uQP/iqeGGTjhgFp2yaCrCYTauASQcpdzESNCkHjSprBJVcZP9KG9aQ0q+xcsXiKd/iuw/4dLjS3Odc7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -12748,12 +12729,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", diff --git a/package.json b/package.json index 8c76438826b..53464762bfd 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^3.0.1", - "lodash-es": "^4.17.21", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -113,7 +112,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", diff --git a/public/locale/en.json b/public/locale/en.json index f86d78fa3aa..d73268df952 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -181,6 +181,14 @@ "ROUNDS_TYPE__NORMAL": "Brief Update", "ROUNDS_TYPE__TELEMEDICINE": "Tele-medicine Log", "ROUNDS_TYPE__VENTILATOR": "Detailed Update", + "SAMPLE_TEST_HISTORY__APPROVED": "Approved", + "SAMPLE_TEST_HISTORY__COMPLETED": "Completed", + "SAMPLE_TEST_HISTORY__RECEIVED_AT_LAB": "Received At Lab", + "SAMPLE_TEST_HISTORY__REQUEST_SUBMITTED": "Request Submitted", + "SAMPLE_TEST_RESULT__AWAITING": "Awaiting", + "SAMPLE_TEST_RESULT__INVALID": "Invalid", + "SAMPLE_TEST_RESULT__NEGATIVE": "Negative", + "SAMPLE_TEST_RESULT__POSITIVE": "Positive", "SLEEP__EXCESSIVE": "Excessive", "SLEEP__NO_SLEEP": "No sleep", "SLEEP__SATISFACTORY": "Satisfactory", diff --git a/src/Utils/Notifications.js b/src/Utils/Notifications.js index 5b3ecdf143c..08bc90443b5 100644 --- a/src/Utils/Notifications.js +++ b/src/Utils/Notifications.js @@ -1,6 +1,5 @@ import { Stack, alert, defaultModules } from "@pnotify/core"; import * as PNotifyMobile from "@pnotify/mobile"; -import { camelCase, startCase } from "lodash-es"; defaultModules.set(PNotifyMobile, {}); @@ -35,6 +34,25 @@ const notify = (text, type) => { }); }; +/** + * Formats input string to a more human readable format + * @param {string} key - The key to format + * @returns {string} The formatted key + * @example + * formatKey("patient_name") => "Patient Name" + */ +const formatKey = (key) => { + return key + .replace(/[^a-zA-Z0-9]+/g, " ") // Replace non-alphanumeric characters with a space + .trim() + .split(" ") + .map( + (word) => + word.charAt(0).toLocaleUpperCase() + word.slice(1).toLocaleLowerCase(), + ) // Capitalize the first letter of each word and lowercase the rest + .join(" "); +}; + const notifyError = (error) => { let errorMsg = ""; if (typeof error === "string" || !error) { @@ -44,7 +62,7 @@ const notifyError = (error) => { errorMsg = error.detail; } else { for (let [key, value] of Object.entries(error)) { - let keyName = startCase(camelCase(key)); + let keyName = formatKey(key); if (Array.isArray(value)) { const uniques = [...new Set(value)]; errorMsg += `${keyName} - ${uniques.splice(0, 5).join(", ")}`; diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index 3888406ad29..a7e6a0a78ec 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -544,3 +544,18 @@ export const fahrenheitToCelsius = (fahrenheit: number) => { export const keysOf = (obj: T) => { return Object.keys(obj) as (keyof T)[]; }; + +// Utility to check if a value is "empty" +export const isEmpty = (value: unknown) => { + return value === "" || value == undefined; +}; + +// equivalent to lodash omitBy +export function omitBy>( + obj: T, + predicate: (value: unknown) => boolean, +): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => !predicate(value)), + ) as Partial; +} diff --git a/src/components/Common/ExcelFIleDragAndDrop.tsx b/src/components/Common/ExcelFIleDragAndDrop.tsx index 67c64f0f433..d868e03f2a4 100644 --- a/src/components/Common/ExcelFIleDragAndDrop.tsx +++ b/src/components/Common/ExcelFIleDragAndDrop.tsx @@ -1,4 +1,3 @@ -import { forIn } from "lodash-es"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as XLSX from "xlsx"; @@ -68,9 +67,9 @@ export default function ExcelFileDragAndDrop({ const data = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); //converts the date to string data.forEach((row: any) => { - forIn(row, (value: any, key: string) => { - if (value instanceof Date) { - row[key] = value.toISOString().split("T")[0]; + Object.keys(row).forEach((key) => { + if (row[key] instanceof Date) { + row[key] = row[key].toISOString().split("T")[0]; } }); }); diff --git a/src/components/Facility/Investigations/Reports/index.tsx b/src/components/Facility/Investigations/Reports/index.tsx index e711372da47..32f0abcb7bc 100644 --- a/src/components/Facility/Investigations/Reports/index.tsx +++ b/src/components/Facility/Investigations/Reports/index.tsx @@ -1,4 +1,3 @@ -import _ from "lodash"; import { useCallback, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -179,16 +178,16 @@ const InvestigationReports = ({ id }: any) => { ), ); - const investigationList = _.chain(data) - .flatMap((i) => i?.data?.results) - .compact() - .flatten() - .map((i) => ({ - ...i, - name: `${i.name} ${i.groups[0].name && " | " + i.groups[0].name} `, - })) - .unionBy("external_id") - .value(); + const investigationList = Array.from( + data + .flatMap((i) => i?.data?.results || []) + .map((i) => ({ + ...i, + name: `${i.name} ${i.groups[0].name ? " | " + i.groups[0].name : ""}`, + })) + .reduce((map, item) => map.set(item.external_id, item), new Map()) + .values(), + ); dispatch({ type: "set_investigations", payload: investigationList }); dispatch({ diff --git a/src/components/Facility/Investigations/Reports/utils.tsx b/src/components/Facility/Investigations/Reports/utils.tsx index 073be541600..564ce53ebe2 100644 --- a/src/components/Facility/Investigations/Reports/utils.tsx +++ b/src/components/Facility/Investigations/Reports/utils.tsx @@ -1,31 +1,70 @@ -import _ from "lodash"; -import { findIndex, memoize } from "lodash-es"; +import { + Investigation, + InvestigationResponse, +} from "@/components/Facility/Investigations/Reports/types"; -import { InvestigationResponse } from "@/components/Facility/Investigations/Reports/types"; +const memoize = any>(fn: T): T => { + const cache = new Map>(); + const MAX_CACHE_SIZE = 1000; + return ((...args: Parameters): ReturnType => { + const key = args + .map((arg) => + typeof arg === "object" + ? arg instanceof Date + ? arg.getTime().toString() + : JSON.stringify(Object.entries(arg).sort()) + : String(arg), + ) + .join("|"); + if (!cache.has(key)) { + if (cache.size >= MAX_CACHE_SIZE) { + const firstKey: any = cache.keys().next().value; + cache.delete(firstKey); + } + cache.set(key, fn(...args)); + } + return cache.get(key)!; + }) as T; +}; export const transformData = memoize((data: InvestigationResponse) => { - const sessions = _.chain(data) - .map((value: any) => { - return { - ...value.session_object, - facility_name: value.consultation_object?.facility_name, - facility_id: value.consultation_object?.facility, - }; - }) - .uniqBy("session_external_id") - .orderBy("session_created_date", "desc") - .value(); - const groupByInvestigation = _.chain(data) - .groupBy("investigation_object.external_id") - .values() - .value(); + const sessions = Array.from( + new Map( + data.map((value: any) => [ + value.session_object.session_external_id, + { + ...value.session_object, + facility_name: value.consultation_object?.facility_name, + facility_id: value.consultation_object?.facility, + }, + ]), + ).values(), + ).sort( + (a, b) => + new Date(b.session_created_date).getTime() - + new Date(a.session_created_date).getTime(), + ); + + const groupByInvestigation = Object.values( + data.reduce( + (acc, value: Investigation) => { + const key = value.investigation_object.external_id; + if (!acc[key]) acc[key] = []; + acc[key].push(value); + return acc; + }, + {} as { [key: string]: Investigation[] }, + ), + ); + + const sessionMap = new Map( + sessions.map((session, index) => [session.session_external_id, index]), + ); const reqData = groupByInvestigation.map((value: any) => { const sessionValues = Array.from({ length: sessions.length }); value.forEach((val: any) => { - const sessionIndex = findIndex(sessions, [ - "session_external_id", - val.session_object.session_external_id, - ]); + const sessionIndex = + sessionMap.get(val.session_object.session_external_id) ?? -1; if (sessionIndex > -1) { sessionValues[sessionIndex] = { min: val.investigation_object.min_value, @@ -58,6 +97,7 @@ export const transformData = memoize((data: InvestigationResponse) => { sessionValues, }; }); + return { sessions, data: reqData }; }); diff --git a/src/components/Facility/Investigations/ShowInvestigation.tsx b/src/components/Facility/Investigations/ShowInvestigation.tsx index 11e9b5cc0ad..05f2dd8eecf 100644 --- a/src/components/Facility/Investigations/ShowInvestigation.tsx +++ b/src/components/Facility/Investigations/ShowInvestigation.tsx @@ -1,5 +1,3 @@ -import _ from "lodash"; -import { set } from "lodash-es"; import { useCallback, useReducer } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +10,8 @@ import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import useQuery from "@/Utils/request/useQuery"; +// import { setNestedValueSafely } from "@/Utils/utils"; + const initialState = { changedFields: {}, initialValues: {}, @@ -92,8 +92,25 @@ export default function ShowInvestigation(props: ShowInvestigationProps) { }); const handleValueChange = (value: any, name: string) => { + const keys = name.split("."); + // Validate keys to prevent prototype pollution - coderabbit suggested + if ( + keys.some((key) => + ["__proto__", "constructor", "prototype"].includes(key), + ) + ) { + return; + } + const changedFields = { ...state.changedFields }; - set(changedFields, name, value); + let current = changedFields; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) current[key] = {}; + current = current[key]; + } + + current[keys[keys.length - 1]] = value; dispatch({ type: "set_changed_fields", changedFields }); }; @@ -151,15 +168,19 @@ export default function ShowInvestigation(props: ShowInvestigationProps) { }; const handleUpdateCancel = useCallback(() => { - const changedValues = _.chain(state.initialValues) - .map((val: any, _key: string) => ({ - id: val?.id, - initialValue: val?.notes || val?.value || null, - value: val?.value || null, - notes: val?.notes || null, - })) - .reduce((acc: any, cur: any) => ({ ...acc, [cur.id]: cur }), {}) - .value(); + const changedValues = Object.keys(state.initialValues).reduce( + (acc: any, key: any) => { + const val = state.initialValues[key]; + acc[key] = { + id: val?.id, + initialValue: val?.notes || val?.value || null, + value: val?.value || null, + notes: val?.notes || null, + }; + return acc; + }, + {}, + ); dispatch({ type: "set_changed_fields", changedFields: changedValues }); }, [state.initialValues]); diff --git a/src/components/Facility/Investigations/Table.tsx b/src/components/Facility/Investigations/Table.tsx index 3a267279eb7..49dd924480b 100644 --- a/src/components/Facility/Investigations/Table.tsx +++ b/src/components/Facility/Investigations/Table.tsx @@ -1,4 +1,3 @@ -import { set } from "lodash-es"; import { useState } from "react"; import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; @@ -60,7 +59,24 @@ export const TestTable = ({ title, data, state, dispatch }: any) => { const handleValueChange = (value: any, name: string) => { const form = { ...state }; - set(form, name, value); + const keys = name.split("."); + + // Validate keys to prevent prototype pollution - coderabbit suggested + if ( + keys.some((key) => + ["__proto__", "constructor", "prototype"].includes(key), + ) + ) { + return; + } + + let current = form; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) current[key] = {}; + current = current[key]; + } + current[keys[keys.length - 1]] = value; dispatch({ type: "set_form", form }); }; diff --git a/src/components/Form/AutoCompleteAsync.tsx b/src/components/Form/AutoCompleteAsync.tsx index b0cb9208d73..48c36258765 100644 --- a/src/components/Form/AutoCompleteAsync.tsx +++ b/src/components/Form/AutoCompleteAsync.tsx @@ -5,8 +5,7 @@ import { ComboboxOption, ComboboxOptions, } from "@headlessui/react"; -import { debounce } from "lodash-es"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; @@ -17,6 +16,8 @@ import { dropdownOptionClassNames, } from "@/components/Form/MultiSelectMenuV2"; +import useDebounce from "@/hooks/useDebounce"; + import { classNames } from "@/Utils/utils"; interface Props { @@ -69,27 +70,29 @@ const AutoCompleteAsync = (props: Props) => { const hasSelection = (!multiple && selected) || (multiple && selected?.length > 0); - const fetchDataAsync = useMemo( - () => - debounce(async (query: string) => { - setLoading(true); - const data = ((await fetchData(query)) || [])?.filter((d: any) => - filter ? filter(d) : true, - ); + const fetchDataDebounced = useDebounce(async (searchQuery: string) => { + setLoading(true); + try { + const fetchedData = (await fetchData(searchQuery)) || []; + const filteredData = filter + ? fetchedData.filter((item: any) => filter(item)) + : fetchedData; - if (showNOptions !== undefined) { - setData(data.slice(0, showNOptions)); - } else { - setData(data); - } - setLoading(false); - }, debounceTime), - [fetchData, showNOptions, debounceTime], - ); + setData( + showNOptions !== undefined + ? filteredData.slice(0, showNOptions) + : filteredData, + ); + } catch (error) { + console.error("Error fetching data:", error); + } finally { + setLoading(false); + } + }, debounceTime); useEffect(() => { - fetchDataAsync(query); - }, [query, fetchDataAsync]); + fetchDataDebounced(query); + }, [query, fetchDataDebounced]); return (
diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index e31467b6c2a..63b8c3614e5 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -1,4 +1,3 @@ -import { isEmpty, omitBy } from "lodash-es"; import { useEffect, useMemo, useState } from "react"; import { Cancel, Submit } from "@/components/Common/ButtonV2"; @@ -17,7 +16,7 @@ import { import { DraftSection, useAutoSaveReducer } from "@/Utils/AutoSave"; import * as Notification from "@/Utils/Notifications"; -import { classNames } from "@/Utils/utils"; +import { classNames, isEmpty, omitBy } from "@/Utils/utils"; type Props = { className?: string; @@ -59,6 +58,7 @@ const Form = ({ if (validate) { const errors = omitBy(validate(state.form), isEmpty) as FormErrors; + if (Object.keys(errors).length) { dispatch({ type: "set_errors", errors }); diff --git a/src/components/Patient/DiagnosesFilter.tsx b/src/components/Patient/DiagnosesFilter.tsx index c8fc2bc1a44..912a10cfde2 100644 --- a/src/components/Patient/DiagnosesFilter.tsx +++ b/src/components/Patient/DiagnosesFilter.tsx @@ -1,4 +1,3 @@ -import { debounce } from "lodash-es"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,6 +5,8 @@ import { ICD11DiagnosisModel } from "@/components/Diagnosis/types"; import { getDiagnosesByIds } from "@/components/Diagnosis/utils"; import AutocompleteMultiSelectFormField from "@/components/Form/FormFields/AutocompleteMultiselect"; +import useDebounce from "@/hooks/useDebounce"; + import { Error } from "@/Utils/Notifications"; import routes from "@/Utils/request/api"; import useQuery from "@/Utils/request/useQuery"; @@ -34,6 +35,7 @@ interface Props { value?: string; onChange: (event: { name: DiagnosesFilterKey; value: string }) => void; } + export default function DiagnosesFilter(props: Props) { const { t } = useTranslation(); const [diagnoses, setDiagnoses] = useState([]); @@ -42,6 +44,11 @@ export default function DiagnosesFilter(props: Props) { prefetch: false, }); + const handleQuery = useDebounce( + (query: string) => refetch({ query: { query } }), + 300, + ); + useEffect(() => { if (res?.status === 500) { Error({ msg: "ICD-11 Diagnosis functionality is facing issues." }); @@ -88,7 +95,7 @@ export default function DiagnosesFilter(props: Props) { options={mergeQueryOptions(diagnoses, data ?? [], (obj) => obj.id)} optionLabel={(option) => option.label} optionValue={(option) => option} - onQuery={debounce((query: string) => refetch({ query: { query } }), 300)} + onQuery={handleQuery} isLoading={loading} /> ); diff --git a/src/components/Patient/PatientRegister.tsx b/src/components/Patient/PatientRegister.tsx index 5828517adf9..a7098c24264 100644 --- a/src/components/Patient/PatientRegister.tsx +++ b/src/components/Patient/PatientRegister.tsx @@ -1,6 +1,4 @@ import careConfig from "@careConfig"; -import { startCase, toLower } from "lodash-es"; -import { debounce } from "lodash-es"; import { navigate } from "raviger"; import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -56,6 +54,7 @@ import { UserModel } from "@/components/Users/models"; import useAppHistory from "@/hooks/useAppHistory"; import useAuthUser from "@/hooks/useAuthUser"; +import useDebounce from "@/hooks/useDebounce"; import { BLOOD_GROUPS, @@ -656,7 +655,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { ? formData.last_vaccinated_date : null : null, - name: startCase(toLower(formData.name)), + name: formData.name, pincode: formData.pincode ? formData.pincode : undefined, gender: Number(formData.gender), nationality: formData.nationality, @@ -776,7 +775,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { }); }; - const duplicateCheck = debounce(async (phoneNo: string) => { + const duplicateCheck = useDebounce(async (phoneNo: string) => { if ( phoneNo && PhoneNumberValidator()(parsePhoneNumber(phoneNo) ?? "") === undefined @@ -800,6 +799,11 @@ export const PatientRegister = (props: PatientRegisterProps) => { }); } } + } else { + setStatusDialog({ + show: false, + patientList: [], + }); } }, 300); @@ -1022,6 +1026,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { {...field("name")} type="text" label={"Name"} + autoCapitalize="words" />
diff --git a/src/components/Patient/SampleDetails.tsx b/src/components/Patient/SampleDetails.tsx index 2cadda8b22e..9a779446d54 100644 --- a/src/components/Patient/SampleDetails.tsx +++ b/src/components/Patient/SampleDetails.tsx @@ -1,4 +1,3 @@ -import { camelCase, capitalize, startCase } from "lodash-es"; import { Link, navigate } from "raviger"; import { useTranslation } from "react-i18next"; @@ -271,11 +270,11 @@ export const SampleDetails = ({ id }: DetailRoute) => { {t("status")}:{" "} {" "} - {startCase(camelCase(flow.status))} + {t(`SAMPLE_TEST_HISTORY__${flow.status}`) || "Unknown"}
{t("label")}:{" "} - {capitalize(flow.notes)} + {flow.notes}
@@ -385,8 +384,8 @@ export const SampleDetails = ({ id }: DetailRoute) => {
{t("doctors_name")}:
-
- {startCase(camelCase(sampleDetails.doctor_name))} +
+ {sampleDetails.doctor_name}
)} @@ -514,9 +513,9 @@ export const SampleDetails = ({ id }: DetailRoute) => {
{t("sample_type")}:{" "}
-
- {startCase(camelCase(sampleDetails.sample_type))} -
+ + {sampleDetails.sample_type} +
)} {sampleDetails?.sample_type === "OTHER TYPE" && ( diff --git a/src/components/Patient/SampleTestCard.tsx b/src/components/Patient/SampleTestCard.tsx index c22155c494e..f1d59980c23 100644 --- a/src/components/Patient/SampleTestCard.tsx +++ b/src/components/Patient/SampleTestCard.tsx @@ -1,6 +1,6 @@ -import { camelCase, startCase } from "lodash-es"; import { navigate } from "raviger"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import ButtonV2 from "@/components/Common/ButtonV2"; import RelativeDateUserMention from "@/components/Common/RelativeDateUserMention"; @@ -24,6 +24,7 @@ interface SampleDetailsProps { } export const SampleTestCard = (props: SampleDetailsProps) => { + const { t } = useTranslation(); const { itemData, handleApproval, facilityId, patientId, refetch } = props; const [statusDialog, setStatusDialog] = useState<{ @@ -103,9 +104,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => {
- {startCase(camelCase(itemData.status))} + {t(`SAMPLE_TEST_HISTORY__${itemData.status}`) || "Unknown"}
@@ -144,9 +145,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => {
- {startCase(camelCase(itemData.result))} + {t(`SAMPLE_TEST_RESULT__${itemData.result}`) || "Unknown"}
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000000..66ee398f962 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +export default function useDebounce( + callback: (...args: T) => void, + delay: number, +) { + const callbackRef = useRef(callback); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const debouncedCallback = (...args: T) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }; + return debouncedCallback; +} From b67f03c944d8e3e9af9509002035e7017e3993db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:38:22 +0530 Subject: [PATCH 04/11] Bump cmdk from 1.0.0 to 1.0.4 (#9348) Bumps [cmdk](https://github.com/pacocoursey/cmdk/tree/HEAD/cmdk) from 1.0.0 to 1.0.4. - [Release notes](https://github.com/pacocoursey/cmdk/releases) - [Commits](https://github.com/pacocoursey/cmdk/commits/v1.0.4/cmdk) --- updated-dependencies: - dependency-name: cmdk dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 364 ++-------------------------------------------- package.json | 2 +- 2 files changed, 11 insertions(+), 355 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84fc3844cb9..d386f08e9b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "browserslist-useragent-regexp": "^4.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", + "cmdk": "^1.0.4", "cross-env": "^7.0.3", "cypress": "^13.16.1", "dayjs": "^1.11.13", @@ -7444,363 +7444,19 @@ } }, "node_modules/cmdk": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", - "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", - "dependencies": { - "@radix-ui/react-dialog": "1.0.5", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/cmdk/node_modules/@radix-ui/primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", - "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", - "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.5", - "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.4", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-portal": "1.0.4", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.1", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", - "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-escape-keydown": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", - "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", - "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-portal": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", - "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", - "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", - "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", + "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==", + "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "use-sync-external-store": "^1.2.2" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "node_modules/codepage": { diff --git a/package.json b/package.json index 53464762bfd..8fd129463e4 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "browserslist-useragent-regexp": "^4.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", + "cmdk": "^1.0.4", "cross-env": "^7.0.3", "cypress": "^13.16.1", "dayjs": "^1.11.13", From 0f7a9d8b3134ecd759c89f8757670589da1d0737 Mon Sep 17 00:00:00 2001 From: Nithish Kumar Siliveru Date: Tue, 10 Dec 2024 15:40:01 +0530 Subject: [PATCH 05/11] Fixed User Default View in Resource and Shifting (#9306) --- src/components/Resource/ResourceBoard.tsx | 9 +++++---- src/components/Resource/ResourceList.tsx | 13 ++++++++----- src/components/Shifting/ShiftingBoard.tsx | 16 +++++++++------- src/components/Shifting/ShiftingList.tsx | 16 +++++++++------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/components/Resource/ResourceBoard.tsx b/src/components/Resource/ResourceBoard.tsx index f76604ff9f6..1f8ccafcb5d 100644 --- a/src/components/Resource/ResourceBoard.tsx +++ b/src/components/Resource/ResourceBoard.tsx @@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import PageTitle from "@/components/Common/PageTitle"; @@ -92,10 +93,10 @@ export default function BoardView() { currentTab={boardFilter !== ACTIVE ? 1 : 0} />
- - + advancedFilter.setShow(true)} /> diff --git a/src/components/Resource/ResourceList.tsx b/src/components/Resource/ResourceList.tsx index 891c2f3378b..3bca8377a6b 100644 --- a/src/components/Resource/ResourceList.tsx +++ b/src/components/Resource/ResourceList.tsx @@ -5,7 +5,8 @@ import Chip from "@/CAREUI/display/Chip"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; @@ -34,8 +35,10 @@ export default function ListView() { const { t } = useTranslation(); - const onBoardViewBtnClick = () => + const onBoardViewBtnClick = () => { navigate("/resource/board", { query: qParams }); + localStorage.setItem("defaultResourceView", "board"); + }; const appliedFilters = formatFilter(qParams); const { loading, data, refetch } = useQuery(routes.listResourceRequests, { @@ -216,10 +219,10 @@ export default function ListView() {
- - + advancedFilter.setShow(true)} /> diff --git a/src/components/Shifting/ShiftingBoard.tsx b/src/components/Shifting/ShiftingBoard.tsx index 8486c8dd4d0..cd9f4300fd4 100644 --- a/src/components/Shifting/ShiftingBoard.tsx +++ b/src/components/Shifting/ShiftingBoard.tsx @@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import ConfirmDialog from "@/components/Common/ConfirmDialog"; import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; @@ -75,6 +76,10 @@ export default function BoardView() { const [boardFilter, setBoardFilter] = useState(activeBoards); const { t } = useTranslation(); + const onListViewBtnClick = () => { + navigate("/shifting/list", { query: qParams }); + localStorage.setItem("defaultShiftView", "list"); + }; return (
@@ -119,13 +124,10 @@ export default function BoardView() { />
- navigate("/shifting/list", { query: qParams })} - > - + advancedFilter.setShow(true)} /> diff --git a/src/components/Shifting/ShiftingList.tsx b/src/components/Shifting/ShiftingList.tsx index 7161ea29441..f9727bdd819 100644 --- a/src/components/Shifting/ShiftingList.tsx +++ b/src/components/Shifting/ShiftingList.tsx @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; @@ -32,6 +33,10 @@ export default function ListView() { } = useFilters({ cacheBlacklist: ["patient_name"] }); const { t } = useTranslation(); + const onBoardViewBtnClick = () => { + navigate("/shifting/board", { query: qParams }); + localStorage.setItem("defaultShiftView", "board"); + }; const { data: shiftData, loading, @@ -73,13 +78,10 @@ export default function ListView() {
- navigate("/shifting/board", { query: qParams })} - > - + advancedFilter.setShow(true)} /> From b25ecd001b117624cb4b279f7078232ea2f4fdb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:48:34 +0530 Subject: [PATCH 06/11] Bump nanoid from 3.3.7 to 3.3.8 (#9346) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d386f08e9b8..08ceb2deb4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14377,9 +14377,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", From 1f173e8447cfa459e352fc8b9b8b7af468716564 Mon Sep 17 00:00:00 2001 From: Jacob John Jeevan <40040905+Jacobjeevan@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:39:42 +0530 Subject: [PATCH 07/11] User Management Redesign and User Details Page (#9080) --- cypress/e2e/users_spec/UsersCreation.cy.ts | 9 +- cypress/e2e/users_spec/UsersHomepage.cy.ts | 12 + cypress/e2e/users_spec/UsersManage.cy.ts | 274 +++- cypress/pageobject/Login/LoginPage.ts | 6 +- cypress/pageobject/Users/ManageUserPage.ts | 319 +++- cypress/pageobject/Users/UserCreation.ts | 2 +- cypress/pageobject/Users/UserSearch.ts | 34 +- public/locale/en.json | 124 +- src/Routers/routes/UserRoutes.tsx | 9 +- src/Utils/permissions.ts | 55 +- src/Utils/utils.ts | 4 +- src/common/validation.tsx | 5 + src/components/Auth/ResetPassword.tsx | 12 +- src/components/Common/ConfirmDialog.tsx | 9 +- src/components/Common/FacilitySelect.tsx | 3 + src/components/Common/LanguageSelector.tsx | 12 +- src/components/Common/Page.tsx | 2 + src/components/Common/PageTitle.tsx | 6 +- src/components/Common/SkillSelect.tsx | 6 +- src/components/Common/Tabs.tsx | 3 +- src/components/Common/UserColumns.tsx | 36 + src/components/Common/UserDetails.tsx | 4 +- src/components/Facility/FacilityUsers.tsx | 313 +--- src/components/Form/Form.tsx | 26 +- src/components/Users/ConfirmFacilityModal.tsx | 79 + .../Users/ConfirmHomeFacilityUpdateDialog.tsx | 52 - src/components/Users/LinkFacilityDialog.tsx | 50 - src/components/Users/LinkedFacilities.tsx | 338 +++++ src/components/Users/LinkedFacilitiesTab.tsx | 43 + src/components/Users/LinkedSkills.tsx | 173 +++ src/components/Users/LinkedSkillsTab.tsx | 44 + src/components/Users/ManageUsers.tsx | 570 +------- src/components/Users/SkillsSlideOver.tsx | 175 --- .../Users/SkillsSlideOverComponents.tsx | 66 - src/components/Users/UnlinkSkillDialog.tsx | 18 +- src/components/Users/UserAdd.tsx | 938 +----------- src/components/Users/UserAddEditForm.tsx | 1289 +++++++++++++++++ src/components/Users/UserAvatar.tsx | 125 ++ src/components/Users/UserBanner.tsx | 94 ++ src/components/Users/UserEditDetails.tsx | 177 +++ src/components/Users/UserFormValidations.tsx | 161 ++ src/components/Users/UserHome.tsx | 149 ++ src/components/Users/UserListAndCard.tsx | 419 ++++++ src/components/Users/UserProfile.tsx | 2 +- src/components/Users/UserResetPassword.tsx | 213 +++ src/components/Users/UserSummary.tsx | 236 +++ src/components/Users/UserViewDetails.tsx | 155 ++ 47 files changed, 4638 insertions(+), 2213 deletions(-) create mode 100644 src/components/Common/UserColumns.tsx create mode 100644 src/components/Users/ConfirmFacilityModal.tsx delete mode 100644 src/components/Users/ConfirmHomeFacilityUpdateDialog.tsx delete mode 100644 src/components/Users/LinkFacilityDialog.tsx create mode 100644 src/components/Users/LinkedFacilities.tsx create mode 100644 src/components/Users/LinkedFacilitiesTab.tsx create mode 100644 src/components/Users/LinkedSkills.tsx create mode 100644 src/components/Users/LinkedSkillsTab.tsx delete mode 100644 src/components/Users/SkillsSlideOver.tsx delete mode 100644 src/components/Users/SkillsSlideOverComponents.tsx create mode 100644 src/components/Users/UserAddEditForm.tsx create mode 100644 src/components/Users/UserAvatar.tsx create mode 100644 src/components/Users/UserBanner.tsx create mode 100644 src/components/Users/UserEditDetails.tsx create mode 100644 src/components/Users/UserFormValidations.tsx create mode 100644 src/components/Users/UserHome.tsx create mode 100644 src/components/Users/UserListAndCard.tsx create mode 100644 src/components/Users/UserResetPassword.tsx create mode 100644 src/components/Users/UserSummary.tsx create mode 100644 src/components/Users/UserViewDetails.tsx diff --git a/cypress/e2e/users_spec/UsersCreation.cy.ts b/cypress/e2e/users_spec/UsersCreation.cy.ts index 947d5b6ce87..d93707617ff 100644 --- a/cypress/e2e/users_spec/UsersCreation.cy.ts +++ b/cypress/e2e/users_spec/UsersCreation.cy.ts @@ -43,7 +43,7 @@ describe("User Creation", () => { "Please enter valid phone number", "Please enter the username", "Please enter date in DD/MM/YYYY format", - "Please enter the password", + "Password is required", "Confirm password is required", "First Name is required", "Last Name is required", @@ -164,13 +164,10 @@ describe("User Creation", () => { cy.verifyNotification("User added successfully"); userPage.typeInSearchInput(username); userPage.checkUsernameText(username); - cy.verifyContentPresence("#name", [newUserFirstName]); + cy.verifyContentPresence(`#name-${username}`, [newUserFirstName]); cy.verifyContentPresence("#role", [role]); cy.verifyContentPresence("#district", [district]); - cy.verifyContentPresence("#home_facility", [homeFacility]); - cy.verifyContentPresence("#qualification", [qualification]); - cy.verifyContentPresence("#doctor-experience", [experience]); - cy.verifyContentPresence("#medical-council-registration", [regNo]); + cy.verifyContentPresence("#home-facility", [homeFacility]); }); it("create new user form throwing mandatory field error", () => { diff --git a/cypress/e2e/users_spec/UsersHomepage.cy.ts b/cypress/e2e/users_spec/UsersHomepage.cy.ts index 8d86482645b..acc1639e003 100644 --- a/cypress/e2e/users_spec/UsersHomepage.cy.ts +++ b/cypress/e2e/users_spec/UsersHomepage.cy.ts @@ -89,6 +89,18 @@ describe("User Homepage", () => { pageNavigation.verifyCurrentPageNumber(1); }); + it("Switch to list view, search by username and verify the results", () => { + userPage.switchToListView(); + userPage.verifyListView(); + userPage.checkSearchInputVisibility(); + userPage.typeInSearchInput(doctorUserName); + userPage.checkUrlForUsername(doctorUserName); + userPage.checkUsernameText(doctorUserName); + userPage.checkUsernameBadgeVisibility(true); + userPage.clearSearchInput(); + userPage.verifyListView(); + }); + afterEach(() => { cy.saveLocalStorage(); }); diff --git a/cypress/e2e/users_spec/UsersManage.cy.ts b/cypress/e2e/users_spec/UsersManage.cy.ts index 41557e87756..120eb551f57 100644 --- a/cypress/e2e/users_spec/UsersManage.cy.ts +++ b/cypress/e2e/users_spec/UsersManage.cy.ts @@ -1,3 +1,4 @@ +import * as dayjs from "dayjs"; import FacilityHome from "pageobject/Facility/FacilityHome"; import { advanceFilters } from "pageobject/utils/advanceFilterHelpers"; @@ -17,11 +18,14 @@ describe("Manage User", () => { const firstNameUserSkill = "Dev"; const lastNameUserSkill = "Doctor"; const usernameforworkinghour = "devdistrictadmin"; + const nurseUsername = "dummynurse1"; + const doctorUsername = "devdoctor"; + const doctorToDelete = "dummydoctor12"; const usernamerealname = "Dummy Doctor"; const facilitytolinkusername = "Dummy Shifting Center"; const facilitytolinkskill = "Dummy Facility 40"; const workinghour = "23"; - const linkedskill = "General Medicine"; + const linkedskill = "Immunologist"; before(() => { loginPage.loginByRole("districtAdmin"); @@ -34,24 +38,221 @@ describe("Manage User", () => { cy.awaitUrl("/users"); }); + // To Do: Add avatar upload + /* it("District Admin can change their own avatar", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyChangeAvatarButtonVisible(); + manageUserPage.clickChangeAvatarButton(); + }); */ + + it("edit a nurse user's basic information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickBasicInfoViewButton(); + manageUserPage.clickBasicInfoEditButton(); + manageUserPage.clearUserBasicInfo(); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("First Name is required"); + manageUserPage.verifyErrorText("Last Name is required"); + manageUserPage.editUserBasicInfo("Devo", "Districto", "11081999", "Female"); + manageUserPage.clickSubmit(); + manageUserPage.clickBasicInfoViewButton(); + manageUserPage.verifyEditUserDetails( + "Devo", + "Districto", + "8/11/1999", + "Female", + ); + }); + + it("edit a nurse user's contact information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickContactInfoViewButton(); + manageUserPage.clickContactInfoEditButton(); + manageUserPage.clearUserContactInfo(); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Please enter a valid email address"); + manageUserPage.verifyErrorText("Please enter valid phone number"); + manageUserPage.editUserContactInfo("dev@gmail.com", "6234343435"); + manageUserPage.clickSubmit(); + manageUserPage.clickContactInfoViewButton(); + manageUserPage.verifyEditUserContactInfo("dev@gmail.com", "6234343435"); + }); + + it("edit a nurse user's professional information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickProfessionalInfoViewButton(); + // Should have qualification field + // Should not have years of experience and medical council registration fields + manageUserPage.verifyQualificationExist(); + manageUserPage.verifyYoeAndCouncilRegistrationDoesntExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearDoctorOrNurseProfessionalInfo(false); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Qualification is required"); + manageUserPage.editUserProfessionalInfo("Msc"); + manageUserPage.clickSubmit(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyEditUserProfessionalInfo("Msc"); + }); + + it("edit a doctor user's professional information and verify its reflection", () => { + // Should have qualification, years of experience and medical council registration + userPage.typeInSearchInput(usernameToLinkFacilitydoc1); + userPage.checkUsernameText(usernameToLinkFacilitydoc1); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc1); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyQualificationExist(); + manageUserPage.verifyYoeAndCouncilRegistrationExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearDoctorOrNurseProfessionalInfo(true); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Qualification is required"); + manageUserPage.verifyErrorText("Years of experience is required"); + manageUserPage.verifyErrorText("Medical Council Registration is required"); + manageUserPage.editUserProfessionalInfo("Msc", "120", "1234567890"); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText( + "Please enter a valid number between 0 and 100.", + ); + manageUserPage.clearDoctorOrNurseProfessionalInfo(true); + manageUserPage.editUserProfessionalInfo("Msc", "10", "1234567890"); + manageUserPage.clickSubmit(); + manageUserPage.clickProfessionalInfoViewButton(); + const experienceCommencedOn = dayjs().subtract(10, "year"); + const formattedDate = dayjs(experienceCommencedOn).format("YYYY-MM-DD"); + manageUserPage.verifyEditUserProfessionalInfo( + "Msc", + formattedDate, + "1234567890", + ); + }); + + it("Nurse user doesn't have edit options or password change option (for other users)", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(doctorUsername); + userPage.checkUsernameText(doctorUsername); + manageUserPage.clickMoreDetailsButton(doctorUsername); + manageUserPage.verifyMoreDetailsPage(false); + manageUserPage.verifyUsername(doctorUsername); + manageUserPage.verifyBasicInfoEditButtonNotExist(); + manageUserPage.verifyContactInfoEditButtonNotExist(); + manageUserPage.verifyProfessionalInfoEditButtonNotExist(); + manageUserPage.verifyPasswordEditButtonNotExist(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("Nurse user doesn't have delete option for other users", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(doctorUsername); + userPage.checkUsernameText(doctorUsername); + manageUserPage.clickMoreDetailsButton(doctorUsername); + manageUserPage.verifyMoreDetailsPage(false); + manageUserPage.verifyDeleteButtonNotExist(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("Nurse user can change their own password", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickPasswordEditButton(); + manageUserPage.changePassword("Coronasafe@123", "Coronasafe@1233"); + manageUserPage.clickSubmit(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse("Coronasafe@1233"); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickPasswordEditButton(); + manageUserPage.changePassword("Coronasafe@1233", "Coronasafe@123"); + manageUserPage.clickSubmit(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("District Admin can delete a user", () => { + userPage.typeInSearchInput(doctorToDelete); + userPage.checkUsernameText(doctorToDelete); + manageUserPage.clickMoreDetailsButton(doctorToDelete); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyDeleteButtonVisible(); + manageUserPage.clickDeleteButton(); + manageUserPage.clickSubmit(); + cy.verifyNotification("User Deleted Successfully"); + cy.closeNotification(); + userPage.typeInSearchInput(doctorToDelete); + userPage.checkUsernameTextDoesNotExist(doctorToDelete); + }); + it("linking skills for users and verify its reflection in profile", () => { // select the district user and select one skill link and verify its profile reflection userPage.typeInSearchInput(usernameforworkinghour); userPage.checkUsernameText(usernameforworkinghour); - manageUserPage.clicklinkedskillbutton(); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + cy.wait(500); + manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); manageUserPage.clickAddSkillButton(); - manageUserPage.clickCloseSlideOver(); - cy.wait(5000); - manageUserPage.clicklinkedskillbutton(); + cy.wait(500); manageUserPage.assertSkillInAddedUserSkills(linkedskill); - manageUserPage.clickCloseSlideOver(); - cy.wait(5000); + cy.wait(500); manageUserPage.navigateToProfile(); cy.verifyContentPresence("#username-profile-details", [ usernameforworkinghour, ]); manageUserPage.assertSkillInAlreadyLinkedSkills(linkedskill); + // unlink the skill + manageUserPage.navigateToManageUser(); + userPage.typeInSearchInput(usernameforworkinghour); + userPage.checkUsernameText(usernameforworkinghour); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + manageUserPage.assertSkillInAddedUserSkills(linkedskill); + manageUserPage.clickUnlinkSkill(); + manageUserPage.verifyUnlinkSkillModal(); + manageUserPage.clickConfirmUnlinkSkill(); }); it("linking skills for a doctor users and verify its reflection in doctor connect", () => { @@ -62,13 +263,15 @@ describe("Manage User", () => { userPage.selectHomeFacility(facilitytolinkskill); advanceFilters.applySelectedFilter(); userPage.checkUsernameText(usernameToLinkSkill); - manageUserPage.clicklinkedskillbutton(); + manageUserPage.clickMoreDetailsButton(usernameToLinkSkill); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); manageUserPage.clickAddSkillButton(); cy.verifyNotification("Skill added successfully"); cy.closeNotification(); manageUserPage.assertSkillInAddedUserSkills(linkedskill); - manageUserPage.clickCloseSlideOver(); // verifying the doctor connect facilityHome.navigateToFacilityHomepage(); facilityHome.typeFacilitySearch(facilitytolinkskill); @@ -79,18 +282,28 @@ describe("Manage User", () => { }); it("add working hour for a user and verify its reflection in card and user profile", () => { - // verify mandatory field error and select working hour for a user + // verify qualification and yoe and council registration fields are not present + // verify field error and add working hour userPage.typeInSearchInput(usernameforworkinghour); userPage.checkUsernameText(usernameforworkinghour); - manageUserPage.clicksetaveragehourbutton(); - manageUserPage.clearweeklyhourfield(); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyProfileTabPage(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyQualificationDoesntExist(); + manageUserPage.verifyYoeAndCouncilRegistrationDoesntExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearProfessionalInfo(); + manageUserPage.typeInWeeklyWorkingHours("200"); manageUserPage.clickSubmit(); - manageUserPage.verifyErrorText("Value should be between 0 and 168"); - // verify the data is reflected in user card and profile page + manageUserPage.verifyErrorText( + "Average weekly working hours must be a number between 0 and 168", + ); + manageUserPage.clearProfessionalInfo(); manageUserPage.typeInWeeklyWorkingHours(workinghour); manageUserPage.clickSubmit(); + // verify the data is reflected in the page manageUserPage.verifyWorkingHours(workinghour); - manageUserPage.navigateToProfile(); manageUserPage.verifyProfileWorkingHours(workinghour); }); @@ -98,42 +311,51 @@ describe("Manage User", () => { // verify the user doesn't have any home facility userPage.typeInSearchInput(usernameToLinkFacilitydoc1); userPage.checkUsernameText(usernameToLinkFacilitydoc1); - manageUserPage.assertHomeFacility("No Home Facility"); + manageUserPage.assertHomeFacility("No home facility"); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc1); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); // Link a new facility and ensure it is under linked facility - doctor username (1) - manageUserPage.clickFacilitiesTab(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); manageUserPage.assertLinkedFacility(facilitytolinkusername); // Verify in the already linked facility are not present in droplist manageUserPage.assertFacilityNotInDropdown(facilitytolinkusername); - manageUserPage.clickCloseSlideOver(); + // Go back to manage user page + manageUserPage.navigateToManageUser(); // Link a new facility and ensure it is under home facility - doctor username (2) - userPage.clearSearchInput(); userPage.typeInSearchInput(usernameToLinkFacilitydoc2); userPage.checkUsernameText(usernameToLinkFacilitydoc2); - manageUserPage.clickFacilitiesTab(); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc2); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); - manageUserPage.clickHomeFacilityIcon(); + manageUserPage.clickLinkedFacilitySettings(); + manageUserPage.clickSetHomeFacility(); manageUserPage.assertnotLinkedFacility(facilitytolinkusername); manageUserPage.assertHomeFacilitylink(facilitytolinkusername); - manageUserPage.clickCloseSlideOver(); // verify the home facility doctor id have reflection in user card - userPage.clearSearchInput(); + manageUserPage.navigateToManageUser(); userPage.typeInSearchInput(usernameToLinkFacilitydoc2); userPage.checkUsernameText(usernameToLinkFacilitydoc2); manageUserPage.assertHomeFacility(facilitytolinkusername); // Link a new facility and unlink the facility from the doctor username (3) - userPage.clearSearchInput(); + manageUserPage.navigateToManageUser(); userPage.typeInSearchInput(usernameToLinkFacilitydoc3); userPage.checkUsernameText(usernameToLinkFacilitydoc3); - manageUserPage.clickFacilitiesTab(); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc3); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); + manageUserPage.clickLinkedFacilitySettings(); manageUserPage.clickUnlinkFacilityButton(); manageUserPage.clickSubmit(); manageUserPage.linkedfacilitylistnotvisible(); - manageUserPage.clickCloseSlideOver(); // Go to particular facility doctor connect and all user-id are reflected based on there access // Path will be facility page to patient page then doctor connect button facilityHome.navigateToFacilityHomepage(); diff --git a/cypress/pageobject/Login/LoginPage.ts b/cypress/pageobject/Login/LoginPage.ts index 481c6ec3045..64e9cd5f25e 100644 --- a/cypress/pageobject/Login/LoginPage.ts +++ b/cypress/pageobject/Login/LoginPage.ts @@ -19,9 +19,11 @@ class LoginPage { cy.clickSubmitButton("Login"); } - loginManuallyAsNurse(): void { + loginManuallyAsNurse(password?: string): void { cy.get("input[id='username']").click().type("dummynurse1"); - cy.get("input[id='password']").click().type("Coronasafe@123"); + cy.get("input[id='password']") + .click() + .type(password || "Coronasafe@123"); cy.clickSubmitButton("Login"); } diff --git a/cypress/pageobject/Users/ManageUserPage.ts b/cypress/pageobject/Users/ManageUserPage.ts index 01871acd97d..f5bbce7e95b 100644 --- a/cypress/pageobject/Users/ManageUserPage.ts +++ b/cypress/pageobject/Users/ManageUserPage.ts @@ -1,10 +1,6 @@ export class ManageUserPage { assertHomeFacility(expectedText: string) { - cy.get("#home_facility").should("contain.text", expectedText); - } - - clickFacilitiesTab() { - cy.get("#facilities").click(); + cy.get("#home-facility").should("contain.text", expectedText); } selectFacilityFromDropdown(facilityName: string) { @@ -15,10 +11,6 @@ export class ManageUserPage { cy.typeAndSelectOption("input[name='skill']", skill); } - clickLinkFacility() { - cy.get("#link-facility").click(); - } - assertLinkedFacility(facilityName: string) { cy.get("#linked-facility-list").should("contain.text", facilityName); } @@ -40,36 +32,155 @@ export class ManageUserPage { cy.get("[role='option']").should("not.exist"); } - clickCloseSlideOver() { - cy.get("#close-slide-over").click({ force: true }); + clickLinkedFacilitySettings() { + cy.get("#linked-facility-settings").click(); } - clickHomeFacilityIcon() { - cy.get("#home-facility-icon").click(); + clickSetHomeFacility() { + cy.get("#set-home-facility").click(); } clickUnlinkFacilityButton() { - cy.get("#unlink-facility-button").click(); + cy.get("#unlink-facility").click(); + } + + clickConfirmUnlinkSkill() { + cy.get("button[name='confirm-unlink-skill']").click(); + } + + clickLinkFacility() { + cy.get("#link-facility").click(); } clickSubmit() { cy.get("#submit").click(); } - clicksetaveragehourbutton() { - cy.get("#avg-workinghour").click(); + verifyErrorText(expectedError: string) { + cy.get(".error-text").first().scrollIntoView(); + cy.get(".error-text") + .should("be.visible") + .then(($elements) => { + const errorTextArray = Array.from($elements).map( + (el) => el.textContent, + ); + expect(errorTextArray).to.include(expectedError); + }); } - clearweeklyhourfield() { - cy.get("#weekly_working_hours").click().clear(); + clearUserBasicInfo() { + cy.get("input[name='first_name']").click().clear(); + cy.get("input[name='last_name']").click().clear(); } - verifyErrorText(expectedError: string) { - cy.get(".error-text").should("contain", expectedError).and("be.visible"); + editUserBasicInfo( + fName: string, + lName: string, + dateOfBirth: string, + gender: string, + ) { + cy.get("input[name='first_name']").click().type(fName); + cy.get("input[name='last_name']").click().type(lName); + cy.clickAndTypeDate("#date_of_birth", dateOfBirth); + cy.get("#gender").click(); + cy.get("[role='option']").contains(gender).click(); + } + + verifyEditUserDetails( + fName: string, + lName: string, + dateOfBirth: string, + gender: string, + ) { + cy.get("#view-first_name").should("contain.text", fName); + cy.get("#view-last_name").should("contain.text", lName); + cy.get("#view-date_of_birth").should("contain.text", dateOfBirth); + cy.get("#view-gender").should("contain.text", gender); + } + + clearUserContactInfo() { + cy.get("input[name='email']").click().clear(); + cy.get("input[name='phone_number']").click().clear(); + cy.get("input[name='phone_number_is_whatsapp']").should("be.checked"); + } + + editUserContactInfo(email: string, phoneNumber: string) { + cy.get("input[name='email']").click().type(email); + cy.get("input[name='phone_number']").click().type(phoneNumber); + cy.get("input[name='phone_number_is_whatsapp']").should("be.checked"); + } + + verifyEditUserContactInfo(email: string, phoneNumber: string) { + cy.get("#view-email").should("contain.text", email); + cy.get("#view-phone_number").should("contain.text", phoneNumber); + cy.get("#view-whatsapp_number").should("contain.text", phoneNumber); + } + + clearDoctorOrNurseProfessionalInfo(yoeAndCouncilRegistration: boolean) { + cy.get("input[name='qualification']").click().clear(); + if (yoeAndCouncilRegistration) { + cy.get("input[name='doctor_experience_commenced_on']").click().clear(); + cy.get("input[name='doctor_medical_council_registration']") + .click() + .clear(); + } + } + + clearProfessionalInfo() { + cy.get("input[name='weekly_working_hours']").click().clear(); + cy.get("input[name='video_connect_link']").click().clear(); + } + + editUserProfessionalInfo( + qualification: string, + yearsOfExperience?: string, + medicalCouncilRegistration?: string, + ) { + cy.get("input[name='qualification']").click().type(qualification); + if (yearsOfExperience) { + cy.get("input[name='doctor_experience_commenced_on']") + .click() + .type(yearsOfExperience); + } + if (medicalCouncilRegistration) { + cy.get("input[name='doctor_medical_council_registration']") + .click() + .type(medicalCouncilRegistration); + } + } + + verifyEditUserProfessionalInfo( + qualification: string, + yearsOfExperience?: string, + medicalCouncilRegistration?: string, + ) { + cy.get("#view-qualification").should("contain.text", qualification); + if (yearsOfExperience) { + cy.get("#view-years_of_experience").should( + "contain.text", + yearsOfExperience, + ); + } + if (medicalCouncilRegistration) { + cy.get("#view-doctor_medical_council_registration").should( + "contain.text", + medicalCouncilRegistration, + ); + } + } + + verifyPasswordEditButtonNotExist() { + cy.get("#change-edit-password-button").should("not.exist"); + } + + changePassword(oldPassword: string, newPassword: string) { + cy.get("input[name='old_password']").click().type(oldPassword); + cy.get("input[name='new_password_1']").click().type(newPassword); + cy.get("input[name='new_password_2']").click().type(newPassword); } typeInWeeklyWorkingHours(hours: string) { - cy.get("#weekly_working_hours").click().type(hours); + cy.get("input[name='weekly_working_hours']").click().type(hours); } navigateToProfile() { @@ -80,25 +191,174 @@ export class ManageUserPage { } verifyWorkingHours(expectedHours: string) { - cy.get("#working-hours").should("contain", `${expectedHours} hours`); + cy.get("input[name='weekly_working_hours']").should("be.visible"); + cy.get("input[name='weekly_working_hours']").should( + "have.value", + expectedHours, + ); } verifyProfileWorkingHours(expectedHours: string) { - cy.get("#averageworkinghour-profile-details").should( - "contain", + cy.get("#view-average_weekly_working_hours").should( + "contain.text", expectedHours, ); } + navigateToManageUser() { + cy.visit("/users"); + } + clickFacilityPatients() { cy.get("#facility-patients").should("be.visible"); cy.get("#facility-patients").click(); } - clicklinkedskillbutton() { + clickLinkedSkillTab() { cy.get("#skills").click(); } + clickLinkedFacilitiesTab() { + cy.get("#facilities").click(); + } + + clickMoreDetailsButton(username: string) { + cy.intercept("GET", "**/api/v1/users/**").as("getUserDetails"); + cy.get(`#more-details-${username}`).click(); + cy.wait("@getUserDetails"); + } + + verifyMoreDetailsPage(hasPermissions = true) { + cy.get("#username").should("be.visible"); + cy.get("#role").should("be.visible"); + cy.get("#usermanagement_tab_nav").should("be.visible"); + cy.get("#profile").should("be.visible"); + if (hasPermissions) { + cy.get("#facilities").should("be.visible"); + cy.get("#skills").should("be.visible"); + } + cy.get("#view-username").scrollIntoView(); + cy.get("#view-username").should("be.visible"); + } + + verifyChangeAvatarButtonVisible() { + cy.get("#change-avatar").should("be.visible"); + } + + clickChangeAvatarButton() { + cy.get("#change-avatar").click(); + } + + clickBasicInfoViewButton() { + cy.get("#basic-info-view-button").scrollIntoView(); + cy.get("#basic-info-view-button").should("be.visible"); + cy.get("#basic-info-view-button").click(); + } + + clickBasicInfoEditButton() { + cy.get("#basic-info-edit-button").scrollIntoView(); + cy.get("#basic-info-edit-button").should("be.visible"); + cy.get("#basic-info-edit-button").click(); + } + + clickContactInfoViewButton() { + cy.get("#contact-info-view-button").scrollIntoView(); + cy.get("#contact-info-view-button").should("be.visible"); + cy.get("#contact-info-view-button").click(); + } + + clickContactInfoEditButton() { + cy.get("#contact-info-edit-button").scrollIntoView(); + cy.get("#contact-info-edit-button").should("be.visible"); + cy.get("#contact-info-edit-button").click(); + } + + clickProfessionalInfoViewButton() { + cy.get("#professional-info-view-button").scrollIntoView(); + cy.get("#professional-info-view-button").should("be.visible"); + cy.get("#professional-info-view-button").click(); + } + + clickProfessionalInfoEditButton() { + cy.get("#professional-info-edit-button").scrollIntoView(); + cy.get("#professional-info-edit-button").should("be.visible"); + cy.get("#professional-info-edit-button").click(); + } + + clickPasswordEditButton() { + cy.get("#change-edit-password-button").scrollIntoView(); + cy.get("#change-edit-password-button").should("be.visible"); + cy.get("#change-edit-password-button").click(); + } + + verifyQualificationDoesntExist() { + cy.get("input[name='qualification']").should("not.exist"); + } + + verifyQualificationExist() { + cy.get("#view-qualification").should("be.visible"); + } + + verifyYoeAndCouncilRegistrationDoesntExist() { + cy.get("#view-years_of_experience").should("not.exist"); + cy.get("#view-doctor_medical_council_registration").should("not.exist"); + } + + verifyYoeAndCouncilRegistrationExist() { + cy.get("#view-years_of_experience").should("be.visible"); + cy.get("#view-doctor_medical_council_registration").should("be.visible"); + } + + verifyUsername(username: string) { + cy.get("#view-username").should("contain", username); + } + + verifyBasicInfoEditButtonNotExist() { + cy.get("#basic-info-edit-button").should("not.exist"); + } + + verifyContactInfoEditButtonNotExist() { + cy.get("#contact-info-edit-button").should("not.exist"); + } + + verifyProfessionalInfoEditButtonNotExist() { + cy.get("#professional-info-edit-button").should("not.exist"); + } + + verifyProfileTabPage() { + cy.get("#user-edit-form").should("be.visible"); + } + + verifyDoctorQualification() { + cy.get("#view-qualification").should("be.visible"); + } + + verifyDoctorQualificationDoesNotExist() { + cy.get("#view-qualification").should("not.exist"); + } + + verifyLinkedSkillsTabPage() { + cy.get("#select-skill").scrollIntoView(); + cy.get("#select-skill").should("be.visible"); + } + + verifyLinkedFacilitiesTabPage() { + cy.get("#select-facility").should("be.visible"); + } + + verifyDeleteButtonNotExist() { + cy.get("[data-testid='user-delete-button']").should("not.exist"); + } + + verifyDeleteButtonVisible() { + cy.get("[data-testid='user-delete-button']").scrollIntoView(); + cy.get("[data-testid='user-delete-button']").should("be.visible"); + } + + clickDeleteButton() { + cy.get("[data-testid='user-delete-button']").click(); + } + clickAddSkillButton() { cy.intercept("GET", "**/api/v1/skill/**").as("getSkills"); cy.get("#add-skill-button").click(); @@ -125,10 +385,19 @@ export class ManageUserPage { cy.get("#unlink-skill").click(); } + verifyUnlinkSkillModal() { + cy.get("#unlink-skill-modal-description").should("be.visible"); + cy.get("button[name='confirm-unlink-skill']").should("be.visible"); + } + assertSkillInAddedUserSkills(skillName: string) { cy.get("#added-user-skills").should("contain", skillName); } + assertSkillNotInAddedUserSkills(skillName: string) { + cy.get("#added-user-skills").should("not.contain", skillName); + } + assertDoctorConnectVisibility(realName: string) { cy.get('*[id="doctor-connect-home-doctor"]').should( "contain.text", diff --git a/cypress/pageobject/Users/UserCreation.ts b/cypress/pageobject/Users/UserCreation.ts index d8d386402b7..26eaa088e23 100644 --- a/cypress/pageobject/Users/UserCreation.ts +++ b/cypress/pageobject/Users/UserCreation.ts @@ -46,6 +46,6 @@ export class UserCreationPage { } clickSaveUserButton() { - cy.clickSubmitButton("Save User"); + cy.clickSubmitButton("Submit"); } } diff --git a/cypress/pageobject/Users/UserSearch.ts b/cypress/pageobject/Users/UserSearch.ts index 9d996abd83f..882a4dc8ea6 100644 --- a/cypress/pageobject/Users/UserSearch.ts +++ b/cypress/pageobject/Users/UserSearch.ts @@ -23,7 +23,11 @@ export class UserPage { } checkUsernameText(username: string) { - cy.get(this.usernameText).should("contain.text", username); + cy.get(`${this.usernameText}-${username}`).should("contain.text", username); + } + + checkUsernameTextDoesNotExist(username: string) { + cy.get(`${this.usernameText}-${username}`).should("not.exist"); } checkUsernameBadgeVisibility(shouldBeVisible: boolean) { @@ -69,24 +73,16 @@ export class UserPage { } verifyMultipleBadgesWithSameId(alreadylinkedusersviews: string[]) { - cy.get("#user-view-name").then(($elements) => { - const userViews = $elements - .map((_, el) => Cypress.$(el).text().trim()) - .get(); - let foundMatches = 0; - - alreadylinkedusersviews.forEach((expectedContent) => { - const index = userViews.findIndex((actualContent) => - actualContent.includes(expectedContent), - ); - if (index !== -1) { - userViews.splice(index, 1); // Remove the matched element - foundMatches++; - } - if (foundMatches === alreadylinkedusersviews.length) { - return false; // Break the loop if all matches are found - } - }); + cy.wrap(alreadylinkedusersviews).each((username) => { + cy.get(`#name-${username}`).scrollIntoView().should("be.visible"); }); } + + switchToListView() { + cy.get("#user-list-view").click(); + } + + verifyListView() { + cy.get("#user-list-view").should("have.class", "text-white"); + } } diff --git a/public/locale/en.json b/public/locale/en.json index d73268df952..9f0bc5ecf12 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -223,6 +223,9 @@ "URINATION_FREQUENCY__DECREASED": "Decreased", "URINATION_FREQUENCY__INCREASED": "Increased", "URINATION_FREQUENCY__NORMAL": "Normal", + "USERMANAGEMENT_TAB__FACILITIES": "Linked Facilities", + "USERMANAGEMENT_TAB__PROFILE": "User Information", + "USERMANAGEMENT_TAB__SKILLS": "Linked Skills", "VENTILATOR": "Detailed Update", "VENTILATOR_MODE__CMV": "Control Mechanical Ventilation (CMV)", "VENTILATOR_MODE__CMV_short": "CMV", @@ -293,6 +296,7 @@ "add_consultation": "Add consultation", "add_consultation_update": "Add Consultation Update", "add_details_of_patient": "Add Details of Patient", + "add_facility": "Add Facility", "add_insurance_details": "Add Insurance Details", "add_location": "Add Location", "add_new_beds": "Add New Bed(s)", @@ -304,6 +308,7 @@ "add_preset": "Add preset", "add_prn_prescription": "Add PRN Prescription", "add_remarks": "Add remarks", + "add_skill": "Add Skill", "add_spoke": "Add Spoke Facility", "address": "Address", "administer": "Administer", @@ -377,6 +382,7 @@ "autofilled_fields": "Autofilled Fields", "available_features": "Available Features", "available_in": "Available in", + "avatar_updated_success": "Avatar updated successfully", "average_weekly_working_hours": "Average weekly working hours", "awaiting_destination_approval": "AWAITING DESTINATION APPROVAL", "back": "Back", @@ -384,6 +390,7 @@ "back_to_consultation": "Go back to Consultation", "back_to_login": "Back to login", "base_dosage": "Dosage", + "basic_info": "Basic Information", "bed_capacity": "Bed Capacity", "bed_created_notification_one": "{{count}} Bed created successfully", "bed_created_notification_other": "{{count}} Beds created successfully", @@ -418,6 +425,8 @@ "category": "Category", "caution": "Caution", "central_nursing_station": "Central Nursing Station", + "change_avatar": "Change Avatar", + "change_avatar_note": "JPG, GIF or PNG. 1MB max.", "change_file": "Change File", "change_password": "Change Password", "chat_on_whatsapp": "Chat on Whatsapp", @@ -431,8 +440,11 @@ "checking_for_update": "Checking for update", "checking_policy_eligibility": "Checking Policy Eligibility", "choose_date_time": "Choose Date and Time", + "choose_district": "Choose District", "choose_file": "Upload From Device", + "choose_localbody": "Choose Local Body", "choose_location": "Choose Location", + "choose_state": "Choose State", "claim__add_item": "Add Item", "claim__create_claim": "Create Claim", "claim__create_preauthorization": "Create Pre Authorization", @@ -468,7 +480,10 @@ "clear": "Clear", "clear_all_filters": "Clear All Filters", "clear_home_facility": "Clear Home Facility", + "clear_home_facility_confirm": "Are you sure you want to clear the home facility", + "clear_home_facility_error": "Error while clearing home facility. Try again later.", "clear_selection": "Clear selection", + "clear_skill": "Clear Skill", "close": "Close", "close_scanner": "Close Scanner", "collapse_sidebar": "Collapse Sidebar", @@ -483,6 +498,7 @@ "confirm_delete": "Confirm Delete", "confirm_discontinue": "Confirm Discontinue", "confirm_password": "Confirm Password", + "confirm_password_required": "Confirm password is required", "confirm_transfer_complete": "Confirm Transfer Complete!", "confirmed": "Confirmed", "consent__hi_range": "Health Information Range", @@ -523,7 +539,10 @@ "consultation_not_filed_description": "Please file a consultation for this patient to continue.", "consultation_notes": "General Instructions (Advice)", "consultation_updates": "Consultation updates", - "contact_info": "Contact Info", + "contact_info": "Contact Information", + "contact_info_note": "View or update user's contact information", + "contact_info_note_self": "View or update your contact information", + "contact_info_note_view": "View user's contact information", "contact_number": "Contact Number", "contact_person": "Name of Contact Person at Facility", "contact_person_at_the_facility": "Contact person at the current facility", @@ -531,6 +550,7 @@ "contact_phone": "Contact Person Number", "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", "contact_your_admin_to_add_skills": "Contact your admin to add skills", "continue": "Continue", "continue_watching": "Continue watching", @@ -578,6 +598,9 @@ "days": "Days", "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_facility": "Delete Facility", "delete_item": "Delete {{name}}", "delete_record": "Delete Record", @@ -621,6 +644,9 @@ "disease_status": "Disease status", "district": "District", "district_program_management_supporting_unit": "District Program Management Supporting Unit", + "dob_format": "Please enter date in DD/MM/YYYY format", + "doctor_experience_error": "Please enter a valid number between 0 and 100.", + "doctor_experience_required": "Years of experience is required", "doctor_s_medical_council_registration": "Doctor's Medical Council Registration", "doctors_name": "Doctor's Name", "domestic_healthcare_support": "Domestic healthcare support", @@ -640,6 +666,9 @@ "duplicate_patient_record_rejection": "I confirm that the suspect / patient I want to create is not on the list.", "edit": "Edit", "edit_avatar": "Edit Avatar", + "edit_avatar_note": "Change the avatar of the user", + "edit_avatar_note_self": "Change your avatar", + "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_history": "Edit History", @@ -692,6 +721,8 @@ "enter_mobile_otp": "Enter OTP sent to the given mobile number", "enter_otp": "Enter OTP sent to the registered mobile with the respective ID", "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", "entered-in-error": "Entered in error", "error_404": "Error 404", "error_deleting_shifting": "Error while deleting Shifting record", @@ -709,6 +740,7 @@ "facility": "Facility", "facility_consent_requests_page_title": "Patient Consent List", "facility_district_name": "Facility/District Name", + "facility_linked_success": "Facility linked successfully", "facility_name": "Facility Name", "facility_preference": "Facility preference", "facility_search_placeholder": "Search by Facility / District Name", @@ -742,10 +774,12 @@ "filter_by_category": "Filter by category", "filters": "Filters", "first_name": "First Name", + "first_name_required": "First Name is required", "footer_body": "Open Healthcare Network is an open-source public utility designed by a multi-disciplinary team of innovators and volunteers. Open Healthcare Network CARE is a Digital Public Good recognised by the United Nations.", "forget_password": "Forgot password?", "forget_password_instruction": "Enter your username, and if it exists, we will send you a link to reset your password.", "frequency": "Frequency", + "from_user": "from User", "full_name": "Full Name", "full_screen": "Full Screen", "gender": "Gender", @@ -794,6 +828,9 @@ "hide": "Hide", "high": "High", "home_facility": "Home Facility", + "home_facility_cleared_success": "Home Facility cleared successfully", + "home_facility_updated_error": "Error while updating Home Facility", + "home_facility_updated_success": "Home Facility updated successfully", "hubs": "Hub Facilities", "i_declare": "I hereby declare that:", "icd11_as_recommended": "As per ICD-11 recommended by WHO", @@ -812,7 +849,7 @@ "insurer_name_required": "Insurer Name is required", "international_mobile": "International Mobile", "invalid_asset_id_msg": "Oops! The asset ID you entered does not appear to be valid.", - "invalid_email": "Please Enter a Valid Email Address", + "invalid_email": "Please enter a valid email address", "invalid_ip_address": "Invalid IP Address", "invalid_link_msg": "It appears that the password reset link you have used is either invalid or expired. Please request a new password reset link.", "invalid_password": "Password doesn't meet the requirements", @@ -822,7 +859,11 @@ "invalid_phone_number": "Invalid Phone Number", "invalid_pincode": "Invalid Pincode", "invalid_reset": "Invalid Reset", + "invalid_url": "Please enter a valid url", + "invalid_url_http_https": "URL should start with http:// or https://", + "invalid_url_javascript": "URL should not include javascript, please enter a valid URL.", "invalid_username": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + "invalid_username_format": "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -", "inventory_management": "Inventory Management", "investigation_report": "Investigation Report", "investigation_report_for_{{name}}": "Investigation Report for {{name}}", @@ -845,6 +886,7 @@ "is_emergency": "Is emergency", "is_emergency_case": "Is emergency case", "is_it_upshift": "is it upshift", + "is_phone_a_whatsapp_number": "Is the phone number a WhatsApp number?", "is_pregnant": "Is pregnant", "is_this_an_emergency": "Is this an emergency?", "is_this_an_upshift": "Is this an upshift?", @@ -861,6 +903,7 @@ "last_edited": "Last Edited", "last_modified": "Last Modified", "last_name": "Last Name", + "last_name_required": "Last Name is required", "last_online": "Last Online", "last_serviced_on": "Last Serviced On", "last_updated_by": "Last updated by", @@ -872,8 +915,11 @@ "link_abha_profile": "Link ABHA Profile", "link_camera_and_bed": "Link bed to Camera", "link_existing_abha_profile": "Already have an ABHA number", + "link_facility_error": "Error while linking facility. Try again later.", "linked_facilities": "Linked Facilities", + "linked_facilities_note": "Add or remove facilities and set or change the Home Facility", "linked_skills": "Linked Skills", + "linked_skills_note": "Search and select skills to add to the skill set", "liquid_oxygen_capacity": "Liquid Oxygen Capacity", "list_view": "List View", "litres": "Litres", @@ -901,6 +947,7 @@ "manage_bed_presets": "Manage Presets of Bed", "manage_prescriptions": "Manage Prescriptions", "manage_preset": "Manage preset {{ name }}", + "manage_user": "Manage User", "manufacturer": "Manufacturer", "map_acronym": "M.A.P.", "mark_all_as_read": "Mark all as Read", @@ -915,6 +962,7 @@ "measured_before": "Measured before", "medical": "Medical", "medical_council_registration": "Medical Council Registration", + "medical_council_registration_required": "Medical Council Registration is required", "medical_worker": "Medical Worker", "medicine": "Medicine", "medicine_administration_history": "Medicine Administration History", @@ -941,6 +989,7 @@ "modified_date": "Modified Date", "modified_on": "Modified On", "monitor": "Monitor", + "more_details": "More details", "more_info": "More Info", "move_to_onvif_preset": "Move to an ONVIF Preset", "moving_camera": "Moving Camera", @@ -951,6 +1000,8 @@ "never": "never", "new_password": "New Password", "new_password_confirmation": "Confirm New Password", + "new_password_same_as_old": "New password is same as old password, please enter a different new password.", + "new_password_validation": "New password is not valid.", "next_sessions": "Next Sessions", "no": "No", "no_attachments_found": "This communication has no attachments.", @@ -966,7 +1017,7 @@ "no_duplicate_facility": "You should not create duplicate facilities", "no_facilities": "No Facilities found", "no_files_found": "No {{type}} files found", - "no_home_facility": "No home facility assigned", + "no_home_facility": "No home facility", "no_image_found": "No image found", "no_investigation": "No investigation Reports found", "no_investigation_suggestions": "No Investigation Suggestions", @@ -977,6 +1028,7 @@ "no_notices_for_you": "No notices for you.", "no_patients_found": "No Patients Found", "no_patients_to_show": "No patients to show.", + "no_permission_to_view_page": "You do not have permissions to view this page", "no_policy_added": "No Insurance Policy Added", "no_policy_found": "No Insurance Policy Found for this Patient", "no_presets": "No Presets", @@ -1032,10 +1084,17 @@ "pain_chart_description": "Mark region and intensity of pain", "passport_number": "Passport Number", "password": "Password", - "password_mismatch": "Password and confirm password must be same.", + "password_length_validation": "Password must be at least 8 characters long", + "password_lowercase_validation": "Password must contain at least one lowercase letter (a-z)", + "password_mismatch": "New password and confirm password must be the same.", + "password_number_validation": "Password must contain at least one number (0-9)", + "password_required": "Password is required", "password_reset_failure": "Password Reset Failed", "password_reset_success": "Password Reset successfully", "password_sent": "Password Reset Email Sent", + "password_update_error": "Error while updating password. Try again later.", + "password_uppercase_validation": "Password must contain at least one uppercase letter (A-Z)", + "password_validation": "Password must contain at least: 8 characters, 1 uppercase letter (A-Z), 1 lowercase letter (a-z), and 1 number (0-9)", "patient": "Patient", "patient-notes": "Notes", "patient__general-info": "General Info", @@ -1082,19 +1141,31 @@ "permanent_address": "Permanent Address", "permission_denied": "You do not have permission to perform this action", "personal_information": "Personal Information", + "personal_information_note": "View or update user's personal information", + "personal_information_note_self": "View or update your personal information", + "personal_information_note_view": "View user's personal information", "phone": "Phone", "phone_no": "Phone no.", "phone_number": "Phone Number", "phone_number_at_current_facility": "Phone Number of Contact person at current Facility", "pincode": "Pincode", "please_assign_bed_to_patient": "Please assign a bed to this patient", + "please_confirm_password": "Please confirm your new password.", "please_enter_a_reason_for_the_shift": "Please enter a reason for the shift.", + "please_enter_current_password": "Please enter your current password.", + "please_enter_new_password": "Please enter your new password.", + "please_enter_username": "Please enter the username", "please_select_a_facility": "Please select a facility", "please_select_breathlessness_level": "Please select Breathlessness Level", + "please_select_district": "Please select the district", "please_select_facility_type": "Please select Facility Type", + "please_select_gender": "Please select the Gender", + "please_select_localbody": "Please select the local body", "please_select_patient_category": "Please select Patient Category", "please_select_preferred_vehicle_type": "Please select Preferred Vehicle Type", + "please_select_state": "Please select the state", "please_select_status": "Please select Status", + "please_select_user_type": "Please select the User Type", "please_upload_a_csv_file": "Please Upload A CSV file", "policy": "Policy", "policy__insurer": "Insurer", @@ -1138,9 +1209,14 @@ "procedure_suggestions": "Procedure Suggestions", "procedures_select_placeholder": "Select procedures to add details", "process_transcript": "Process Again", + "professional_info": "Professional Information", + "professional_info_note": "View or update user's professional information", + "professional_info_note_self": "View or update your professional information", + "professional_info_note_view": "View user's professional information", "profile": "Profile", "provisional": "Provisional", "qualification": "Qualification", + "qualification_required": "Qualification is required", "raise_consent_request": "Raise a consent request to fetch patient records over ABDM", "ration_card__APL": "APL", "ration_card__BPL": "BPL", @@ -1169,6 +1245,9 @@ "reload": "Reload", "remove": "Remove", "rename": "Rename", + "replace_home_facility": "Replace Home Facility", + "replace_home_facility_confirm": "Are you sure you want to replace", + "replace_home_facility_confirm_as": "as home facility for user", "reply": "Reply", "report": "Report", "req_atleast_one_digit": "Require at least one digit", @@ -1187,6 +1266,7 @@ "resend_otp": "Resend OTP", "reset": "Reset", "reset_password": "Reset Password", + "reset_password_note_self": "Enter your current password, then create and confirm your new password", "resource": "Resource", "resource_approving_facility": "Resource approving facility", "resource_origin_facility": "Origin Facility", @@ -1207,6 +1287,7 @@ "review_missed": "Review Missed", "revoked_on": "Revoked On", "right": "Right", + "role": "Role", "route": "Route", "routine": "Routine", "sample_collection_date": "Sample Collection Date", @@ -1263,6 +1344,7 @@ "session_expired": "Session Expired", "session_expired_msg": "It appears that your session has expired. This could be due to inactivity. Please login again to continue.", "set_average_weekly_working_hours_for": "Set Average weekly working hours for", + "set_home_facility": "Set as home facility", "set_your_local_language": "Set your local language", "settings_and_filters": "Settings and Filters", "severity_of_breathlessness": "Severity of Breathlessness", @@ -1284,6 +1366,8 @@ "show_patient_presets": "Show Patient Presets", "show_unread_notifications": "Show Unread", "sign_out": "Sign Out", + "skill_add_error": "Error while adding skill", + "skill_added_successfully": "Skill added successfully", "skills": "Skills", "social_profile": "Social Profile", "socioeconomic_status": "Socioeconomic status", @@ -1363,6 +1447,19 @@ "unlink_asset_bed_and_presets": "Delete linked presets and unlink bed", "unlink_asset_bed_caution": "This action will also delete all presets that are associated to this camera and bed.", "unlink_camera_and_bed": "Unlink this bed from this camera", + "unlink_facility": "Unlink Facility", + "unlink_facility_access": "The user will lose access to the facility", + "unlink_facility_confirm": "Are you sure you want to unlink the facility", + "unlink_facility_error": "Error while unlinking facility. Try again later.", + "unlink_facility_success": "Facility unlinked successfully", + "unlink_home_facility_error": "Error while unlinking home facility. Try again later.", + "unlink_home_facility_success": "Home Facility cleared successfully", + "unlink_skill": "Unlink Skill", + "unlink_skill_access": "The user will not have the skill associated anymore.", + "unlink_skill_confirm": "Are you sure you want to unlink the skill", + "unlink_skill_error": "Error while unlinking skill. Try again later.", + "unlink_skill_success": "Skill unlinked successfully", + "unlink_this_facility": "Unlink Facility", "unsubscribe": "Unsubscribe", "unsubscribe_failed": "Unsubscribe failed.", "unsubscribed_successfully": "Unsubscribed Successfully.", @@ -1397,9 +1494,22 @@ "upload_report": "Upload Report", "uploading": "Uploading", "use_existing_abha_address": "Use Existing ABHA Address", - "user_deleted_successfuly": "User Deleted Successfuly", + "user_add_error": "Error while adding User", + "user_added_successfully": "User added successfully", + "user_delete_error": "Error while deleting User", + "user_deleted_successfully": "User Deleted Successfully", + "user_details": "User Details", + "user_details_update_error": "Error while updating user details", + "user_details_update_success": "User details updated successfully", "user_management": "User Management", + "user_qualifications": "Qualifications", + "user_qualifications_note": "Enter appropriate qualifications for this user", + "user_type": "User Type", "username": "Username", + "username_already_exists": "This username already exists", + "username_available": "Username is available", + "username_not_available": "Username is not available", + "username_userdetails_not_found": "Unable to fetch details as username or user details not found", "users": "Users", "vacant": "Vacant", "vaccinated": "Vaccinated", @@ -1433,6 +1543,8 @@ "view_patients": "View Patients", "view_update_patient_files": "View/Update patient files", "view_updates": "View Updates", + "view_user": "View User", + "view_user_profile": "View Profile", "view_users": "View Users", "village": "Village", "virtual_nursing_assistant": "Virtual Nursing Assistant", @@ -1445,10 +1557,12 @@ "volunteer_unassigned": "Volunteer unassigned successfuly", "ward": "Ward", "warranty_amc_expiry": "Warranty / AMC Expiry", + "weekly_working_hours_error": "Average weekly working hours must be a number between 0 and 168", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", "whatsapp_number": "Whatsapp Number", "why_the_asset_is_not_working": "Why the asset is not working?", "width": "Width ({{unit}})", + "with": "with", "working_status": "Working Status", "year_of_birth": "Year of Birth", "years": "years", diff --git a/src/Routers/routes/UserRoutes.tsx b/src/Routers/routes/UserRoutes.tsx index ff7212a02e2..cc668e2fee6 100644 --- a/src/Routers/routes/UserRoutes.tsx +++ b/src/Routers/routes/UserRoutes.tsx @@ -1,5 +1,6 @@ import ManageUsers from "@/components/Users/ManageUsers"; -import { UserAdd } from "@/components/Users/UserAdd"; +import UserAdd from "@/components/Users/UserAdd"; +import UserHome from "@/components/Users/UserHome"; import UserProfile from "@/components/Users/UserProfile"; import { AppRoutes } from "@/Routers/AppRouter"; @@ -7,6 +8,12 @@ import { AppRoutes } from "@/Routers/AppRouter"; const UserRoutes: AppRoutes = { "/users": () => , "/users/add": () => , + "/users/:username": ({ username }) => ( + + ), + "/users/:username/:tab": ({ username, tab }) => ( + + ), "/user/profile": () => , }; diff --git a/src/Utils/permissions.ts b/src/Utils/permissions.ts index 35f12715eff..41587d2dcfd 100644 --- a/src/Utils/permissions.ts +++ b/src/Utils/permissions.ts @@ -2,29 +2,58 @@ import { UserModel } from "@/components/Users/models"; import { USER_TYPES, UserRole } from "@/common/constants"; +const checkIfStateOrDistrictAdminInSameLocation = ( + authUser: UserModel, + targetUser: UserModel, +) => { + const hasLocation = Boolean( + targetUser.state_object || targetUser.district_object, + ); + + const isStateAdminOfSameState = + authUser.user_type === "StateAdmin" && + targetUser.state_object?.id === authUser.state; + + const isDistrictAdminOfSameDistrict = + authUser.user_type === "DistrictAdmin" && + targetUser.district_object?.id === authUser.district; + + return ( + hasLocation && (isStateAdminOfSameState || isDistrictAdminOfSameDistrict) + ); +}; + export const showUserDelete = (authUser: UserModel, targetUser: UserModel) => { // Auth user should be higher in hierarchy than target user + // User can't delete their own account if ( USER_TYPES.indexOf(authUser.user_type) <= - USER_TYPES.indexOf(targetUser.user_type) + USER_TYPES.indexOf(targetUser.user_type) || + authUser.username === targetUser.username ) return false; - if ( - authUser.user_type === "StateAdmin" && - targetUser.state_object?.id === authUser.state - ) - return true; - - if ( - authUser.user_type === "DistrictAdmin" && - targetUser.district_object?.id === authUser.district - ) - return true; + return checkIfStateOrDistrictAdminInSameLocation(authUser, targetUser); +}; - return false; +export const showUserPasswordReset = ( + authUser: UserModel, + targetUser: UserModel, +) => { + return authUser.username === targetUser.username; }; +export const showAvatarEdit = (authUser: UserModel, targetUser: UserModel) => { + return authUser.username === targetUser.username || authUser.is_superuser; +}; +export const editUserPermissions = ( + authUser: UserModel, + targetUser: UserModel, +) => { + if (authUser.username === targetUser.username || authUser.is_superuser) + return true; + return checkIfStateOrDistrictAdminInSameLocation(authUser, targetUser); +}; export const CameraFeedPermittedUserTypes: UserRole[] = [ "DistrictAdmin", "StateAdmin", diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index a7e6a0a78ec..5588e048bd2 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -217,7 +217,9 @@ export const formatCurrency = (price: number) => }); export const isUserOnline = (user: { last_login: DateLike }) => { - return dayjs().subtract(5, "minutes").isBefore(user.last_login); + return user.last_login + ? dayjs().subtract(5, "minutes").isBefore(user.last_login) + : false; }; export interface CountryData { diff --git a/src/common/validation.tsx b/src/common/validation.tsx index 99b4342fbf6..f091303a452 100644 --- a/src/common/validation.tsx +++ b/src/common/validation.tsx @@ -57,6 +57,11 @@ export const validatePincode = (pincode: string) => { return pattern.test(pincode); }; +export const validateNumber = (number: string) => { + const pattern = /^[0-9]+$/; + return pattern.test(number); +}; + export const checkIfValidIP = (str: string) => { // Regular expression to check if string is a IP address const regexExp = diff --git a/src/components/Auth/ResetPassword.tsx b/src/components/Auth/ResetPassword.tsx index 11b541e276c..f993b621062 100644 --- a/src/components/Auth/ResetPassword.tsx +++ b/src/components/Auth/ResetPassword.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Cancel, Submit } from "@/components/Common/ButtonV2"; import TextFormField from "@/components/Form/FormFields/TextFormField"; -import { validateRule } from "@/components/Users/UserAdd"; +import { validateRule } from "@/components/Users/UserAddEditForm"; import { LocalStorageKeys } from "@/common/constants"; import { validatePassword } from "@/common/validation"; @@ -134,22 +134,22 @@ const ResetPassword = (props: ResetPasswordProps) => {
{validateRule( form.password?.length >= 8, - "Password should be atleast 8 characters long", + t("password_length_validation"), !form.password, )} {validateRule( form.password !== form.password.toUpperCase(), - "Password should contain at least 1 lowercase letter", + t("password_lowercase_validation"), !form.password, )} {validateRule( form.password !== form.password.toLowerCase(), - "Password should contain at least 1 uppercase letter", + t("password_uppercase_validation"), !form.password, )} {validateRule( /\d/.test(form.password), - "Password should contain at least 1 number", + t("password_number_validation"), !form.password, )}
@@ -168,7 +168,7 @@ const ResetPassword = (props: ResetPasswordProps) => { form.password.length > 0 && validateRule( form.confirm === form.password, - "Confirm password should match the entered password", + t("password_mismatch"), !form.password && form.password.length > 0, )}
diff --git a/src/components/Common/ConfirmDialog.tsx b/src/components/Common/ConfirmDialog.tsx index 0183fc1d651..5fff5d9f8fb 100644 --- a/src/components/Common/ConfirmDialog.tsx +++ b/src/components/Common/ConfirmDialog.tsx @@ -13,6 +13,7 @@ type ConfirmDialogProps = { onConfirm: () => void; children?: React.ReactNode; cancelLabel?: string; + name?: string; }; const ConfirmDialog = ({ @@ -22,6 +23,7 @@ const ConfirmDialog = ({ onConfirm, cancelLabel, children, + name, ...props }: ConfirmDialogProps) => { return ( @@ -29,7 +31,12 @@ const ConfirmDialog = ({ {children}
- + {action}
diff --git a/src/components/Common/FacilitySelect.tsx b/src/components/Common/FacilitySelect.tsx index e4397cbcb9c..4c0a6629bec 100644 --- a/src/components/Common/FacilitySelect.tsx +++ b/src/components/Common/FacilitySelect.tsx @@ -27,6 +27,7 @@ interface FacilitySelectProps { allowNone?: boolean; placeholder?: string; filter?: (facilities: FacilityModel) => boolean; + id?: string; } export const FacilitySelect = (props: FacilitySelectProps) => { @@ -50,6 +51,7 @@ export const FacilitySelect = (props: FacilitySelectProps) => { errors = "", placeholder, filter, + id, } = props; const facilitySearch = useCallback( @@ -88,6 +90,7 @@ export const FacilitySelect = (props: FacilitySelectProps) => { return ( { ); return ( -
+
-
- -
); }; diff --git a/src/components/Common/Page.tsx b/src/components/Common/Page.tsx index 558d1432182..874bfce4ad6 100644 --- a/src/components/Common/Page.tsx +++ b/src/components/Common/Page.tsx @@ -17,6 +17,7 @@ interface PageProps extends PageTitleProps { * @default false **/ collapseSidebar?: boolean; + hideTitleOnPage?: boolean; } export default function Page(props: PageProps) { @@ -51,6 +52,7 @@ export default function Page(props: PageProps) { focusOnLoad={props.focusOnLoad} onBackClick={props.onBackClick} isInsidePage={true} + hideTitleOnPage={props.hideTitleOnPage} /> {props.options}
diff --git a/src/components/Common/PageTitle.tsx b/src/components/Common/PageTitle.tsx index ecdadec2d62..b7af8f0eaa8 100644 --- a/src/components/Common/PageTitle.tsx +++ b/src/components/Common/PageTitle.tsx @@ -19,6 +19,7 @@ export interface PageTitleProps { // New props for Breadcrumbs hideBack?: boolean; backUrl?: string; + hideTitleOnPage?: boolean; onBackClick?: () => boolean | void; } @@ -35,6 +36,7 @@ export default function PageTitle({ hideBack = false, backUrl, onBackClick, + hideTitleOnPage, }: PageTitleProps) { const divRef = useRef(); @@ -70,7 +72,9 @@ export default function PageTitle({ )} >
-

{title}

+ {!hideTitleOnPage && ( +

{title}

+ )}
{componentRight}
diff --git a/src/components/Common/SkillSelect.tsx b/src/components/Common/SkillSelect.tsx index 74ddbba5d83..eff16d1380e 100644 --- a/src/components/Common/SkillSelect.tsx +++ b/src/components/Common/SkillSelect.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import AutoCompleteAsync from "@/components/Form/AutoCompleteAsync"; -import { SkillModel, SkillObjectModel } from "@/components/Users/models"; +import { SkillModel } from "@/components/Users/models"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; @@ -15,8 +15,8 @@ interface SkillSelectProps { multiple?: boolean; showNOptions?: number; disabled?: boolean; - selected: SkillObjectModel | SkillObjectModel[] | null; - setSelected: (selected: SkillObjectModel) => void; + selected: SkillModel | null; + setSelected: (selected: SkillModel | null) => void; userSkills?: SkillModel[]; } diff --git a/src/components/Common/Tabs.tsx b/src/components/Common/Tabs.tsx index 9a549cfabcf..9c375db618e 100644 --- a/src/components/Common/Tabs.tsx +++ b/src/components/Common/Tabs.tsx @@ -8,7 +8,7 @@ export default function Tabs(props: { className?: string; currentTab: string | number; onTabChange: (value: string | number) => void; - tabs: { text: ReactNode; value: string | number }[]; + tabs: { text: ReactNode; value: string | number; id?: string }[]; }) { const { className, currentTab, onTabChange, tabs } = props; const ref = useRef(null); @@ -60,6 +60,7 @@ export default function Tabs(props: { {tabs.map((tab, i) => ( +
+ )} + + + ); + }; + + return ( + <> + {modalProps.toggle && ( + + )} +
+
+ + linkFacility(userData.username, facility)} + disabled={!authorizeForHomeFacility} + tooltip={ + !authorizeForHomeFacility + ? t("contact_your_admin_to_add_facilities") + : undefined + } + > + {t("add_facility")} + +
+ + {homeFacility && ( +
+

{t("home_facility")}

+
+ {renderHomeFacilityButton(homeFacility)} +
+
+ )} + {userFacilities && userFacilities.length > 0 && ( +
+

{t("linked_facilities")}

+ +
+ {userFacilities.map((facility: FacilityModel) => { + if (homeFacility?.id === facility.id) { + return null; + } + return renderFacilityButtons(facility); + })} +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/LinkedFacilitiesTab.tsx b/src/components/Users/LinkedFacilitiesTab.tsx new file mode 100644 index 00000000000..3f255dacbe1 --- /dev/null +++ b/src/components/Users/LinkedFacilitiesTab.tsx @@ -0,0 +1,43 @@ +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import UserColumns from "@/components/Common/UserColumns"; +import LinkedFacilities from "@/components/Users/LinkedFacilities"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; + +type Props = { + userData: UserModel; + username: string; + refetchUserData?: () => void; +}; + +export default function LinkedFacilitiesTab(props: Props) { + const { userData } = props; + const { t } = useTranslation(); + const authUser = useAuthUser(); + const editPermissions = editUserPermissions(authUser, userData); + + if (!userData) { + Notification.Error({ msg: t("username_userdetails_not_found") }); + return <>; + } else if (!editPermissions) { + Notification.Error({ msg: t("no_permission_to_view_page") }); + navigate("/users"); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Users/LinkedSkills.tsx b/src/components/Users/LinkedSkills.tsx new file mode 100644 index 00000000000..8baa5a1b06b --- /dev/null +++ b/src/components/Users/LinkedSkills.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import ButtonV2 from "@/components/Common/ButtonV2"; +import { SkillSelect } from "@/components/Common/SkillSelect"; + +import { useIsAuthorized } from "@/hooks/useIsAuthorized"; + +import AuthorizeFor from "@/Utils/AuthorizeFor"; +import * as Notification from "@/Utils/Notifications"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import useQuery from "@/Utils/request/useQuery"; + +import UnlinkSkillDialog from "./UnlinkSkillDialog"; +import { SkillModel } from "./models"; + +const initModalProps: { + selectedSkill: SkillModel | null; + toggle: boolean; +} = { + toggle: false, + selectedSkill: null, +}; + +export default function LinkedSkills({ username }: { username: string }) { + const [modalProps, setModalProps] = useState(initModalProps); + const [selectedSkill, setSelectedSkill] = useState(null); + const { t } = useTranslation(); + + const { data: skills, refetch: refetchUserSkills } = useQuery( + routes.userListSkill, + { + pathParams: { username }, + }, + ); + + const handleOnClick = (selectedSkill: SkillModel) => { + setModalProps({ + selectedSkill, + toggle: true, + }); + }; + + const handleModalCancel = () => { + setModalProps(initModalProps); + }; + + const handleModalOk = () => { + removeSkill(username, modalProps.selectedSkill?.id.toString() ?? ""); + setModalProps(initModalProps); + }; + + const authorizeForAddSkill = useIsAuthorized( + AuthorizeFor(["DistrictAdmin", "StateAdmin"]), + ); + + const addSkill = async (username: string, skill: SkillModel | null) => { + if (!skill) return; + const { res } = await request(routes.addUserSkill, { + pathParams: { username }, + body: { skill: skill.id }, + }); + if (res?.ok) { + Notification.Success({ + msg: t("skill_added_successfully"), + }); + } else { + Notification.Error({ + msg: t("skill_add_error"), + }); + } + setSelectedSkill(null); + setModalProps(initModalProps); + await refetchUserSkills(); + }; + + const removeSkill = async (username: string, skillId: string) => { + const { res } = await request(routes.deleteUserSkill, { + pathParams: { username, id: skillId }, + }); + if (res?.status !== 204) { + Notification.Error({ + msg: t("unlink_skill_error"), + }); + } else { + Notification.Success({ + msg: t("unlink_skill_success"), + }); + } + await refetchUserSkills(); + }; + + const renderSkillButtons = (skill: SkillModel) => { + return ( +
+
+
{skill.skill_object.name}
+ {authorizeForAddSkill && ( +
+ +
+ )} +
+
+ ); + }; + + return ( + <> + {modalProps.toggle && ( + + )} +
+
+ + addSkill(username, selectedSkill)} + className="mt-1 rounded-lg px-6 py-[11px] text-base" + tooltip={ + !authorizeForAddSkill + ? t("contact_your_admin_to_add_skills") + : undefined + } + > + {t("add_skill")} + +
+ {skills && skills?.count > 0 && ( +
+

{t("linked_skills")}

+ +
+ {skills?.results.map((skill: SkillModel) => { + return renderSkillButtons(skill); + })} +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/LinkedSkillsTab.tsx b/src/components/Users/LinkedSkillsTab.tsx new file mode 100644 index 00000000000..3910f0a9e52 --- /dev/null +++ b/src/components/Users/LinkedSkillsTab.tsx @@ -0,0 +1,44 @@ +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import UserColumns from "@/components/Common/UserColumns"; +import LinkedSkills from "@/components/Users/LinkedSkills"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; + +type Props = { + userData: UserModel; + username: string; +}; + +export default function LinkedSkillsTab(props: Props) { + const { userData, username } = props; + const { t } = useTranslation(); + const authUser = useAuthUser(); + const editPermissions = editUserPermissions(authUser, userData); + + if (!userData || !username) { + Notification.Error({ msg: t("username_userdetails_not_found") }); + return <>; + } else if (!editPermissions) { + Notification.Error({ msg: t("no_permission_to_view_page") }); + navigate("/users"); + } + + return ( + <> +
+ +
+ + ); +} diff --git a/src/components/Users/ManageUsers.tsx b/src/components/Users/ManageUsers.tsx index 1290a074547..5b441e76150 100644 --- a/src/components/Users/ManageUsers.tsx +++ b/src/components/Users/ManageUsers.tsx @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { navigate } from "raviger"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,48 +5,31 @@ import { useTranslation } from "react-i18next"; import CountBlock from "@/CAREUI/display/Count"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import SlideOverCustom from "@/CAREUI/interactive/SlideOver"; -import { Avatar } from "@/components/Common/Avatar"; -import ButtonV2, { Submit } from "@/components/Common/ButtonV2"; +import ButtonV2 from "@/components/Common/ButtonV2"; import CircularProgress from "@/components/Common/CircularProgress"; import { FacilitySelect } from "@/components/Common/FacilitySelect"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import Pagination from "@/components/Common/Pagination"; -import UserDetails from "@/components/Common/UserDetails"; -import UserDetailComponent from "@/components/Common/UserDetailsComponet"; import { FacilityModel } from "@/components/Facility/models"; -import TextFormField from "@/components/Form/FormFields/TextFormField"; -import SearchInput from "@/components/Form/SearchInput"; -import ConfirmHomeFacilityUpdateDialog from "@/components/Users/ConfirmHomeFacilityUpdateDialog"; -import SkillsSlideOver from "@/components/Users/SkillsSlideOver"; import UnlinkFacilityDialog from "@/components/Users/UnlinkFacilityDialog"; -import UserDeleteDialog from "@/components/Users/UserDeleteDialog"; import UserFilter from "@/components/Users/UserFilter"; +import UserListView from "@/components/Users/UserListAndCard"; import useAuthUser from "@/hooks/useAuthUser"; import useFilters from "@/hooks/useFilters"; -import useWindowDimensions from "@/hooks/useWindowDimensions"; import { USER_TYPES } from "@/common/constants"; import * as Notification from "@/Utils/Notifications"; -import { showUserDelete } from "@/Utils/permissions"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import useQuery from "@/Utils/request/useQuery"; -import { - classNames, - formatDisplayName, - formatName, - isUserOnline, - relativeTime, -} from "@/Utils/utils"; +import { classNames } from "@/Utils/utils"; export default function ManageUsers() { const { t } = useTranslation(); - const { width } = useWindowDimensions(); const { qParams, updateQuery, @@ -59,55 +41,39 @@ export default function ManageUsers() { limit: 18, cacheBlacklist: ["username"], }); - let manageUsers: any = null; - const [expandSkillList, setExpandSkillList] = useState(false); - const [expandFacilityList, setExpandFacilityList] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); - const [expandWorkingHours, setExpandWorkingHours] = useState(false); + let manageUsers: JSX.Element = <>; const authUser = useAuthUser(); - const [weeklyHours, setWeeklyHours] = useState("0"); const userIndex = USER_TYPES.indexOf(authUser.user_type); const userTypes = authUser.is_superuser ? [...USER_TYPES] : USER_TYPES.slice(0, userIndex + 1); - - const [userData, setUserData] = useState<{ - show: boolean; - username: string; - name: string; - }>({ show: false, username: "", name: "" }); - - const [weeklyHoursError, setWeeklyHoursError] = useState(""); - - const extremeSmallScreenBreakpoint = 320; - const isExtremeSmallScreen = width <= extremeSmallScreenBreakpoint; + const [activeTab, setActiveTab] = useState(0); const { data: homeFacilityData } = useQuery(routes.getAnyFacility, { pathParams: { id: qParams.home_facility }, prefetch: !!qParams.home_facility && qParams.home_facility !== "NONE", }); - const { - data: userListData, - loading: userListLoading, - refetch: refetchUserList, - } = useQuery(routes.userList, { - query: { - limit: resultsPerPage.toString(), - offset: ( - (qParams.page ? qParams.page - 1 : 0) * resultsPerPage - ).toString(), - username: qParams.username, - first_name: qParams.first_name, - last_name: qParams.last_name, - phone_number: qParams.phone_number, - alt_phone_number: qParams.alt_phone_number, - user_type: qParams.user_type, - district_id: qParams.district, - home_facility: qParams.home_facility, - last_active_days: qParams.last_active_days, + const { data: userListData, loading: userListLoading } = useQuery( + routes.userList, + { + query: { + limit: resultsPerPage.toString(), + offset: ( + (qParams.page ? qParams.page - 1 : 0) * resultsPerPage + ).toString(), + username: qParams.username, + first_name: qParams.first_name, + last_name: qParams.last_name, + phone_number: qParams.phone_number, + alt_phone_number: qParams.alt_phone_number, + user_type: qParams.user_type, + district_id: qParams.district, + home_facility: qParams.home_facility, + last_active_days: qParams.last_active_days, + }, }, - }); + ); useEffect(() => { if (!qParams.state && qParams.district) { @@ -137,419 +103,26 @@ export default function ManageUsers() { ); - const handleCancel = () => { - setUserData({ show: false, username: "", name: "" }); - }; - - const handleWorkingHourSubmit = async () => { - const username = selectedUser; - if (!username || !weeklyHours || +weeklyHours < 0 || +weeklyHours > 168) { - setWeeklyHoursError("Value should be between 0 and 168"); - return; - } - const { res, data, error } = await request(routes.partialUpdateUser, { - pathParams: { username }, - body: { weekly_working_hours: weeklyHours }, - }); - if (res && res.status === 200 && data) { - Notification.Success({ - msg: "Working hours updated successfully", - }); - setExpandWorkingHours(false); - setSelectedUser(null); - } else { - Notification.Error({ - msg: "Error while updating working hours: " + (error || ""), - }); - } - setWeeklyHours("0"); - setWeeklyHoursError(""); - await refetchUserList(); - }; - - const handleSubmit = async () => { - const { res, error } = await request(routes.deleteUser, { - pathParams: { username: userData.username }, - }); - if (res?.status === 204) { - Notification.Success({ - msg: "User deleted successfully", - }); - } else { - Notification.Error({ - msg: "Error while deleting User: " + (error || ""), - }); - } - - setUserData({ show: false, username: "", name: "" }); - await refetchUserList(); - }; - - const handleDelete = (user: any) => { - setUserData({ - show: true, - username: user.username, - name: formatName(user), - }); - }; - - let userList: any[] = []; - userListData?.results && - userListData.results.length && - (userList = userListData.results.map((user: any, idx) => { - const cur_online = isUserOnline(user); - return ( -
-
-
-
-
- -
-
- {user.username && ( -
- {user.username} -
- )} -
- {user.last_login && cur_online ? ( - - {" "} - {" "} - Currently Online - - ) : ( - <> - - Last - Online:{" "} - - - - {user.last_login - ? relativeTime(user.last_login) - : "Never"} - - - )} -
-
-
-
- {formatName(user)} -
- - {user.last_login && cur_online ? ( -
- ) : null} - {showUserDelete(authUser, user) && ( -
handleDelete(user)} - > - -
- )} -
- -
- {user.user_type && ( - - )} - {user.district_object && ( - - )} - {user.user_type === "Doctor" && ( - <> -
- - {user.qualification ? ( - - {user.qualification} - - ) : ( - - {t("unknown")} - - )} - -
-
- - {user.doctor_experience_commenced_on ? ( - - {dayjs().diff( - user.doctor_experience_commenced_on, - "years", - false, - )}{" "} - years - - ) : ( - - {t("unknown")} - - )} - -
-
- - {user.doctor_medical_council_registration ? ( - - {user.doctor_medical_council_registration} - - ) : ( - - {t("unknown")} - - )} - -
- - )} -
- {user.local_body_object && ( - -
- {user.local_body_object.name} -
-
- )} - -
- {user.user_type === "Nurse" && ( -
- - {user.qualification ? ( - - {user.qualification} - - ) : ( - - {t("unknown")} - - )} - -
- )} - {user.created_by && ( -
- -
-
- {user.created_by} -
-
-
-
- )} - {user.username && ( -
- - - {user.home_facility_object?.name || - "No Home Facility"} - - -
- )} -
-
- - {user.weekly_working_hours ? ( - - {user.weekly_working_hours} hours - - ) : ( - - - )} - -
-
- {user.username && ( -
-
- { - setExpandFacilityList(!expandFacilityList); - setSelectedUser(user); - }} - > - -

{t("linked_facilities")}

-
- { - setExpandSkillList(true); - setSelectedUser(user.username); - }} - > - -

{t("linked_skills")}

-
-
- {["DistrictAdmin", "StateAdmin"].includes( - authUser.user_type, - ) && ( -
- { - setExpandWorkingHours(true); - setSelectedUser(user.username); - setWeeklyHours(user.weekly_working_hours); - }} - > - -

- Set Average weekly working hours -

-
-
- )} -
- )} -
-
-
- ); - })); - if (userListLoading || districtDataLoading || !userListData?.results) { - manageUsers = ; - } else if (userListData?.results.length) { - manageUsers = ( -
-
- {userList} -
- -
- ); - } else if (userListData?.results && userListData?.results.length === 0) { - manageUsers = ( -
-
-
- No Users Found -
-
-
- ); + return ; } + manageUsers = ( +
+ updateQuery({ username })} + searchValue={qParams.username} + activeTab={activeTab} + onTabChange={setActiveTab} + /> + +
+ ); + return ( - {expandSkillList && ( - - )} - - - - { - setExpandWorkingHours(state); - setWeeklyHours("0"); - setWeeklyHoursError(""); - }} - slideFrom="right" - title={t("average_weekly_working_hours")} - dialogClass="md:w-[400px]" - > -
-
- {t("set_average_weekly_working_hours_for")} {selectedUser} -
- { - setWeeklyHours(e.value); - }} - error={weeklyHoursError} - required - label="" - type="number" - min={0} - max={168} - /> -
- -
-
-
- -
+
-
- updateQuery({ [e.name]: e.value })} - value={qParams.username} - placeholder={t("search_by_username")} - /> -
advancedFilter.setShow(true)} @@ -617,13 +181,6 @@ export default function ManageUsers() {
{manageUsers}
- {userData.show && ( - - )} ); } @@ -645,26 +202,6 @@ export function UserFacilities(props: { user: any }) { isHomeFacility: boolean; }>({ show: false, userName: "", facility: undefined, isHomeFacility: false }); const authUser = useAuthUser(); - - const [replaceHomeFacility, setReplaceHomeFacility] = useState<{ - show: boolean; - userName: string; - previousFacility?: FacilityModel; - newFacility?: FacilityModel; - }>({ - show: false, - userName: "", - previousFacility: undefined, - newFacility: undefined, - }); - const hideReplaceHomeFacilityModal = () => { - setReplaceHomeFacility({ - show: false, - previousFacility: undefined, - userName: "", - newFacility: undefined, - }); - }; const hideUnlinkFacilityModal = () => { setUnlinkFacilityData({ show: false, @@ -890,13 +427,6 @@ export function UserFacilities(props: { user: any }) { onClick={() => { if (user?.home_facility_object) { // has previous home facility - setReplaceHomeFacility({ - show: true, - userName: username, - previousFacility: - user?.home_facility_object, - newFacility: facility, - }); } else { // no previous home facility updateHomeFacility(username, facility); @@ -962,28 +492,6 @@ export function UserFacilities(props: { user: any }) { )}
)} - {replaceHomeFacility.show && ( - { - updateHomeFacility( - replaceHomeFacility.userName, - replaceHomeFacility.newFacility, - ); - setReplaceHomeFacility({ - show: false, - previousFacility: undefined, - userName: "", - newFacility: undefined, - }); - }} - /> - )}
); } diff --git a/src/components/Users/SkillsSlideOver.tsx b/src/components/Users/SkillsSlideOver.tsx deleted file mode 100644 index 57e1c8359bb..00000000000 --- a/src/components/Users/SkillsSlideOver.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import SlideOverCustom from "@/CAREUI/interactive/SlideOver"; - -import ButtonV2 from "@/components/Common/ButtonV2"; -import CircularProgress from "@/components/Common/CircularProgress"; -import { SkillSelect } from "@/components/Common/SkillSelect"; -import { - AddSkillsPlaceholder, - SkillsArray, -} from "@/components/Users/SkillsSlideOverComponents"; -import UnlinkSkillDialog from "@/components/Users/UnlinkSkillDialog"; -import { SkillModel, SkillObjectModel } from "@/components/Users/models"; - -import { useIsAuthorized } from "@/hooks/useIsAuthorized"; - -import AuthorizeFor from "@/Utils/AuthorizeFor"; -import * as Notification from "@/Utils/Notifications"; -import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; -import useQuery from "@/Utils/request/useQuery"; - -interface IProps { - username: string; - show: boolean; - setShow: (show: boolean) => void; -} - -export default ({ show, setShow, username }: IProps) => { - /* added const {t} hook here and relevant text to Common.json to avoid eslint error */ - const { t } = useTranslation(); - const [selectedSkill, setSelectedSkill] = useState( - null, - ); - const [isLoading, setIsLoading] = useState(false); - const [deleteSkill, setDeleteSkill] = useState(null); - - const { - data: skills, - loading: skillsLoading, - refetch: refetchUserSkills, - } = useQuery(routes.userListSkill, { - pathParams: { username }, - }); - - const addSkill = useCallback( - async (username: string, skill: SkillObjectModel | null) => { - if (!skill) return; - setIsLoading(true); - const { res } = await request(routes.addUserSkill, { - pathParams: { username }, - body: { skill: skill.id }, - }); - - if (!res?.ok) { - Notification.Error({ - msg: "Error while adding skill", - }); - } else { - Notification.Success({ - msg: "Skill added successfully", - }); - } - setSelectedSkill(null); - setIsLoading(false); - await refetchUserSkills(); - }, - [refetchUserSkills], - ); - - const removeSkill = useCallback( - async (username: string, skillId: string) => { - const { res } = await request(routes.deleteUserSkill, { - pathParams: { username, id: skillId }, - }); - if (res?.status !== 204) { - Notification.Error({ - msg: "Error while unlinking skill", - }); - } else { - Notification.Success({ - msg: "Skill unlinked successfully", - }); - } - setDeleteSkill(null); - await refetchUserSkills(); - }, - [refetchUserSkills], - ); - - const authorizeForAddSkill = useIsAuthorized( - AuthorizeFor(["DistrictAdmin", "StateAdmin"]), - ); - - const hasSkills = skills?.results?.length || 0 > 0; - - return ( -
- {deleteSkill && ( - setDeleteSkill(null)} - onSubmit={() => removeSkill(username, deleteSkill.id)} - /> - )} - { - !deleteSkill && setShow(openState); - }} - slideFrom="right" - title="Skills" - dialogClass="md:w-[400px]" - > -
-
- {(!isLoading || !skillsLoading) && ( -
- - addSkill(username, selectedSkill)} - className="mt-1 h-[45px] w-[74px] text-base" - > - {t("add")} - - {!authorizeForAddSkill && ( - - {t("contact_your_admin_to_add_skills")} - - )} -
- )} - {isLoading || skillsLoading ? ( -
- -
- ) : ( -
- {hasSkills ? ( - - ) : ( - - )} -
- )} -
-
-
-
- ); -}; diff --git a/src/components/Users/SkillsSlideOverComponents.tsx b/src/components/Users/SkillsSlideOverComponents.tsx deleted file mode 100644 index bb91d4cbfd3..00000000000 --- a/src/components/Users/SkillsSlideOverComponents.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Fragment } from "react"; -import { useTranslation } from "react-i18next"; - -import CareIcon from "@/CAREUI/icons/CareIcon"; - -import ButtonV2 from "@/components/Common/ButtonV2"; -import { SkillModel } from "@/components/Users/models"; - -import { classNames } from "@/Utils/utils"; - -export const AddSkillsPlaceholder = () => { - const { t } = useTranslation(); - return ( -
-
- Error 404 -
-

- {t("select_skills")} -

-
- ); -}; - -type SkillsArrayProps = { - isLoading: boolean; - skills: SkillModel[]; - authorizeForAddSkill: boolean; - setDeleteSkill: (skill: SkillModel) => void; -}; - -export const SkillsArray = ({ - isLoading, - skills, - authorizeForAddSkill, - setDeleteSkill, -}: SkillsArrayProps) => { - return ( - - {skills.map((skill, i) => ( -
-
-
{skill.skill_object.name}
-
- setDeleteSkill(skill)} - > - - -
-
-
- ))} -
- ); -}; diff --git a/src/components/Users/UnlinkSkillDialog.tsx b/src/components/Users/UnlinkSkillDialog.tsx index c52e72b30ca..6ddfcc6e569 100644 --- a/src/components/Users/UnlinkSkillDialog.tsx +++ b/src/components/Users/UnlinkSkillDialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import ConfirmDialog from "@/components/Common/ConfirmDialog"; @@ -11,6 +12,7 @@ interface Props { export default function UnlinkSkillDialog(props: Props) { const [disabled, setDisabled] = useState(false); + const { t } = useTranslation(); const handleSubmit = () => { props.onSubmit(); @@ -20,19 +22,21 @@ export default function UnlinkSkillDialog(props: Props) { return ( - Are you sure you want to unlink the skill{" "} - {props.skillName} from user{" "} - {props.userName}? the user will not have the skill - associated anymore. - +
+ + {t("unlink_skill_confirm")} {props.skillName}{" "} + {t("from_user")} {props.userName}?{" "} + {t("unlink_skill_access")} + +
} >
); diff --git a/src/components/Users/UserAdd.tsx b/src/components/Users/UserAdd.tsx index bfd38d2cae2..467cf578fae 100644 --- a/src/components/Users/UserAdd.tsx +++ b/src/components/Users/UserAdd.tsx @@ -1,152 +1,15 @@ -import { Link, navigate } from "raviger"; -import { useEffect, useState } from "react"; +import { Link } from "raviger"; import { useTranslation } from "react-i18next"; -import Card from "@/CAREUI/display/Card"; import CareIcon from "@/CAREUI/icons/CareIcon"; -import { Cancel, Submit } from "@/components/Common/ButtonV2"; -import CircularProgress from "@/components/Common/CircularProgress"; -import { FacilitySelect } from "@/components/Common/FacilitySelect"; -import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; -import { FacilityModel } from "@/components/Facility/models"; -import { PhoneNumberValidator } from "@/components/Form/FieldValidators"; -import CheckBoxFormField from "@/components/Form/FormFields/CheckBoxFormField"; -import DateFormField from "@/components/Form/FormFields/DateFormField"; -import { FieldLabel } from "@/components/Form/FormFields/FormField"; -import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; -import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; -import TextFormField from "@/components/Form/FormFields/TextFormField"; -import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; +import UserAddEditForm from "@/components/Users/UserAddEditForm"; +import { newUserFields } from "@/components/Users/UserFormValidations"; -import useAppHistory from "@/hooks/useAppHistory"; -import useAuthUser from "@/hooks/useAuthUser"; - -import { - GENDER_TYPES, - USER_TYPES, - USER_TYPE_OPTIONS, -} from "@/common/constants"; -import { useAbortableEffect } from "@/common/utils"; -import { - validateEmailAddress, - validateName, - validatePassword, - validateUsername, -} from "@/common/validation"; - -import { DraftSection, useAutoSaveReducer } from "@/Utils/AutoSave"; -import * as Notification from "@/Utils/Notifications"; -import dayjs from "@/Utils/dayjs"; -import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; -import useQuery from "@/Utils/request/useQuery"; -import { - classNames, - dateQueryString, - parsePhoneNumber, - scrollTo, -} from "@/Utils/utils"; - -interface UserProps { - userId?: number; -} - -interface StateObj { - id: number; - name: string; -} - -type UserForm = { - user_type: string; - gender: string; - password: string; - c_password: string; - facilities: Array; - home_facility: FacilityModel | null; - username: string; - first_name: string; - last_name: string; - email: string; - phone_number: string; - alt_phone_number: string; - phone_number_is_whatsapp: boolean; - date_of_birth: Date | null; - state: number; - district: number; - local_body: number; - qualification: string | undefined; - doctor_experience_commenced_on: string | undefined; - doctor_medical_council_registration: string | undefined; -}; - -const initForm: UserForm = { - user_type: "", - gender: "", - password: "", - c_password: "", - facilities: [], - home_facility: null, - username: "", - first_name: "", - last_name: "", - email: "", - phone_number: "+91", - alt_phone_number: "+91", - phone_number_is_whatsapp: true, - date_of_birth: null, - state: 0, - district: 0, - local_body: 0, - qualification: undefined, - doctor_experience_commenced_on: undefined, - doctor_medical_council_registration: undefined, -}; - -const STAFF_OR_NURSE_USER = [ - "Staff", - "StaffReadOnly", - "Nurse", - "NurseReadOnly", -]; - -const initError = Object.assign( - {}, - ...Object.keys(initForm).map((k) => ({ [k]: "" })), -); - -const initialState = { - form: { ...initForm }, - errors: { ...initError }, -}; - -const user_create_reducer = (state = initialState, action: any) => { - switch (action.type) { - case "set_form": { - return { - ...state, - form: action.form, - }; - } - case "set_errors": { - return { - ...state, - errors: action.errors, - }; - } - case "set_state": { - if (action.state) return action.state; - return state; - } - default: - return state; - } -}; - -const getDate = (value: any) => - value && dayjs(value).isValid() && dayjs(value).toDate(); +import { classNames } from "@/Utils/utils"; +//Temporary: ABDM plug imports from UserAdd instead of UserAddEditForm export const validateRule = ( condition: boolean, content: JSX.Element | string, @@ -176,465 +39,12 @@ export const validateRule = ( ); }; -export const UserAdd = (props: UserProps) => { +const UserAdd = () => { const { t } = useTranslation(); - const { goBack } = useAppHistory(); - const { userId } = props; - - const [state, dispatch] = useAutoSaveReducer( - user_create_reducer, - initialState, - ); - const [isLoading, setIsLoading] = useState(false); - const [states, setStates] = useState([]); - const [selectedStateId, setSelectedStateId] = useState(0); - const [selectedDistrictId, setSelectedDistrictId] = useState(0); - const [districts, setDistricts] = useState([]); - const [localBodies, setLocalBodies] = useState([]); - const [selectedFacility, setSelectedFacility] = useState([]); - const [usernameInputInFocus, setUsernameInputInFocus] = useState(false); - const [passwordInputInFocus, setPasswordInputInFocus] = useState(false); - const [confirmPasswordInputInFocus, setConfirmPasswordInputInFocus] = - useState(false); - const [usernameInput, setUsernameInput] = useState(""); - - const userExistsEnums = { - idle: 0, - checking: 1, - exists: 2, - available: 3, - }; - - const [usernameExists, setUsernameExists] = useState(0); - - const check_username = async (username: string) => { - setUsernameExists(userExistsEnums.checking); - const { res: usernameCheck } = await request(routes.checkUsername, { - pathParams: { username }, - silent: true, - }); - if (usernameCheck === undefined || usernameCheck.status === 409) - setUsernameExists(userExistsEnums.exists); - else if (usernameCheck.status === 200) - setUsernameExists(userExistsEnums.available); - else - Notification.Error({ - msg: "Some error checking username availabality. Please try again later.", - }); - }; - - useEffect(() => { - setUsernameExists(userExistsEnums.idle); - if (validateUsername(usernameInput)) { - const timeout = setTimeout(() => { - check_username(usernameInput); - }, 500); - return () => clearTimeout(timeout); - } - }, [usernameInput]); - - const authUser = useAuthUser(); - - const userIndex = USER_TYPES.indexOf(authUser.user_type); - const readOnlyUsers = USER_TYPE_OPTIONS.filter((user) => user.readOnly); - - const defaultAllowedUserTypes = USER_TYPE_OPTIONS.slice(0, userIndex + 1); - const userTypes = authUser.is_superuser - ? [...USER_TYPE_OPTIONS] - : authUser.user_type === "StaffReadOnly" - ? readOnlyUsers.slice(0, 1) - : authUser.user_type === "DistrictReadOnlyAdmin" - ? readOnlyUsers.slice(0, 2) - : authUser.user_type === "StateReadOnlyAdmin" - ? readOnlyUsers.slice(0, 3) - : authUser.user_type === "Pharmacist" - ? USER_TYPE_OPTIONS.slice(0, 1) - : // Exception to allow Staff to Create Doctors - defaultAllowedUserTypes; - - // TODO: refactor lines 227 through 248 to be more readable. This is messy. - if (authUser.user_type === "Nurse" || authUser.user_type === "Staff") { - userTypes.push(USER_TYPE_OPTIONS[6]); // Temperorily allows creation of users with elevated permissions due to introduction of new roles. - } - - const headerText = !userId ? "Add User" : "Update User"; - const buttonText = !userId ? "Save User" : "Update Details"; - const showLocalbody = ![ - "Pharmacist", - "Volunteer", - "Doctor", - ...STAFF_OR_NURSE_USER, - ].includes(state.form.user_type); - - const { loading: isDistrictLoading } = useQuery(routes.getDistrictByState, { - prefetch: !!(selectedStateId > 0), - pathParams: { id: selectedStateId.toString() }, - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("DistrictAdmin")) { - setDistricts([authUser.district_object!]); - } else { - setDistricts(result.data); - } - }, - }); - - const { loading: isLocalbodyLoading } = useQuery( - routes.getAllLocalBodyByDistrict, - { - prefetch: !!(selectedDistrictId > 0), - pathParams: { id: selectedDistrictId.toString() }, - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("LocalBodyAdmin")) { - setLocalBodies([authUser.local_body_object!]); - } else { - setLocalBodies(result.data); - } - }, - }, - ); - - const { loading: isStateLoading } = useQuery(routes.statesList, { - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("StateAdmin")) { - setStates([authUser.state_object!]); - } else { - setStates(result.data.results); - } - }, - }); - - const handleDateChange = (e: FieldChangeEvent) => { - if (dayjs(e.value).isValid()) { - const errors = { ...state.errors, [e.name]: "" }; - dispatch({ - type: "set_form", - form: { - ...state.form, - [e.name]: dayjs(e.value).format("YYYY-MM-DD"), - }, - }); - dispatch({ type: "set_errors", errors }); - } - }; - - const handleFieldChange = (event: FieldChangeEvent) => { - const errors = { ...state.errors, [event.name]: "" }; - dispatch({ - type: "set_form", - form: { - ...state.form, - [event.name]: event.value, - }, - }); - dispatch({ type: "set_errors", errors }); - }; - - useAbortableEffect(() => { - if (state.form.phone_number_is_whatsapp) { - handleFieldChange({ - name: "alt_phone_number", - value: state.form.phone_number, - }); - } - }, [state.form.phone_number_is_whatsapp, state.form.phone_number]); - - const setFacility = (selected: FacilityModel | FacilityModel[] | null) => { - const newSelectedFacilities = selected - ? Array.isArray(selected) - ? selected - : [selected] - : []; - setSelectedFacility(newSelectedFacilities as FacilityModel[]); - const form = { ...state.form }; - form.facilities = selected - ? (selected as FacilityModel[]).map((i) => i.id!) - : []; - dispatch({ type: "set_form", form }); - }; - - const validateForm = () => { - const errors = { ...initError }; - let invalidForm = false; - Object.keys(state.form).forEach((field) => { - switch (field) { - case "facilities": - if ( - state.form[field].length === 0 && - STAFF_OR_NURSE_USER.includes(authUser.user_type) && - STAFF_OR_NURSE_USER.includes(state.form.user_type) - ) { - errors[field] = - "Please select atleast one of the facilities you are linked to"; - invalidForm = true; - } - return; - case "user_type": - if (!state.form[field]) { - errors[field] = "Please select the User Type"; - invalidForm = true; - } - return; - case "doctor_experience_commenced_on": - if (state.form.user_type === "Doctor" && !state.form[field]) { - errors[field] = t("field_required"); - invalidForm = true; - } else if ( - state.form.user_type === "Doctor" && - Number(state.form.doctor_experience_commenced_on) > 100 - ) { - errors[field] = "Doctor experience should be less than 100 years"; - invalidForm = true; - } - return; - case "qualification": - if ( - (state.form.user_type === "Doctor" || - state.form.user_type === "Nurse") && - !state.form[field] - ) { - errors[field] = t("field_required"); - invalidForm = true; - } - return; - case "doctor_medical_council_registration": - if (state.form.user_type === "Doctor" && !state.form[field]) { - errors[field] = t("field_required"); - invalidForm = true; - } - return; - case "first_name": - case "last_name": - state.form[field] = state.form[field].trim(); - if (!state.form[field]) { - errors[field] = `${field - .split("_") - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" ")} is required`; - invalidForm = true; - } else if (!validateName(state.form[field])) { - errors[field] = t("min_char_length_error", { min_length: 3 }); - invalidForm = true; - } - return; - case "gender": - if (!state.form[field]) { - errors[field] = "Please select the Gender"; - invalidForm = true; - } - return; - case "username": - if (!state.form[field]) { - errors[field] = "Please enter the username"; - invalidForm = true; - } else if (!validateUsername(state.form[field])) { - errors[field] = - "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -"; - invalidForm = true; - } else if (usernameExists !== userExistsEnums.available) { - errors[field] = "This username already exists"; - invalidForm = true; - } - return; - case "password": - if (!state.form[field]) { - errors[field] = "Please enter the password"; - invalidForm = true; - } else if (!validatePassword(state.form[field])) { - errors.password = - "Password should have 1 lowercase letter, 1 uppercase letter, 1 number, and be at least 8 characters long"; - invalidForm = true; - } - return; - case "c_password": - if (!state.form.password) { - errors.c_password = "Confirm password is required"; - invalidForm = true; - } else if (state.form.password !== state.form.c_password) { - errors.c_password = "Passwords not matching"; - invalidForm = true; - } - return; - case "phone_number": - // eslint-disable-next-line no-case-declarations - const phoneNumber = parsePhoneNumber(state.form[field]); - // eslint-disable-next-line no-case-declarations - let is_valid = false; - if (phoneNumber) { - is_valid = PhoneNumberValidator()(phoneNumber) === undefined; - } - if (!state.form[field] || !is_valid) { - errors[field] = "Please enter valid phone number"; - invalidForm = true; - } - return; - - case "alt_phone_number": - // eslint-disable-next-line no-case-declarations - let alt_is_valid = false; - if (state.form[field] && state.form[field] !== "+91") { - const altPhoneNumber = parsePhoneNumber(state.form[field]); - if (altPhoneNumber) { - alt_is_valid = - PhoneNumberValidator(["mobile"])(altPhoneNumber) === undefined; - } - } - if ( - state.form[field] && - state.form[field] !== "+91" && - !alt_is_valid - ) { - errors[field] = "Please enter valid mobile number"; - invalidForm = true; - } - return; - case "email": - state.form[field] = state.form[field].trim(); - if ( - state.form[field].length === 0 || - !validateEmailAddress(state.form[field]) - ) { - errors[field] = "Please enter a valid email address"; - invalidForm = true; - } - return; - case "date_of_birth": - if (!state.form[field]) { - errors[field] = "Please enter date in DD/MM/YYYY format"; - invalidForm = true; - } else if ( - dayjs(state.form[field]).isAfter(dayjs().subtract(1, "year")) - ) { - errors[field] = "Enter a valid date of birth"; - invalidForm = true; - } - return; - case "state": - if (!Number(state.form[field])) { - errors[field] = "Please select the state"; - invalidForm = true; - } - return; - case "district": - if (!Number(state.form[field])) { - errors[field] = "Please select the district"; - invalidForm = true; - } - return; - case "local_body": - if (showLocalbody && !Number(state.form[field])) { - errors[field] = "Please select the local body"; - invalidForm = true; - } - return; - - default: - return; - } - }); - if (invalidForm) { - dispatch({ type: "set_errors", errors }); - const firstError = Object.keys(errors).find((e) => errors[e]); - if (firstError) { - scrollTo(firstError); - } - return false; - } - dispatch({ type: "set_errors", errors }); - return true; - }; - - const handleSubmit = async (e: any) => { - e.preventDefault(); - const validated = validateForm(); - if (validated) { - setIsLoading(true); - const data = { - user_type: state.form.user_type, - gender: state.form.gender, - password: state.form.password, - facilities: state.form.facilities ? state.form.facilities : undefined, - home_facility: state.form.home_facility ?? undefined, - username: state.form.username, - first_name: state.form.first_name ? state.form.first_name : undefined, - last_name: state.form.last_name ? state.form.last_name : undefined, - email: state.form.email, - state: state.form.state, - district: state.form.district, - local_body: showLocalbody ? state.form.local_body : null, - phone_number: - state.form.phone_number === "+91" - ? "" - : parsePhoneNumber(state.form.phone_number), - alt_phone_number: - parsePhoneNumber( - state.form.phone_number_is_whatsapp - ? state.form.phone_number === "+91" - ? "" - : state.form.phone_number - : state.form.alt_phone_number === "+91" - ? "" - : state.form.alt_phone_number, - ) ?? "", - date_of_birth: dateQueryString(state.form.date_of_birth), - qualification: - state.form.user_type === "Doctor" || state.form.user_type == "Nurse" - ? state.form.qualification - : undefined, - doctor_experience_commenced_on: - state.form.user_type === "Doctor" - ? dayjs() - .subtract( - parseInt(state.form.doctor_experience_commenced_on ?? "0"), - "years", - ) - .format("YYYY-MM-DD") - : undefined, - doctor_medical_council_registration: - state.form.user_type === "Doctor" - ? state.form.doctor_medical_council_registration - : undefined, - }; - - const { res } = await request(routes.addUser, { - body: data, - }); - if (res?.ok) { - dispatch({ type: "set_form", form: initForm }); - if (!userId) { - Notification.Success({ - msg: "User added successfully", - }); - } else { - Notification.Success({ - msg: "User updated successfully", - }); - } - navigate("/users"); - } - setIsLoading(false); - } - }; - - if (isLoading) { - return ; - } - - const field = (name: string) => { - return { - id: name, - name, - onChange: handleFieldChange, - value: (state.form as any)[name], - error: (state.errors as any)[name], - }; - }; return ( { } backUrl="/users" > - -
handleSubmit(e)}> - { - dispatch({ type: "set_state", state: newState }); - }} - formData={state.form} - /> -
-
- Facilities - -
- o.role + (o.readOnly ? " (Read Only)" : "")} - optionValue={(o) => o.id} - /> - - {(state.form.user_type === "Doctor" || - state.form.user_type === "Nurse") && ( - - )} - {state.form.user_type === "Doctor" && ( - <> - - - - - )} - - option.name} - optionValue={(option) => option.id} - onChange={handleFieldChange} - /> - -
- - -
- - - -
- { - handleFieldChange(e); - setUsernameInput(e.value); - }} - onFocus={() => setUsernameInputInFocus(true)} - onBlur={() => { - setUsernameInputInFocus(false); - }} - /> - {usernameInputInFocus && ( -
-
- {usernameExists !== userExistsEnums.idle && ( - <> - {usernameExists === userExistsEnums.checking ? ( - - {" "} - checking... - - ) : ( - <> - {usernameExists === userExistsEnums.exists ? ( -
- {" "} - - Username is not available - -
- ) : ( -
- {" "} - - Username is available - -
- )} - - )} - - )} -
-
- {validateRule( - usernameInput.length >= 4 && usernameInput.length <= 16, - "Username should be 4-16 characters long", - !state.form.username, - )} -
-
- {validateRule( - /^[a-z0-9._-]*$/.test(usernameInput), - "Username can only contain lowercase letters, numbers, and . _ -", - !state.form.username, - )} -
-
- {validateRule( - /^[a-z0-9].*[a-z0-9]$/i.test(usernameInput), - "Username must start and end with a letter or number", - !state.form.username, - )} -
-
- {validateRule( - !/(?:[._-]{2,})/.test(usernameInput), - "Username can't contain consecutive special characters . _ -", - !state.form.username, - )} -
-
- )} -
- - - -
- setPasswordInputInFocus(true)} - onBlur={() => setPasswordInputInFocus(false)} - /> - {passwordInputInFocus && ( -
- {validateRule( - state.form.password?.length >= 8, - "Password should be atleast 8 characters long", - !state.form.password, - )} - {validateRule( - state.form.password !== state.form.password.toUpperCase(), - "Password should contain at least 1 lowercase letter", - !state.form.password, - )} - {validateRule( - state.form.password !== state.form.password.toLowerCase(), - "Password should contain at least 1 uppercase letter", - !state.form.password, - )} - {validateRule( - /\d/.test(state.form.password), - "Password should contain at least 1 number", - !state.form.password, - )} -
- )} -
-
- setConfirmPasswordInputInFocus(true)} - onBlur={() => setConfirmPasswordInputInFocus(false)} - /> - {confirmPasswordInputInFocus && - state.form.c_password.length > 0 && - validateRule( - state.form.c_password === state.form.password, - "Confirm password should match the entered password", - !state.form.password, - )} -
- - - - o.text} - optionValue={(o) => o.text} - /> - - {isStateLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - onChange={(e) => { - handleFieldChange(e); - if (e) setSelectedStateId(e.value); - }} - /> - )} - - {isDistrictLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - onChange={(e) => { - handleFieldChange(e); - if (e) setSelectedDistrictId(e.value); - }} - /> - )} - - {showLocalbody && - (isLocalbodyLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - /> - ))} -
-
- goBack()} /> - -
- -
+
); }; + +export default UserAdd; diff --git a/src/components/Users/UserAddEditForm.tsx b/src/components/Users/UserAddEditForm.tsx new file mode 100644 index 00000000000..f82c4e73de7 --- /dev/null +++ b/src/components/Users/UserAddEditForm.tsx @@ -0,0 +1,1289 @@ +import { navigate } from "raviger"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import CircularProgress from "@/components/Common/CircularProgress"; +import { FacilitySelect } from "@/components/Common/FacilitySelect"; +import Loading from "@/components/Common/Loading"; +import { FacilityModel } from "@/components/Facility/models"; +import { + FieldError, + PhoneNumberValidator, +} from "@/components/Form/FieldValidators"; +import Form from "@/components/Form/Form"; +import { FormContextValue } from "@/components/Form/FormContext"; +import CheckBoxFormField from "@/components/Form/FormFields/CheckBoxFormField"; +import DateFormField from "@/components/Form/FormFields/DateFormField"; +import { FieldLabel } from "@/components/Form/FormFields/FormField"; +import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; +import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; +import TextFormField from "@/components/Form/FormFields/TextFormField"; +import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; +import { + UserForm, + ValidateDoctorExperienceCommencedOn, + ValidateDoctorMedicalCouncilRegistration, + ValidateQualification, + ValidateVideoLink, +} from "@/components/Users/UserFormValidations"; +import { GetUserTypes } from "@/components/Users/UserListAndCard"; +import { GenderType, UserModel } from "@/components/Users/models"; + +import useAppHistory from "@/hooks/useAppHistory"; +import useAuthUser from "@/hooks/useAuthUser"; + +import { GENDER_TYPES, USER_TYPES } from "@/common/constants"; +import { + validateEmailAddress, + validateName, + validateNumber, + validatePassword, + validateUsername, +} from "@/common/validation"; + +import { useAutoSaveReducer } from "@/Utils/AutoSave"; +import * as Notification from "@/Utils/Notifications"; +import dayjs from "@/Utils/dayjs"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import useQuery from "@/Utils/request/useQuery"; +import { classNames, dateQueryString, parsePhoneNumber } from "@/Utils/utils"; + +interface StateObj { + id: number; + name: string; +} + +const initForm: UserForm = { + user_type: "", + gender: "", + password: "", + c_password: "", + facilities: [], + home_facility: null, + username: "", + first_name: "", + last_name: "", + email: "", + phone_number: "+91", + alt_phone_number: "+91", + phone_number_is_whatsapp: true, + date_of_birth: null, + state: 0, + district: 0, + local_body: 0, + qualification: undefined, + doctor_experience_commenced_on: undefined, + doctor_medical_council_registration: undefined, + weekly_working_hours: undefined, + video_connect_link: undefined, +}; + +interface UserProps { + username?: string; + includedFields?: Array; + onSubmitSuccess?: () => void; +} + +const STAFF_OR_NURSE_USER = [ + "Staff", + "StaffReadOnly", + "Nurse", + "NurseReadOnly", +]; + +const initError = Object.assign( + {}, + ...Object.keys(initForm).map((k) => ({ [k]: "" })), +); + +const initialState = { + form: { ...initForm }, + errors: { ...initError }, +}; + +type UserFormAction = + | { type: "set_form"; form: UserForm } + | { type: "set_state"; state?: typeof initialState }; + +const user_create_reducer = (state = initialState, action: UserFormAction) => { + switch (action.type) { + case "set_form": { + return { + ...state, + form: action.form, + }; + } + case "set_state": { + if (action.state) return action.state; + return state; + } + default: + return state; + } +}; + +const getDate = (value: string | Date | null) => + value && dayjs(value).isValid() ? dayjs(value).toDate() : undefined; + +export const validateRule = ( + condition: boolean, + content: JSX.Element | string, + isInitialState: boolean = false, +) => { + return ( +
+ {isInitialState ? ( + + ) : condition ? ( + + ) : ( + + )}{" "} + + {content} + +
+ ); +}; + +const UserAddEditForm = (props: UserProps) => { + const { t } = useTranslation(); + const { goBack } = useAppHistory(); + const { username, includedFields } = props; + const editUser = username ? true : false; + const formVals = useRef(initForm); + const [facilityErrors, setFacilityErrors] = useState(""); + + const { + loading: userDataLoading, + data: userData, + refetch: refetchUserData, + } = useQuery(routes.getUserDetails, { + pathParams: { + username: username ?? "", + }, + prefetch: editUser && !!username, + onResponse: (result) => { + if (!editUser || !result || !result.res || !result.data) return; + const userData = result.data; + const formData: UserForm = { + first_name: userData.first_name, + last_name: userData.last_name, + date_of_birth: userData.date_of_birth || null, + gender: userData.gender || "Male", + email: userData.email, + video_connect_link: userData.video_connect_link, + phone_number: userData.phone_number?.toString() || "", + alt_phone_number: userData.alt_phone_number?.toString() || "", + weekly_working_hours: userData.weekly_working_hours, + phone_number_is_whatsapp: + userData.phone_number?.toString() === + userData.alt_phone_number?.toString(), + user_type: userData.user_type, + qualification: userData.qualification, + doctor_experience_commenced_on: userData.doctor_experience_commenced_on + ? dayjs() + .diff(dayjs(userData.doctor_experience_commenced_on), "years") + .toString() + : undefined, + doctor_medical_council_registration: + userData.doctor_medical_council_registration, + }; + dispatch({ + type: "set_form", + form: formData, + }); + formVals.current = formData; + }, + }); + + const prepData = ( + formData: UserForm, + isCreate: boolean = false, + ): Partial => { + const fields = includedFields ?? Object.keys(formData); + let baseData: Partial = {}; + const phoneNumber = parsePhoneNumber(formData.phone_number) ?? ""; + const altPhoneNumber = formData.phone_number_is_whatsapp + ? phoneNumber + : (parsePhoneNumber(formData.alt_phone_number) ?? ""); + + let fieldMappings: Partial = { + first_name: formData.first_name, + last_name: formData.last_name, + email: formData.email, + video_connect_link: formData.video_connect_link, + phone_number: phoneNumber, + alt_phone_number: altPhoneNumber, + gender: formData.gender as GenderType, + date_of_birth: dateQueryString(formData.date_of_birth), + qualification: + formData.user_type === "Doctor" || formData.user_type === "Nurse" + ? formData.qualification + : undefined, + doctor_experience_commenced_on: + formData.user_type === "Doctor" + ? dayjs() + .subtract( + parseInt( + (formData.doctor_experience_commenced_on as string) ?? "0", + ), + "years", + ) + .format("YYYY-MM-DD") + : undefined, + doctor_medical_council_registration: + formData.user_type === "Doctor" + ? formData.doctor_medical_council_registration + : undefined, + weekly_working_hours: + formData.weekly_working_hours && formData.weekly_working_hours !== "" + ? formData.weekly_working_hours + : undefined, + }; + + if (isCreate) { + fieldMappings = { + ...fieldMappings, + user_type: formData.user_type, + password: formData.password, + facilities: formData.facilities ? formData.facilities : undefined, + home_facility: formData.home_facility ?? undefined, + username: formData.username, + state: formData.state, + district: formData.district, + local_body: showLocalbody ? formData.local_body : undefined, + }; + } + + for (const field of fields) { + if (field in fieldMappings) { + baseData = { + ...baseData, + [field as keyof UserForm]: fieldMappings[field as keyof UserForm], + }; + } + } + + return baseData; + }; + + const handleEditSubmit = async (formData: UserForm) => { + if (!username) return; + const data = prepData(formData); + const { res, error } = await request(routes.partialUpdateUser, { + pathParams: { username }, + body: data as Partial, + }); + if (res?.ok) { + Notification.Success({ + msg: t("user_details_update_success"), + }); + await refetchUserData(); + } else { + Notification.Error({ + msg: error?.message ?? t("user_details_update_error"), + }); + } + props.onSubmitSuccess?.(); + }; + + const [state, dispatch] = useAutoSaveReducer( + user_create_reducer, + initialState, + ); + const [isLoading, setIsLoading] = useState(false); + const [states, setStates] = useState([]); + const [selectedStateId, setSelectedStateId] = useState(0); + const [selectedDistrictId, setSelectedDistrictId] = useState(0); + const [districts, setDistricts] = useState([]); + const [localBodies, setLocalBodies] = useState([]); + const [selectedFacility, setSelectedFacility] = useState([]); + const [usernameInputInFocus, setUsernameInputInFocus] = useState(false); + const [passwordInputInFocus, setPasswordInputInFocus] = useState(false); + const [confirmPasswordInputInFocus, setConfirmPasswordInputInFocus] = + useState(false); + const [usernameInput, setUsernameInput] = useState(""); + + const userExistsEnums = { + idle: 0, + checking: 1, + exists: 2, + available: 3, + }; + + const [usernameExists, setUsernameExists] = useState(0); + + const check_username = async (username: string) => { + setUsernameExists(userExistsEnums.checking); + const { res: usernameCheck } = await request(routes.checkUsername, { + pathParams: { username }, + silent: true, + }); + if (usernameCheck === undefined || usernameCheck.status === 409) + setUsernameExists(userExistsEnums.exists); + else if (usernameCheck.status === 200) + setUsernameExists(userExistsEnums.available); + else + Notification.Error({ + msg: "Some error checking username availabality. Please try again later.", + }); + }; + + useEffect(() => { + setUsernameExists(userExistsEnums.idle); + if (validateUsername(usernameInput)) { + const timeout = setTimeout(() => { + check_username(usernameInput); + }, 500); + return () => clearTimeout(timeout); + } + }, [usernameInput]); + + const userTypes = GetUserTypes(); + const authUser = useAuthUser(); + const userIndex = USER_TYPES.indexOf(authUser.user_type); + + const showLocalbody = ![ + "Pharmacist", + "Volunteer", + "Doctor", + ...STAFF_OR_NURSE_USER, + ].includes(state.form.user_type ?? ""); + + const { loading: isDistrictLoading } = useQuery(routes.getDistrictByState, { + prefetch: !!(selectedStateId > 0), + pathParams: { id: selectedStateId.toString() }, + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("DistrictAdmin")) { + setDistricts([authUser.district_object!]); + } else { + setDistricts(result.data); + } + }, + }); + + const { loading: isLocalbodyLoading } = useQuery( + routes.getAllLocalBodyByDistrict, + { + prefetch: !!(selectedDistrictId > 0), + pathParams: { id: selectedDistrictId.toString() }, + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("LocalBodyAdmin")) { + setLocalBodies([authUser.local_body_object!]); + } else { + setLocalBodies(result.data); + } + }, + }, + ); + + const { loading: isStateLoading } = useQuery(routes.statesList, { + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("StateAdmin")) { + setStates([authUser.state_object!]); + } else { + setStates(result.data.results); + } + }, + }); + + const handleDateChange = ( + event: FieldChangeEvent, + field?: FormContextValue, + ) => { + if (dayjs(event.value).isValid()) { + dispatch({ + type: "set_form", + form: { + ...state.form, + [event.name]: dayjs(event.value).format("YYYY-MM-DD"), + }, + }); + if (field) field(event.name as keyof UserForm).onChange(event); + } + }; + + const handleFieldChange = ( + event: FieldChangeEvent, + field?: FormContextValue, + ) => { + const fieldName = event.name as keyof UserForm; + dispatch({ + type: "set_form", + form: { + ...state.form, + [fieldName]: event.value, + }, + }); + field?.(fieldName).onChange(event); + }; + + const changePhoneNumber = ( + field: FormContextValue, + fieldName: keyof UserForm, + value: string | boolean, + ) => { + field(fieldName).onChange({ + name: field(fieldName).name, + value: value, + }); + }; + + const updatePhoneNumber = ( + field: FormContextValue, + phoneNumber: string, + ) => { + changePhoneNumber(field, "phone_number", phoneNumber); + return { phone_number: phoneNumber }; + }; + + const updateAltPhoneNumber = ( + field: FormContextValue, + allowUpdate: boolean, + phoneNumber: string, + ) => { + if (allowUpdate) { + changePhoneNumber(field, "alt_phone_number", phoneNumber); + return { alt_phone_number: phoneNumber }; + } + return {}; + }; + + const handlePhoneChange = ( + event: FieldChangeEvent, + field: FormContextValue, + ) => { + let formData = { ...state.form }; + let phoneNumberVal = ""; + switch (event.name) { + case "phone_number": + formData = { + ...formData, + ...updatePhoneNumber(field, event.value as string), + ...updateAltPhoneNumber( + field, + state.form.phone_number_is_whatsapp ?? true, + event.value as string, + ), + }; + break; + case "alt_phone_number": + phoneNumberVal = event.value as string; + formData = { + ...formData, + ...updateAltPhoneNumber( + field, + !(state.form.phone_number_is_whatsapp ?? true), + phoneNumberVal, + ), + }; + break; + case "phone_number_is_whatsapp": + formData = { + ...formData, + ...updateAltPhoneNumber( + field, + event.value as boolean, + state.form.phone_number, + ), + phone_number_is_whatsapp: event.value as boolean, + }; + changePhoneNumber( + field, + "phone_number_is_whatsapp", + event.value as boolean, + ); + break; + } + dispatch({ + type: "set_form", + form: formData, + }); + }; + + const setFacility = (selected: FacilityModel | FacilityModel[] | null) => { + const newSelectedFacilities = selected + ? Array.isArray(selected) + ? selected + : [selected] + : []; + setSelectedFacility(newSelectedFacilities as FacilityModel[]); + const form = { ...state.form }; + form.facilities = selected + ? (selected as FacilityModel[]).map((i) => i.id!) + : []; + dispatch({ type: "set_form", form }); + }; + + const validateFacility = ( + formData: UserForm, + selectedFacility: FacilityModel[], + ) => { + if ( + selectedFacility && + formData.user_type && + selectedFacility.length === 0 && + STAFF_OR_NURSE_USER.includes(authUser.user_type) && + STAFF_OR_NURSE_USER.includes(formData.user_type) + ) { + return "Please select atleast one of the facilities you are linked to"; + } + }; + + const validatePhoneNumber = (phoneNumber: string) => { + const parsedPhoneNumber = parsePhoneNumber(phoneNumber); + if (!parsedPhoneNumber) return false; + return PhoneNumberValidator()(parsedPhoneNumber) === undefined; + }; + + const validateForm = (formData: UserForm) => { + const errors: Partial> = {}; + const fieldsToValidate = includedFields || Object.keys(formData); + const facilityError = fieldsToValidate.includes("facilities") + ? validateFacility(formData, selectedFacility) + : null; + if (facilityError) { + errors.facilities = facilityError; + } + let currentError = null; + fieldsToValidate.forEach((field) => { + switch (field) { + case "user_type": + if (!formData[field]) { + errors[field] = t("please_select_user_type"); + } + break; + case "qualification": + currentError = ValidateQualification(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "doctor_experience_commenced_on": + currentError = ValidateDoctorExperienceCommencedOn(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "doctor_medical_council_registration": + currentError = ValidateDoctorMedicalCouncilRegistration(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "phone_number": + if (!formData[field] || !validatePhoneNumber(formData[field])) { + errors[field] = t("invalid_phone"); + } + break; + case "alt_phone_number": + if ( + formData[field] && + formData[field] !== "+91" && + !validatePhoneNumber(formData[field]) + ) { + errors[field] = t("mobile_number_validation_error"); + } + break; + case "username": + if (!formData[field]) { + errors[field] = t("please_enter_username"); + } else if (!validateUsername(formData[field])) { + errors[field] = t("invalid_username"); + } else if (usernameExists !== userExistsEnums.available) { + errors[field] = t("username_already_exists"); + } + break; + case "password": + if (!formData[field]) { + errors[field] = t("password_required"); + } else if (!validatePassword(formData[field])) { + errors.password = t("password_validation"); + } + break; + case "c_password": + if (!formData.password) { + errors.c_password = t("confirm_password_required"); + } else if (formData.password !== formData.c_password) { + errors.c_password = t("password_mismatch"); + } + break; + case "first_name": + case "last_name": + formData[field] = formData[field].trim(); + if (!formData[field]) { + errors[field] = t(`${field}_required`); + } else if (!validateName(formData[field])) { + errors[field] = t("min_char_length_error", { min_length: 3 }); + } + break; + case "email": + formData[field] = formData[field].trim(); + if ( + formData[field].length === 0 || + !validateEmailAddress(formData[field]) + ) { + errors[field] = t("invalid_email"); + } + break; + case "date_of_birth": + if (!formData[field]) { + errors[field] = t("dob_format"); + } else if ( + dayjs(formData[field]).isAfter(dayjs().subtract(1, "year")) + ) { + errors[field] = t("enter_valid_dob"); + } else if ( + dayjs(formData[field]).isAfter(dayjs().subtract(16, "year")) + ) { + errors[field] = t("enter_valid_dob_age"); + } + break; + case "gender": + if (!formData[field]) { + errors[field] = t("please_select_gender"); + } + break; + case "state": + if (!Number(formData[field])) { + errors[field] = t("please_select_state"); + } + break; + case "district": + if (!Number(formData[field])) { + errors[field] = t("please_select_district"); + } + break; + case "local_body": + if (showLocalbody && !Number(formData[field])) { + errors[field] = t("please_select_localbody"); + } + break; + case "weekly_working_hours": + if (formData[field] !== null && formData[field] !== undefined) { + const hours = Number(formData[field]); + if ( + Number.isNaN(hours) || + hours < 0 || + hours > 168 || + !validateNumber(formData[field] ?? "") + ) { + errors[field] = t("weekly_working_hours_error"); + } + } + break; + case "video_connect_link": + currentError = ValidateVideoLink(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + default: + break; + } + }); + return errors; + }; + + const handleSubmit = async (formData: UserForm) => { + setIsLoading(true); + const data = prepData(formData, true); + + const { res, error } = await request(routes.addUser, { + body: data, + }); + if (res?.ok) { + dispatch({ type: "set_form", form: initForm }); + Notification.Success({ + msg: t("user_added_successfully"), + }); + navigate("/users"); + } else { + Notification.Error({ + msg: error?.message ?? t("user_add_error"), + }); + } + setIsLoading(false); + }; + + useEffect(() => { + const facilityError = validateFacility(state.form, selectedFacility); + setFacilityErrors(facilityError || ""); + }, [state.form, selectedFacility]); + + if (isLoading || (editUser && userDataLoading)) { + return ; + } + + const handleCancel = () => { + dispatch({ + type: "set_form", + form: formVals.current, + }); + }; + + const renderDoctorOrNurseFields = (field: FormContextValue) => { + return ( + <> + {(state.form.user_type === "Doctor" || + state.form.user_type === "Nurse") && + includedFields?.includes("qualification") && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("qualification")} + /> + )} + {state.form.user_type === "Doctor" && ( +
+ {includedFields?.includes("doctor_experience_commenced_on") && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("years_of_experience")} + /> + )} + {includedFields?.includes( + "doctor_medical_council_registration", + ) && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("medical_council_registration")} + /> + )} +
+ )} + + ); + }; + + const renderPhoneNumberFields = (field: FormContextValue) => { + return ( + <> + {includedFields?.includes("phone_number") && ( +
+
+ { + handlePhoneChange(e, field); + }} + className="" + aria-label={t("phone_number")} + /> + { + handlePhoneChange(e, field); + }} + label={t("is_phone_a_whatsapp_number")} + /> +
+ { + handlePhoneChange(e, field); + }} + className="flex-1" + aria-label={t("whatsapp_number")} + /> +
+ )} + + ); + }; + + const renderUsernameField = (field: FormContextValue) => { + return ( + <> + {includedFields?.includes("username") && ( + <> + { + handleFieldChange(e, field); + setUsernameInput(e.value); + }} + onFocus={() => setUsernameInputInFocus(true)} + onBlur={() => { + setUsernameInputInFocus(false); + }} + aria-label={t("username")} + /> + {usernameInputInFocus && ( +
+
+ {usernameExists !== userExistsEnums.idle && ( + <> + {usernameExists === userExistsEnums.checking ? ( + + {" "} + checking... + + ) : ( + <> + {usernameExists === userExistsEnums.exists ? ( +
+ {" "} + + {t("username_not_available")} + +
+ ) : ( +
+ {" "} + + {t("username_available")} + +
+ )} + + )} + + )} +
+
+ {validateRule( + usernameInput.length >= 4 && usernameInput.length <= 16, + "Username should be 4-16 characters long", + !state.form.username, + )} + {validateRule( + /^[a-z0-9._-]*$/.test(usernameInput), + "Username can only contain lowercase letters, numbers, and . _ -", + !state.form.username, + )} + {validateRule( + /^[a-z0-9].*[a-z0-9]$/i.test(usernameInput), + "Username must start and end with a letter or number", + !state.form.username, + )} + {validateRule( + !/(?:[._-]{2,})/.test(usernameInput), + "Username can't contain consecutive special characters . _ -", + !state.form.username, + )} +
+
+ )} + + )} + + ); + }; + + const renderPasswordFields = (field: FormContextValue) => { + return ( + <> +
+ {includedFields?.includes("password") && ( +
+ setPasswordInputInFocus(true)} + onBlur={() => setPasswordInputInFocus(false)} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("password")} + /> + {passwordInputInFocus && state.form.password && ( +
+ {validateRule( + state.form.password.length >= 8, + t("password_length_validation"), + !state.form.password, + )} + {validateRule( + state.form.password !== state.form.password.toUpperCase(), + t("password_lowercase_validation"), + !state.form.password, + )} + {validateRule( + state.form.password !== state.form.password.toLowerCase(), + t("password_uppercase_validation"), + !state.form.password, + )} + {validateRule( + /\d/.test(state.form.password), + t("password_number_validation"), + !state.form.password, + )} +
+ )} +
+ )} + {includedFields?.includes("c_password") && ( +
+ setConfirmPasswordInputInFocus(true)} + onBlur={() => setConfirmPasswordInputInFocus(false)} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("confirm_password")} + /> + {confirmPasswordInputInFocus && + state.form.c_password && + state.form.c_password.length > 0 && ( +
+ {validateRule( + state.form.c_password === state.form.password, + t("password_mismatch"), + !state.form.c_password, + )} +
+ )} +
+ )} +
+ + ); + }; + + const renderPersonalInfoFields = (field: FormContextValue) => { + return ( + <> +
+ {includedFields?.includes("first_name") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("first_name")} + /> + )} + {includedFields?.includes("last_name") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("last_name")} + /> + )} +
+ {includedFields?.includes("email") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("email")} + /> + )} +
+ {includedFields?.includes("date_of_birth") && ( + { + handleDateChange(e, field); + }} + disableFuture + className="flex-1" + aria-label={t("date_of_birth")} + /> + )} + {includedFields?.includes("gender") && ( + o.text} + optionValue={(o) => o.text} + onChange={(e) => { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("gender")} + /> + )} +
+ + ); + }; + + const renderHoursAndConferenceLinkFields = ( + field: FormContextValue, + ) => { + return ( + <> +
+ {includedFields?.includes("weekly_working_hours") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("average_weekly_working_hours")} + /> + )} + {includedFields?.includes("video_connect_link") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("video_conference_link")} + /> + )} +
+ + ); + }; + + const renderStateDistrictLocalBodyFields = ( + field: FormContextValue, + ) => { + return ( + <> + {includedFields?.includes("state") && ( + <> + {isStateLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + if (e) setSelectedStateId(e.value); + }} + aria-label={t("state")} + /> + )} + + )} + {includedFields?.includes("district") && ( + <> + {isDistrictLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + if (e) setSelectedDistrictId(e.value); + }} + aria-label={t("district")} + /> + )} + + )} + {includedFields?.includes("local_body") && ( + <> + {isLocalbodyLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("local_body")} + /> + )} + + )} + + ); + }; + + const renderFacilityUserTypeHomeFacilityFields = ( + field: FormContextValue, + ) => { + return ( + <> + {includedFields?.includes("facilities") && ( +
+ {t("facilities")} + +
+ )} +
+ {includedFields?.includes("user_type") && ( + o.role + (o.readOnly ? " (Read Only)" : "")} + onChange={(e) => { + handleFieldChange(e, field); + }} + optionValue={(o) => o.id} + className="flex-1" + aria-label={t("user_type")} + /> + )} + {includedFields?.includes("home_facility") && ( + option.name} + optionValue={(option) => option.id} + onChange={(e) => { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("home_facility")} + /> + )} +
+ + ); + }; + + return ( + + disabled={isLoading} + defaults={userData ? state.form : initForm} + validate={validateForm} + onCancel={editUser ? handleCancel : () => goBack()} + onSubmit={editUser ? handleEditSubmit : handleSubmit} + onDraftRestore={(newState) => { + dispatch({ type: "set_state", state: newState }); + }} + hideRestoreDraft={editUser} + noPadding + resetFormValsOnCancel + hideCancelButton={editUser} + > + {(field) => ( + <> +
+ {renderFacilityUserTypeHomeFacilityFields(field)} + {renderDoctorOrNurseFields(field)} + {renderPhoneNumberFields(field)} + {renderUsernameField(field)} + {renderPasswordFields(field)} + {renderPersonalInfoFields(field)} + {renderHoursAndConferenceLinkFields(field)} + {renderStateDistrictLocalBodyFields(field)} +
+ + )} + + ); +}; + +export default UserAddEditForm; diff --git a/src/components/Users/UserAvatar.tsx b/src/components/Users/UserAvatar.tsx new file mode 100644 index 00000000000..c186039951e --- /dev/null +++ b/src/components/Users/UserAvatar.tsx @@ -0,0 +1,125 @@ +import careConfig from "@careConfig"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Avatar } from "@/components/Common/Avatar"; +import AvatarEditModal from "@/components/Common/AvatarEditModal"; +import ButtonV2 from "@/components/Common/ButtonV2"; +import Loading from "@/components/Common/Loading"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import { LocalStorageKeys } from "@/common/constants"; + +import * as Notification from "@/Utils/Notifications"; +import { showAvatarEdit } from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import uploadFile from "@/Utils/request/uploadFile"; +import useQuery from "@/Utils/request/useQuery"; +import { formatDisplayName, sleep } from "@/Utils/utils"; + +export default function UserAvatar({ username }: { username: string }) { + const { t } = useTranslation(); + const [editAvatar, setEditAvatar] = useState(false); + const authUser = useAuthUser(); + + const { + data: userData, + loading: isLoading, + refetch: refetchUserData, + } = useQuery(routes.getUserDetails, { + pathParams: { + username: username, + }, + }); + + if (isLoading || !userData) { + return ; + } + + const handleAvatarUpload = async (file: File, onError: () => void) => { + const formData = new FormData(); + formData.append("profile_picture", file); + const url = `${careConfig.apiUrl}/api/v1/users/${userData.username}/profile_picture/`; + + uploadFile( + url, + formData, + "POST", + { + Authorization: + "Bearer " + localStorage.getItem(LocalStorageKeys.accessToken), + }, + async (xhr: XMLHttpRequest) => { + if (xhr.status === 200) { + await sleep(1000); + refetchUserData(); + Notification.Success({ msg: t("avatar_updated_success") }); + setEditAvatar(false); + } + }, + null, + () => { + onError(); + }, + ); + }; + + const handleAvatarDelete = async (onError: () => void) => { + const { res } = await request(routes.deleteProfilePicture, { + pathParams: { username }, + }); + if (res?.ok) { + Notification.Success({ msg: "Profile picture deleted" }); + await refetchUserData(); + setEditAvatar(false); + } else { + onError(); + } + }; + + return ( + <> + setEditAvatar(false)} + /> +
+
+
+ +
+ setEditAvatar(!editAvatar)} + type="button" + id="change-avatar" + className="border border-gray-200 bg-gray-50 text-black hover:bg-gray-100" + shadow={false} + disabled={!showAvatarEdit(authUser, userData)} + tooltip={ + !showAvatarEdit(authUser, userData) + ? t("edit_avatar_permission_error") + : undefined + } + > + {t("change_avatar")} + +

+ {t("change_avatar_note")} +

+
+
+
+
+ + ); +} diff --git a/src/components/Users/UserBanner.tsx b/src/components/Users/UserBanner.tsx new file mode 100644 index 00000000000..c42c75ce165 --- /dev/null +++ b/src/components/Users/UserBanner.tsx @@ -0,0 +1,94 @@ +import dayjs from "dayjs"; +import { t } from "i18next"; + +import { Avatar } from "@/components/Common/Avatar"; +import UserDetails from "@/components/Common/UserDetails"; +import UserDetailComponent from "@/components/Common/UserDetailsComponet"; +import { UserModel } from "@/components/Users/models"; + +import { formatDisplayName, formatName } from "@/Utils/utils"; + +import { UserStatusIndicator } from "./UserListAndCard"; + +export default function UserBanner({ userData }: { userData: UserModel }) { + if (!userData) { + return; + } + + return ( +
+
+
+ +
+
+

+ {formatName(userData)} +

+
+ +
+
+ + + {userData.username} + +
+
+
+ {userData.user_type && ( + + )} + {userData.district_object && ( + + )} + + + {userData.home_facility_object?.name || t("no_home_facility")} + + + {["Doctor", "Nurse"].includes(userData.user_type) && ( + + {userData.qualification ? ( + {userData.qualification} + ) : ( + {t("unknown")} + )} + + )} + {userData.user_type === "Doctor" && ( + + {userData.doctor_experience_commenced_on ? ( + + {dayjs().diff( + userData.doctor_experience_commenced_on, + "years", + false, + )}{" "} + years + + ) : ( + {t("unknown")} + )} + + )} +
+
+
+ ); +} diff --git a/src/components/Users/UserEditDetails.tsx b/src/components/Users/UserEditDetails.tsx new file mode 100644 index 00000000000..66b9d0b22fd --- /dev/null +++ b/src/components/Users/UserEditDetails.tsx @@ -0,0 +1,177 @@ +import { TFunction } from "i18next"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import UserAddEditForm from "@/components/Users/UserAddEditForm"; +import { + editBasicInfoFields, + editContactInfoFields, + editProfessionalInfoFields, + editProfessionalInfoFieldsForNurseDoctor, +} from "@/components/Users/UserFormValidations"; +import { + BasicInfoDetails, + ContactInfoDetails, + ProfessionalInfoDetails, +} from "@/components/Users/UserViewDetails"; +import { UserModel } from "@/components/Users/models"; + +interface UserEditDetailsProps { + username: string; + userData: UserModel; + onSubmitSuccess?: () => void; +} + +const ViewEditToggle = ({ + isEditing, + setIsEditing, + id, + t, +}: { + isEditing: boolean; + setIsEditing: (value: boolean) => void; + id: string; + t: TFunction; +}) => { + return ( +
+ + +
+ ); +}; + +export function UserBasicInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} + +export function UserContactInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} + +export function UserProfessionalInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const editFields = + userData.user_type === "Doctor" || userData.user_type === "Nurse" + ? editProfessionalInfoFieldsForNurseDoctor + : editProfessionalInfoFields; + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} diff --git a/src/components/Users/UserFormValidations.tsx b/src/components/Users/UserFormValidations.tsx new file mode 100644 index 00000000000..a5af4549803 --- /dev/null +++ b/src/components/Users/UserFormValidations.tsx @@ -0,0 +1,161 @@ +import { TFunction } from "i18next"; + +import { validateNumber } from "@/common/validation"; + +import { FacilityModel } from "../Facility/models"; + +export type UserForm = { + user_type?: string; + gender: string; + password?: string; + c_password?: string; + facilities?: Array; + home_facility?: FacilityModel | null; + username?: string; + first_name: string; + last_name: string; + email: string; + phone_number: string; + alt_phone_number: string; + phone_number_is_whatsapp?: boolean; + date_of_birth: Date | null | string; + state?: number; + district?: number; + local_body?: number; + qualification?: string | undefined; + doctor_experience_commenced_on?: string; + doctor_medical_council_registration?: string; + video_connect_link?: string; + weekly_working_hours?: string | null; +}; + +export const newUserFields: Array = [ + "user_type", + "username", + "password", + "c_password", + "first_name", + "last_name", + "email", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", + "gender", + "date_of_birth", + "state", + "district", + "local_body", + "facilities", + "home_facility", + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", + "weekly_working_hours", + "video_connect_link", +]; + +export const editUserFields: Array = [ + "first_name", + "last_name", + "date_of_birth", + "gender", + "email", + "video_connect_link", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", + "weekly_working_hours", +]; + +export const editBasicInfoFields: Array = [ + "first_name", + "last_name", + "date_of_birth", + "gender", +]; + +export const editContactInfoFields: Array = [ + "email", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", +]; + +export const editProfessionalInfoFields: Array = [ + "weekly_working_hours", + "video_connect_link", +]; + +export const editProfessionalInfoFieldsForNurseDoctor: Array = [ + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", + ...editProfessionalInfoFields, +]; + +export const ValidateQualification = ( + formData: UserForm, + translator: TFunction, +) => { + if ( + (formData.user_type === "Doctor" || formData.user_type === "Nurse") && + !formData["qualification"] + ) { + return translator("qualification_required"); + } + return null; +}; + +export const ValidateDoctorExperienceCommencedOn = ( + formData: UserForm, + translator: TFunction, +) => { + if (formData.user_type === "Doctor") { + if (!formData["doctor_experience_commenced_on"]) { + return translator("doctor_experience_required"); + } else if ( + !validateNumber(formData["doctor_experience_commenced_on"] ?? "") || + Number(formData["doctor_experience_commenced_on"]) < 0 || + Number(formData["doctor_experience_commenced_on"]) > 100 + ) { + return translator("doctor_experience_error"); + } + } + return null; +}; + +export const ValidateDoctorMedicalCouncilRegistration = ( + formData: UserForm, + translator: TFunction, +) => { + if ( + formData.user_type === "Doctor" && + !formData["doctor_medical_council_registration"] + ) { + return translator("medical_council_registration_required"); + } + return null; +}; + +export const ValidateVideoLink = ( + formData: UserForm, + translator: TFunction, +) => { + if (!formData["video_connect_link"]) return null; + + try { + const parsed = new URL(formData["video_connect_link"]); + if (!["https:", "http:"].includes(parsed.protocol)) { + return translator("invalid_url_http_https"); + } + if (parsed.href.toLowerCase().includes("javascript:")) { + return translator("invalid_url_javascript"); + } + } catch { + return translator("invalid_url"); + } + return null; +}; diff --git a/src/components/Users/UserHome.tsx b/src/components/Users/UserHome.tsx new file mode 100644 index 00000000000..dea75598ec9 --- /dev/null +++ b/src/components/Users/UserHome.tsx @@ -0,0 +1,149 @@ +import { Link, navigate } from "raviger"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Loading from "@/components/Common/Loading"; +import Page from "@/components/Common/Page"; +import { userChildProps } from "@/components/Common/UserColumns"; +import Error404 from "@/components/ErrorPages/404"; +import LinkedFacilitiesTab from "@/components/Users/LinkedFacilitiesTab"; +import LinkedSkillsTab from "@/components/Users/LinkedSkillsTab"; +import UserBanner from "@/components/Users/UserBanner"; +import UserSummaryTab from "@/components/Users/UserSummary"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import useQuery from "@/Utils/request/useQuery"; +import { classNames, formatName, keysOf } from "@/Utils/utils"; + +export interface UserHomeProps { + username?: string; + tab: string; +} +export interface tabChildProp { + body: (childProps: userChildProps) => JSX.Element | undefined; + hidden?: boolean; +} + +export default function UserHome(props: UserHomeProps) { + const { tab } = props; + let { username } = props; + const [userData, setUserData] = useState(); + const { t } = useTranslation(); + const authUser = useAuthUser(); + if (!username) { + username = authUser.username; + } + + const { loading, refetch: refetchUserDetails } = useQuery( + routes.getUserDetails, + { + pathParams: { + username: username, + }, + onResponse: ({ res, data, error }) => { + if (res?.status === 200 && data) { + setUserData(data); + } else if (res?.status === 400) { + navigate("/users"); + } else if (error) { + Notification.Error({ + msg: "Error while fetching user details: " + (error?.message || ""), + }); + } + }, + }, + ); + + if (loading || !userData) { + return ; + } + + const editPermissions = editUserPermissions(authUser, userData); + + const TABS: { + PROFILE: tabChildProp; + SKILLS: tabChildProp; + FACILITIES: tabChildProp; + } = { + PROFILE: { body: UserSummaryTab }, + SKILLS: { + body: LinkedSkillsTab, + hidden: !editPermissions, + }, + FACILITIES: { + body: LinkedFacilitiesTab, + hidden: !editPermissions, + }, + }; + + const normalizedTab = tab.toUpperCase(); + const isValidTab = (tab: string): tab is keyof typeof TABS => + Object.keys(TABS).includes(tab as keyof typeof TABS); + const currentTab = isValidTab(normalizedTab) ? normalizedTab : undefined; + + if (!currentTab) { + return ; + } + + const SelectedTab = TABS[currentTab].body; + + return ( + <> + + { + <> + +
+
+
+ +
+
+
+ + + } +
+ + ); +} diff --git a/src/components/Users/UserListAndCard.tsx b/src/components/Users/UserListAndCard.tsx new file mode 100644 index 00000000000..ee1ac5e9874 --- /dev/null +++ b/src/components/Users/UserListAndCard.tsx @@ -0,0 +1,419 @@ +import { TFunction } from "i18next"; +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import Card from "@/CAREUI/display/Card"; +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Avatar } from "@/components/Common/Avatar"; +import Tabs from "@/components/Common/Tabs"; +import SearchInput from "@/components/Form/SearchInput"; +import { UserAssignedModel, UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; +import useWindowDimensions from "@/hooks/useWindowDimensions"; + +import { USER_TYPES, USER_TYPE_OPTIONS } from "@/common/constants"; + +import { + classNames, + formatName, + isUserOnline, + relativeTime, +} from "@/Utils/utils"; + +export const GetUserTypes = (editForm = false) => { + const authUser = useAuthUser(); + + const userIndex = USER_TYPES.indexOf(authUser.user_type); + const readOnlyUsers = USER_TYPE_OPTIONS.filter((user) => user.readOnly); + const defaultAllowedUserTypes = USER_TYPE_OPTIONS.slice(0, userIndex + 1); + + // Superuser gets all options + if (authUser.is_superuser) { + return [...USER_TYPE_OPTIONS]; + } + + switch (authUser.user_type) { + case "StaffReadOnly": + return readOnlyUsers.slice(0, 1); + case "DistrictReadOnlyAdmin": + return readOnlyUsers.slice(0, 2); + case "StateReadOnlyAdmin": + return readOnlyUsers.slice(0, 3); + case "Pharmacist": + return USER_TYPE_OPTIONS.slice(0, 1); + case "Nurse": + case "Staff": + if (editForm) return [...defaultAllowedUserTypes]; + // Temporarily allows creation of users with elevated permissions due to introduction of new roles. + return [...defaultAllowedUserTypes, USER_TYPE_OPTIONS[6]]; + default: + return defaultAllowedUserTypes; + } +}; +export const CanUserAccess = (user: UserModel | UserAssignedModel) => { + const allowedTypes = GetUserTypes(true).map((type) => type.id); + return allowedTypes.includes(user.user_type); +}; +const GetDetailsButton = (username: string) => { + const { t } = useTranslation(); + return ( +
+ +
+ ); +}; +const getNameAndStatusCard = ( + user: UserModel | UserAssignedModel, + cur_online: boolean, + showDetailsButton = false, +) => { + return ( +
+
+
+
+

+ {formatName(user)} +

+
+ +
+
+ + {user.username} + +
+
{showDetailsButton && GetDetailsButton(user.username)}
+
+
+ ); +}; + +const getDistrict = (user: UserModel | UserAssignedModel, t: TFunction) => { + const district = + "district_object" in user && user.district_object + ? user.district_object?.name + : "district" in user && user.district + ? user.district + : undefined; + + if (!district) return <>; + return ( +
+
{t("district")}
+
+ {district} +
+
+ ); +}; + +export const UserStatusIndicator = ({ + user, + className, + addPadding = false, +}: { + user: UserModel | UserAssignedModel; + className?: string; + addPadding?: boolean; +}) => { + const cur_online = isUserOnline(user); + const { t } = useTranslation(); + return ( +
+ + + {cur_online + ? t("online") + : user.last_login + ? relativeTime(user.last_login) + : t("never")} + +
+ ); +}; +const UserCard = ({ user }: { user: UserModel | UserAssignedModel }) => { + const userOnline = isUserOnline(user); + const { width } = useWindowDimensions(); + const mediumScreenBreakpoint = 640; + const isMediumScreen = width <= mediumScreenBreakpoint; + const isLessThanXLargeScreen = width <= 1280; + const { t } = useTranslation(); + + return ( + +
+
+
+
+ + {isMediumScreen && getNameAndStatusCard(user, userOnline)} +
+
+ {!isMediumScreen && + getNameAndStatusCard(user, userOnline, !isLessThanXLargeScreen)} +
+
+
{t("role")}
+
+ {user.user_type} +
+
+
+
{t("home_facility")}
+
+ {user.home_facility_object?.name || t("no_home_facility")} +
+
+ {getDistrict(user, t)} +
+
+ {t("average_weekly_working_hours")} +
+
+ {user.weekly_working_hours ?? "-"} +
+
+
+
+
+
+ {isLessThanXLargeScreen && ( +
{GetDetailsButton(user.username)}
+ )} +
+
+ ); +}; +export const UserGrid = ({ + users, +}: { + users?: UserModel[] | UserAssignedModel[]; +}) => ( +
+ {users?.map((user) => )} +
+); + +const UserListHeader = ({ + showDistrictColumn, +}: { + showDistrictColumn: boolean; +}) => { + const { t } = useTranslation(); + return ( + + + + {t("name")} + + {t("status")} + {t("role")} + {t("home_facility")} + {showDistrictColumn && ( + {t("district")} + )} + + + + ); +}; + +const UserListRow = ({ + user, + showDistrictColumn, +}: { + user: UserModel | UserAssignedModel; + showDistrictColumn: boolean; +}) => { + const { t } = useTranslation(); + return ( + + +
+ +
+

+ {formatName(user)} +

+ + @{user.username} + +
+
+ + + + + + {user.user_type} + + + {user.home_facility_object?.name || t("no_home_facility")} + + {showDistrictColumn && ( + + {"district_object" in user && user.district_object + ? user.district_object?.name + : "district" in user && user.district + ? user.district + : ""} + + )} + {GetDetailsButton(user.username)} + + ); +}; +export const UserList = ({ + users, +}: { + users?: UserModel[] | UserAssignedModel[]; +}) => { + const showDistrictColumn = users?.some( + (user) => "district_object" in user || "district" in user, + ); + return ( +
+ + + + {users?.map((user) => ( + + ))} + +
+
+ ); +}; +interface UserListViewProps { + users: UserModel[] | UserAssignedModel[]; + onSearch: (username: string) => void; + searchValue: string; + activeTab: number; + onTabChange: (tab: number) => void; +} + +export default function UserListView({ + users, + onSearch, + searchValue, + activeTab, + onTabChange, +}: UserListViewProps) { + const { t } = useTranslation(); + + return ( + <> +
+
+ onSearch(e.value)} + value={searchValue} + placeholder={t("search_by_username")} + /> +
+ + + Card +
+ ), + value: 0, + id: "user-card-view", + }, + { + text: ( +
+ + List +
+ ), + value: 1, + id: "user-list-view", + }, + ]} + currentTab={activeTab} + onTabChange={(tab) => onTabChange(tab as number)} + className="float-right" + /> +
+ {users.length > 0 ? ( + <> + {activeTab === 0 ? ( + + ) : ( + + )} + + ) : ( +
+
+ No Users Found +
+
+ )} + + ); +} diff --git a/src/components/Users/UserProfile.tsx b/src/components/Users/UserProfile.tsx index 7b0df12cac0..7e8dff2aa84 100644 --- a/src/components/Users/UserProfile.tsx +++ b/src/components/Users/UserProfile.tsx @@ -17,7 +17,7 @@ import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormFi import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; import TextFormField from "@/components/Form/FormFields/TextFormField"; import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; -import { validateRule } from "@/components/Users/UserAdd"; +import { validateRule } from "@/components/Users/UserAddEditForm"; import { GenderType, SkillModel, diff --git a/src/components/Users/UserResetPassword.tsx b/src/components/Users/UserResetPassword.tsx new file mode 100644 index 00000000000..2d61f2bda73 --- /dev/null +++ b/src/components/Users/UserResetPassword.tsx @@ -0,0 +1,213 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import Form from "@/components/Form/Form"; +import TextFormField from "@/components/Form/FormFields/TextFormField"; +import { validateRule } from "@/components/Users/UserAddEditForm"; +import { UpdatePasswordForm, UserModel } from "@/components/Users/models"; + +import * as Notification from "@/Utils/Notifications"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; + +import ButtonV2 from "../Common/ButtonV2"; + +interface PasswordForm { + username: string; + old_password: string; + new_password_1: string; + new_password_2: string; +} + +export default function UserResetPassword({ + userData, +}: { + userData: UserModel; +}) { + const { t } = useTranslation(); + const [isSubmitting, setisSubmitting] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const initForm: PasswordForm = { + username: userData.username, + old_password: "", + new_password_1: "", + new_password_2: "", + }; + + const validateNewPassword = (password: string) => { + if ( + password.length < 8 || + !/\d/.test(password) || + password === password.toUpperCase() || + password === password.toLowerCase() + ) { + return false; + } + return true; + }; + + const validateForm = (formData: PasswordForm) => { + const errors: Partial> = {}; + + if (!formData.old_password) { + errors.old_password = t("please_enter_current_password"); + } + + if (!formData.new_password_1) { + errors.new_password_1 = t("please_enter_new_password"); + } else if (!validateNewPassword(formData.new_password_1)) { + errors.new_password_1 = t("new_password_validation"); + } + + if (!formData.new_password_2) { + errors.new_password_2 = t("please_confirm_password"); + } else if (formData.new_password_1 !== formData.new_password_2) { + errors.new_password_2 = t("password_mismatch"); + } + + if (formData.new_password_1 === formData.old_password) { + errors.new_password_1 = t("new_password_same_as_old"); + } + + return errors; + }; + + const handleSubmit = async (formData: PasswordForm) => { + setisSubmitting(true); + const form: UpdatePasswordForm = { + old_password: formData.old_password, + username: userData.username, + new_password: formData.new_password_1, + }; + + const { res, data, error } = await request(routes.updatePassword, { + body: form, + }); + + if (res?.ok) { + Notification.Success({ msg: data?.message }); + } else { + Notification.Error({ + msg: error?.message ?? t("password_update_error"), + }); + } + setisSubmitting(false); + }; + + const renderPasswordForm = () => { + return ( + + defaults={initForm} + validate={validateForm} + onSubmit={handleSubmit} + resetFormValsOnCancel + resetFormValsOnSubmit + hideRestoreDraft + noPadding + disabled={isSubmitting} + hideCancelButton + > + {(field) => ( +
+ +
+ +
+ {validateRule( + field("new_password_1").value?.length >= 8, + t("password_length_validation"), + !field("new_password_1").value, + )} + {validateRule( + field("new_password_1").value !== + field("new_password_1").value?.toUpperCase(), + t("password_lowercase_validation"), + !field("new_password_1").value, + )} + {validateRule( + field("new_password_1").value !== + field("new_password_1").value?.toLowerCase(), + t("password_uppercase_validation"), + !field("new_password_1").value, + )} + {validateRule( + /\d/.test(field("new_password_1").value ?? ""), + t("password_number_validation"), + !field("new_password_1").value, + )} +
+
+
+ + {field("new_password_2").value?.length > 0 && ( +
+ {validateRule( + field("new_password_1").value === + field("new_password_2").value, + t("password_mismatch"), + !field("new_password_2").value, + )} +
+ )} +
+
+ )} + + ); + }; + + const editButton = () => ( +
+ setIsEditing(!isEditing)} + type="button" + id="change-edit-password-button" + className="flex items-center gap-2 rounded-sm border border-gray-100 bg-white px-3 py-1.5 text-sm text-[#009D48] shadow-sm hover:bg-gray-50" + shadow={false} + > + + {isEditing ? t("cancel") : t("change_password")} + +
+ ); + + return ( +
+ {editButton()} + {isEditing && renderPasswordForm()} +
+ ); +} diff --git a/src/components/Users/UserSummary.tsx b/src/components/Users/UserSummary.tsx new file mode 100644 index 00000000000..f75aa066e65 --- /dev/null +++ b/src/components/Users/UserSummary.tsx @@ -0,0 +1,236 @@ +import { navigate } from "raviger"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import ButtonV2 from "@/components/Common/ButtonV2"; +import LanguageSelector from "@/components/Common/LanguageSelector"; +import UserColumns from "@/components/Common/UserColumns"; +import UserAvatar from "@/components/Users/UserAvatar"; +import UserDeleteDialog from "@/components/Users/UserDeleteDialog"; +import { + UserBasicInfoView, + UserContactInfoView, + UserProfessionalInfoView, +} from "@/components/Users/UserEditDetails"; +import UserResetPassword from "@/components/Users/UserResetPassword"; +import { + BasicInfoDetails, + ContactInfoDetails, + ProfessionalInfoDetails, +} from "@/components/Users/UserViewDetails"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { + editUserPermissions, + showAvatarEdit, + showUserDelete, + showUserPasswordReset, +} from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; + +export default function UserSummaryTab({ + userData, + refetchUserData, +}: { + userData?: UserModel; + refetchUserData?: () => void; +}) { + const { t } = useTranslation(); + const [showDeleteDialog, setshowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const authUser = useAuthUser(); + + if (!userData) { + return <>; + } + + const handleSubmit = async () => { + setIsDeleting(true); + const { res, error } = await request(routes.deleteUser, { + pathParams: { username: userData.username }, + }); + setIsDeleting(false); + if (res?.status === 204) { + Notification.Success({ + msg: t("user_deleted_successfully"), + }); + setshowDeleteDialog(!showDeleteDialog); + navigate("/users"); + } else { + Notification.Error({ + msg: t("user_delete_error") + ": " + (error || ""), + }); + setshowDeleteDialog(!showDeleteDialog); + } + }; + + const userColumnsData = { + userData, + username: userData.username, + refetchUserData, + }; + const deletePermitted = showUserDelete(authUser, userData); + const passwordResetPermitted = showUserPasswordReset(authUser, userData); + const avatarPermitted = showAvatarEdit(authUser, userData); + const editPermissions = editUserPermissions(authUser, userData); + + const renderBasicInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + const renderContactInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + const renderProfessionalInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + return ( + <> + {showDeleteDialog && ( + { + setshowDeleteDialog(false); + }} + /> + )} +
+ {avatarPermitted && ( + + )} + + + + {passwordResetPermitted && ( + + )} + {authUser.username === userData.username && ( + + )} + {deletePermitted && ( +
+
+
+

{t("delete_account")}

+

{t("delete_account_note")}

+
+
+
+ deletePermitted} + onClick={() => setshowDeleteDialog(true)} + variant="danger" + data-testid="user-delete-button" + className="my-1 inline-flex" + disabled={isDeleting} + > + + {t("delete_account_btn")} + +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/UserViewDetails.tsx b/src/components/Users/UserViewDetails.tsx new file mode 100644 index 00000000000..8b6fb40a1d3 --- /dev/null +++ b/src/components/Users/UserViewDetails.tsx @@ -0,0 +1,155 @@ +import { useTranslation } from "react-i18next"; + +import { UserModel } from "./models"; + +interface UserViewDetailsProps { + user: UserModel; +} + +const LabelValue = ({ + label, + value, + id, +}: { + label: string; + value?: string | null; + id?: string; +}) => ( +
+ {label} + + {value || "-"} + +
+); + +interface BadgeProps { + text: string; + bgColor?: string; + textColor?: string; + className?: string; +} + +export const Badge = ({ + text, + textColor = "text-black", + className = "", +}: BadgeProps) => { + return ( +
+
+ + {text} + +
+ ); +}; + +export const BasicInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ + + + + + +
+
+ ); +}; + +export const ContactInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ + + +
+
+ ); +}; + +export const ProfessionalInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ {(user.user_type === "Doctor" || user.user_type === "Nurse") && ( + + )} + {user.user_type === "Doctor" && ( + <> + + + + )} + + +
+
+ ); +}; From 67f9c6e621f9d11ee7b65f95a83e476e71cd6467 Mon Sep 17 00:00:00 2001 From: Manmeet Nagi <143264649+manmeetnagii@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:44:08 +0530 Subject: [PATCH 08/11] fix infinite loading (#9354) --- src/components/Form/AutoCompleteAsync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/AutoCompleteAsync.tsx b/src/components/Form/AutoCompleteAsync.tsx index 48c36258765..1420b02317a 100644 --- a/src/components/Form/AutoCompleteAsync.tsx +++ b/src/components/Form/AutoCompleteAsync.tsx @@ -92,7 +92,7 @@ const AutoCompleteAsync = (props: Props) => { useEffect(() => { fetchDataDebounced(query); - }, [query, fetchDataDebounced]); + }, [query]); return (
From 7ef2299da4dba420c1c8e4c45adedb2fe24064e9 Mon Sep 17 00:00:00 2001 From: JavidSumra <112365664+JavidSumra@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:46:56 +0530 Subject: [PATCH 09/11] Fix Infinite render on country code change (#9337) --- src/components/Form/FormFields/PhoneNumberFormField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/FormFields/PhoneNumberFormField.tsx b/src/components/Form/FormFields/PhoneNumberFormField.tsx index 0fd6f914035..8b6566de8f7 100644 --- a/src/components/Form/FormFields/PhoneNumberFormField.tsx +++ b/src/components/Form/FormFields/PhoneNumberFormField.tsx @@ -105,7 +105,7 @@ const PhoneNumberFormField = React.forwardRef( } setCountry(phoneCodes[getCountryCode(field.value)!]); } - }, [setValue]); + }, [field.value]); return ( Date: Wed, 11 Dec 2024 14:33:31 +0530 Subject: [PATCH 10/11] Users Manage Test Fix (#9368) --- cypress/e2e/users_spec/UsersManage.cy.ts | 5 +++-- cypress/pageobject/Users/ManageUserPage.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/users_spec/UsersManage.cy.ts b/cypress/e2e/users_spec/UsersManage.cy.ts index 120eb551f57..39075b8b96f 100644 --- a/cypress/e2e/users_spec/UsersManage.cy.ts +++ b/cypress/e2e/users_spec/UsersManage.cy.ts @@ -34,6 +34,7 @@ describe("Manage User", () => { beforeEach(() => { cy.restoreLocalStorage(); + cy.viewport(1280, 720); cy.clearLocalStorage(/filters--.+/); cy.awaitUrl("/users"); }); @@ -233,7 +234,7 @@ describe("Manage User", () => { cy.wait(500); manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); - manageUserPage.clickAddSkillButton(); + manageUserPage.clickAddSkillButton(usernameforworkinghour); cy.wait(500); manageUserPage.assertSkillInAddedUserSkills(linkedskill); cy.wait(500); @@ -268,7 +269,7 @@ describe("Manage User", () => { manageUserPage.clickLinkedSkillTab(); manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); - manageUserPage.clickAddSkillButton(); + manageUserPage.clickAddSkillButton(usernameToLinkSkill); cy.verifyNotification("Skill added successfully"); cy.closeNotification(); manageUserPage.assertSkillInAddedUserSkills(linkedskill); diff --git a/cypress/pageobject/Users/ManageUserPage.ts b/cypress/pageobject/Users/ManageUserPage.ts index f5bbce7e95b..92e2008bfd8 100644 --- a/cypress/pageobject/Users/ManageUserPage.ts +++ b/cypress/pageobject/Users/ManageUserPage.ts @@ -127,6 +127,7 @@ export class ManageUserPage { } clearProfessionalInfo() { + cy.get("input[name='weekly_working_hours']").scrollIntoView(); cy.get("input[name='weekly_working_hours']").click().clear(); cy.get("input[name='video_connect_link']").click().clear(); } @@ -180,6 +181,7 @@ export class ManageUserPage { } typeInWeeklyWorkingHours(hours: string) { + cy.get("input[name='weekly_working_hours']").scrollIntoView(); cy.get("input[name='weekly_working_hours']").click().type(hours); } @@ -191,6 +193,7 @@ export class ManageUserPage { } verifyWorkingHours(expectedHours: string) { + cy.get("input[name='weekly_working_hours']").scrollIntoView(); cy.get("input[name='weekly_working_hours']").should("be.visible"); cy.get("input[name='weekly_working_hours']").should( "have.value", @@ -359,8 +362,8 @@ export class ManageUserPage { cy.get("[data-testid='user-delete-button']").click(); } - clickAddSkillButton() { - cy.intercept("GET", "**/api/v1/skill/**").as("getSkills"); + clickAddSkillButton(username: string) { + cy.intercept("GET", `**/api/v1/users/${username}/skill/**`).as("getSkills"); cy.get("#add-skill-button").click(); cy.wait("@getSkills").its("response.statusCode").should("eq", 200); } From e89530d39a98587d2f401ecd308b0dee79b51b4e Mon Sep 17 00:00:00 2001 From: Aditya Jindal Date: Wed, 11 Dec 2024 16:50:56 +0530 Subject: [PATCH 11/11] Upgrade cypress version (#9375) --- .github/workflows/cypress.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index 3abf95cd4b0..6b731c8840b 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -68,7 +68,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.11.0" - name: Install dependencies 📦 run: npm run install-all