diff --git a/app/src/components/domain/BaseMapPointInput/index.tsx b/app/src/components/domain/BaseMapPointInput/index.tsx index 22bb28ba91..5c11ba9ace 100644 --- a/app/src/components/domain/BaseMapPointInput/index.tsx +++ b/app/src/components/domain/BaseMapPointInput/index.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, + useState, } from 'react'; import { NumberInput } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; @@ -10,6 +11,7 @@ import { isNotDefined, } from '@togglecorp/fujs'; import { + MapCenter, MapContainer, MapLayer, MapSource, @@ -17,8 +19,11 @@ import { import { type ObjectError } from '@togglecorp/toggle-form'; import getBbox from '@turf/bbox'; import { + type AnySourceData, type CircleLayer, type FillLayer, + type FitBoundsOptions, + type FlyToOptions, type LngLat, type Map, type MapboxGeoJSONFeature, @@ -35,15 +40,34 @@ import { import { localUnitMapStyle } from '#utils/map'; import ActiveCountryBaseMapLayer from '../ActiveCountryBaseMapLayer'; +import LocationSearchInput, { type LocationSearchResult } from '../LocationSearchInput'; import i18n from './i18n.json'; import styles from './styles.module.css'; +const centerOptions = { + zoom: 16, + duration: 1000, +} satisfies FlyToOptions; + +const geoJsonSourceOptions = { + type: 'geojson', +} satisfies AnySourceData; + interface GeoPoint { lng: number; lat: number } +const fitBoundsOptions = { + padding: { + left: 20, + top: 20, + bottom: 50, + right: 20, + }, +} satisfies FitBoundsOptions; + type Value = Partial; interface Props extends BaseMapProps { @@ -93,17 +117,6 @@ function BaseMapPointInput(props: Props) { const countryDetails = useCountry({ id: country ?? -1 }); const strings = useTranslation(i18n); - const bounds = useMemo( - () => { - if (isNotDefined(countryDetails)) { - return undefined; - } - - return getBbox(countryDetails.bbox); - }, - [countryDetails], - ); - const pointGeoJson = useMemo( () => { if (isNotDefined(value) || isNotDefined(value.lng) || isNotDefined(value.lat)) { @@ -192,6 +205,27 @@ function BaseMapPointInput(props: Props) { [value, onChange, name], ); + const bounds = useMemo( + () => { + if (isNotDefined(countryDetails)) { + return undefined; + } + + return getBbox(countryDetails.bbox); + }, + [countryDetails], + ); + + const [searchResult, setSearchResult] = useState(); + + const center = useMemo(() => { + if (isNotDefined(searchResult)) { + return undefined; + } + + return [+searchResult.lon, +searchResult.lat] satisfies [number, number]; + }, [searchResult]); + return (
@@ -232,12 +266,21 @@ function BaseMapPointInput(props: Props) { />
+ {isDefined(countryDetails) && ( +
+ +
+ )} (props: Props) { (props: Props) { /> )} + {center && ( + + )} {children}
diff --git a/app/src/components/domain/BaseMapPointInput/styles.module.css b/app/src/components/domain/BaseMapPointInput/styles.module.css index c07f3b9f5e..deefdb1604 100644 --- a/app/src/components/domain/BaseMapPointInput/styles.module.css +++ b/app/src/components/domain/BaseMapPointInput/styles.module.css @@ -1,11 +1,23 @@ .base-map-point-input { display: flex; + position: relative; flex-direction: column; gap: var(--go-ui-spacing-md); + isolation: isolate; .location-inputs { display: grid; gap: var(--go-ui-spacing-sm); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); } + + .location-search { + position: absolute; + right: var(--go-ui-spacing-sm); + bottom: var(--go-ui-spacing-sm); + z-index: 1; + border-radius: var(--go-ui-border-radius-lg); + background-color: var(--go-ui-color-foreground); + padding: var(--go-ui-spacing-sm); + } } diff --git a/app/src/components/domain/LocationSearchInput/index.tsx b/app/src/components/domain/LocationSearchInput/index.tsx new file mode 100644 index 0000000000..6d8855271f --- /dev/null +++ b/app/src/components/domain/LocationSearchInput/index.tsx @@ -0,0 +1,108 @@ +import { + useCallback, + useState, +} from 'react'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { SearchSelectInput } from '@ifrc-go/ui'; +import { useDebouncedValue } from '@ifrc-go/ui/hooks'; + +import { useExternalRequest } from '#utils/restRequest'; + +export interface LocationSearchResult { + addresstype: string; + boundingbox: string[]; + readOnly?: boolean; + class: string; + display_name: string; + importance: number; + lat: string; + licence: string; + lon: string; + name: string; + osm_id: number; + osm_type: string; + place_id: number; + place_rank: number; + type: string +} + +function keySelector(result: LocationSearchResult) { + return String(result.osm_id); +} + +function labelSelector(result: LocationSearchResult) { + return result.name; +} + +function descriptionSelector(result: LocationSearchResult) { + return result.display_name; +} + +interface Props { + className?: string; + onResultSelect: (result: LocationSearchResult | undefined) => void; + countryIso: string; + readOnly?: boolean; +} + +function LocationSearchInput(props: Props) { + const { + className, + onResultSelect, + readOnly, + countryIso, + } = props; + + const [opened, setOpened] = useState(false); + const [searchText, setSearchText] = useState(undefined); + + const debouncedSearchText = useDebouncedValue( + searchText?.trim() ?? '', + ); + + const { + pending, + response: options, + } = useExternalRequest({ + skip: !opened || debouncedSearchText.length === 0, + url: 'https://nominatim.openstreetmap.org/search', + query: { + q: debouncedSearchText, + countrycodes: countryIso, + format: 'json', + }, + }); + + const handleOptionSelect = useCallback(( + _: string | undefined, + __: string, + option: LocationSearchResult | undefined, + ) => { + onResultSelect(option); + }, [onResultSelect]); + + return ( + } + /> + ); +} + +export default LocationSearchInput; diff --git a/app/src/hooks/domain/usePermissions.ts b/app/src/hooks/domain/usePermissions.ts index feff71b43c..9c0e19523c 100644 --- a/app/src/hooks/domain/usePermissions.ts +++ b/app/src/hooks/domain/usePermissions.ts @@ -20,7 +20,7 @@ function usePermissions() { && isDefined(countryId) && !!userMe?.is_admin_for_countries?.includes(countryId) ); - const isRegionAdmin = (regionId: number | undefined) => ( + const isRegionAdmin = (regionId: number | null | undefined) => ( !isGuestUser && isDefined(regionId) && !!userMe?.is_admin_for_regions?.includes(regionId) diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 8d3ec82ce2..93b6b00878 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -2,6 +2,8 @@ import { listToMap } from '@togglecorp/fujs'; import { type components } from '#generated/types'; +import { type GoApiResponse } from './restRequest'; + export const defaultChartMargin = { top: 0, right: 0, @@ -199,3 +201,22 @@ export const multiMonthSelectDefaultValue = listToMap( export const ERU_READINESS_READY = 1; export const ERU_READINESS_CAN_CONTRIBUTE = 2; export const ERU_READINESS_NO_CAPACITY = 3; + +// LocalUnits +type LocalUnitHealthFacilityTypeOptions = NonNullable['health_facility_type']>[number]>['id'] + +export const AMBULANCE_TYPE = 1 satisfies LocalUnitHealthFacilityTypeOptions; +export const HOSPITAL_TYPE = 3 satisfies LocalUnitHealthFacilityTypeOptions; +export const PRIMARY_HEALTH_TYPE = 5 satisfies LocalUnitHealthFacilityTypeOptions; +export const RESIDENTIAL_TYPE = 6 satisfies LocalUnitHealthFacilityTypeOptions; +export const TRAINING_FACILITY_TYPE = 7 satisfies LocalUnitHealthFacilityTypeOptions; +export const SPECIALIZED_SERVICES_TYPE = 8 satisfies LocalUnitHealthFacilityTypeOptions; +export const OTHER_TYPE = 9 satisfies LocalUnitHealthFacilityTypeOptions; + +type LocalUnitTrainingFacilityTypeOptions = NonNullable['professional_training_facilities']>[number]>['id'] + +export const OTHER_TRAINING_FACILITIES = 9 satisfies LocalUnitTrainingFacilityTypeOptions; + +type LocalUnitAffiliationOptions = NonNullable['affiliation']>[number]>['id'] + +export const OTHER_AFFILIATION = 9 satisfies LocalUnitAffiliationOptions; diff --git a/app/src/utils/localUnits.ts b/app/src/utils/localUnits.ts index 79a0852632..6ae48b7cd9 100644 --- a/app/src/utils/localUnits.ts +++ b/app/src/utils/localUnits.ts @@ -10,7 +10,7 @@ import { type GoApiResponse } from './restRequest'; type LocalUnitResponse = NonNullable>; -export function getFormFields(value: LocalUnitResponse | PartialLocalUnits) { +export function getFormFields(value: LocalUnitResponse | PartialLocalUnits | undefined) { const { // eslint-disable-next-line @typescript-eslint/no-unused-vars created_at, @@ -30,7 +30,7 @@ export function getFormFields(value: LocalUnitResponse | PartialLocalUnits) { ...formValues // Note: the cast is safe as we're only trying to // remove fields if they exist - } = removeNull(value) as LocalUnitResponse; + } = removeNull(value ?? {}) as LocalUnitResponse || undefined; const { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/app/src/utils/restRequest/go.ts b/app/src/utils/restRequest/go.ts index 477822b687..b9153939de 100644 --- a/app/src/utils/restRequest/go.ts +++ b/app/src/utils/restRequest/go.ts @@ -239,7 +239,17 @@ const isSuccessfulStatus = (status: number): boolean => status >= 200 && status const isContentTypeExcel = (res: Response): boolean => res.headers.get('content-type') === CONTENT_TYPE_EXCEL; -const isContentTypeJson = (res: Response): boolean => res.headers.get('content-type') === CONTENT_TYPE_JSON; +const isContentTypeJson = (res: Response): boolean => { + const contentTypeHeaders = res.headers.get('content-type'); + + if (isNotDefined(contentTypeHeaders)) { + return false; + } + + const mediaTypes = contentTypeHeaders.split('; '); + + return mediaTypes[0]?.toLowerCase() === CONTENT_TYPE_JSON; +}; const isLoginRedirect = (url: string): boolean => new URL(url).pathname.includes('login'); diff --git a/app/src/utils/restRequest/index.ts b/app/src/utils/restRequest/index.ts index ad31e30028..f8ebd92b38 100644 --- a/app/src/utils/restRequest/index.ts +++ b/app/src/utils/restRequest/index.ts @@ -15,6 +15,8 @@ import type { CustomLazyRequestReturn, CustomRequestOptions, CustomRequestReturn, + ExternalRequestOptions, + ExternalRequestReturn, VALID_METHOD, } from './overrideTypes'; @@ -70,8 +72,13 @@ const useRiskLazyRequest = useLazyRequest as < requestOptions: CustomLazyRequestOptions & { apiType: 'risk' } ) => CustomLazyRequestReturn; +const useExternalRequest = useRequest as ( + requestOptions: ExternalRequestOptions, +) => ExternalRequestReturn; + export { RequestContext, + useExternalRequest, useGoLazyRequest as useLazyRequest, useGoRequest as useRequest, useRiskLazyRequest, diff --git a/app/src/utils/restRequest/overrideTypes.ts b/app/src/utils/restRequest/overrideTypes.ts index 974ec91371..9911d6d96e 100644 --- a/app/src/utils/restRequest/overrideTypes.ts +++ b/app/src/utils/restRequest/overrideTypes.ts @@ -270,6 +270,11 @@ type LazyRequestOptionsBase = { OptionOmissions >; +export type ExternalRequestOptions = Pick< +RequestOptions, +'query' | 'url' | 'skip' +>; + export type CustomRequestOptions< SCHEMA extends object, PATH extends keyof SCHEMA, @@ -327,6 +332,8 @@ type LazyRequestReturn = ReturnType>; +export type ExternalRequestReturn = RequestReturn; + export type CustomRequestReturn< SCHEMA, PATH extends keyof SCHEMA, diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/ConfigureLocalUnitsModal/ConfirmationModal/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/ConfigureLocalUnitsModal/ConfirmationModal/index.tsx index 3dcf88497a..e9993689d6 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/ConfigureLocalUnitsModal/ConfirmationModal/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/ConfigureLocalUnitsModal/ConfirmationModal/index.tsx @@ -10,6 +10,7 @@ import { } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; import { resolveToString } from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; import useAlert from '#hooks/useAlert'; import { type CountryOutletContext } from '#utils/outletContext'; @@ -18,7 +19,7 @@ import { useLazyRequest } from '#utils/restRequest'; import i18n from './i18n.json'; export type ManageLocalUnitsValues = { - id: number; + id: number | undefined; country: number; local_unit_type: number; enabled: boolean; @@ -91,7 +92,8 @@ function ConfirmationModal(props: Props) { url: '/api/v2/externally-managed-local-unit/{id}/', method: 'PUT', body: (values: ManageLocalUnitsValues) => values, - pathVariables: manageLocalUnitsValues && { id: manageLocalUnitsValues?.id }, + pathVariables: isDefined(manageLocalUnitsValues?.id) + ? { id: manageLocalUnitsValues.id } : undefined, onSuccess: () => { alert.show( manageLocalUnitsValues?.enabled @@ -109,9 +111,11 @@ function ConfirmationModal(props: Props) { const handleConfirmButtonChange = useCallback(() => { if (isNewManageLocalUnit) { addManageLocalUnit(manageLocalUnitsValues); + } else { + updateManageLocalUnit(manageLocalUnitsValues); } - updateManageLocalUnit(manageLocalUnitsValues); - }, [isNewManageLocalUnit, addManageLocalUnit, manageLocalUnitsValues, updateManageLocalUnit]); + }, [isNewManageLocalUnit, + addManageLocalUnit, manageLocalUnitsValues, updateManageLocalUnit]); return ( { setLocalUnitType(name); - if (isDefined(manageResponse) - && isDefined(manageResponse[name]) - && isDefined(countryResponse) - ) { + + if (isDefined(countryResponse)) { + const isNew = isNotDefined(manageResponse) || isNotDefined(manageResponse[name]); + const manageId = isNew ? undefined : manageResponse[name]?.externallyManagedId; + setManageLocalUnitsValues({ - id: manageResponse[name].externallyManagedId, + id: manageId, country: countryResponse.id, local_unit_type: name, enabled: value, @@ -84,11 +88,12 @@ function ConfigureLocalUnitsModal(props: Props) { ]); const isNewManageLocalUnit = useMemo(() => { - if (isDefined(manageResponse) && isDefined(manageLocalUnitsValues)) { - return !(manageLocalUnitsValues.local_unit_type in manageResponse); + if (isNotDefined(manageLocalUnitsValues) + || isNotDefined(manageLocalUnitsValues?.id)) { + return true; } return false; - }, [manageResponse, manageLocalUnitsValues]); + }, [manageLocalUnitsValues]); const { response: localUnitsOptions, diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/i18n.json index 1a0d5fa30b..9779b4f1ab 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/i18n.json +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/i18n.json @@ -2,16 +2,16 @@ "namespace": "localUnitImportModal", "strings": { "modalHeading": "Import Local Units for {countryName}", - "modalDescription": "Please select a local unit type and upload the CSV file", + "modalDescription": "Please select a local unit type and upload the xlsx file", "modalImportPendingDescription": "Your file is currently being processed. You can close this modal and check the result from the upload history", "closeButtonLabel": "Close", "localUnitTypeInputLabel": "Local unit type", - "uploadFileSectionTitle": "Upload CSV file", - "uploadFileSectionDescription": "Please make sure to select a supported file format (csv) with size less than 10MB", + "uploadFileSectionTitle": "Upload xlsx file", + "uploadFileSectionDescription": "Please make sure to select a supported file format (xlsx) with size less than 10MB", "selectFileButtonLabel": "Select a file", "cancelUploadButtonLabel": "Cancel", "startUploadButtonLabel": "Upload", - "contentStructureDescription": "The contents in the CSV should follow the structure provided in {templateLink}.", + "contentStructureDescription": "The contents in the xlsx should follow the structure provided in {templateLink}.", "templateLinkLabel": "this template", "noPermissionBothDescription": "You don't have permission and this unit is not externally managed.", "noPermissionErrorDescription": "You do not have the permission to upload local unit data", diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/index.tsx index 43a20a4f99..fa61aabb90 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitImportModal/index.tsx @@ -258,7 +258,7 @@ function LocalUnitBulkUploadModal(props: Props) { {isNotDefined(bulkUploadFile) && ( ['other_profiles']>[number]; + +interface Props { + newValue: OtherProfile; + oldValue: OtherProfile | undefined; +} + +function OtherProfilesDiffOutput(props: Props) { + const { + newValue, + oldValue, + } = props; + + const strings = useTranslation(i18n); + + return ( +
+ + + + + + +
+ ); +} + +export default OtherProfilesDiffOutput; diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/OtherProfilesDiffOutput/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/OtherProfilesDiffOutput/styles.module.css new file mode 100644 index 0000000000..b21104e02f --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/OtherProfilesDiffOutput/styles.module.css @@ -0,0 +1,5 @@ +.other-profiles-input { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-sm); +} diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/i18n.json index e29680e742..c170e04482 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/i18n.json +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/i18n.json @@ -55,8 +55,10 @@ "localUnitViewDentist": "Dentist", "localUnitViewNursingAid": "Nursing aid", "localUnitViewMidwife": "Midwife", - "localUnitViewOtherMedicalHeal": "Other medical heal", + "localUnitViewPharmacists": "Pharmacists", "localUnitViewOtherProfiles": "Other profiles", + "localUnitViewRemovedOtherProfiles": "Removed Other profiles", + "localUnitViewOtherMedicalHeal": "Other medical heal", "localUnitViewCommentsNS": "Comments by the NS", "localUnitViewLatitude": "Latitude", "localUnitViewLongitude": "Longitude", diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/index.tsx index f7a71b8da6..bfec9c8fd0 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/index.tsx @@ -1,10 +1,14 @@ -import { useMemo } from 'react'; +import { + useCallback, + useMemo, +} from 'react'; import { Container, TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; import { + injectClientId, numericIdSelector, stringNameSelector, stringValueSelector, @@ -27,10 +31,12 @@ import { useRequest, } from '#utils/restRequest'; +import { injectClientIdToResponse } from '../common'; import { type PartialLocalUnits, TYPE_HEALTH_CARE, } from '../LocalUnitsFormModal/LocalUnitsForm/schema'; +import OtherProfilesDiffOutput from './OtherProfilesDiffOutput'; import i18n from './i18n.json'; import styles from './styles.module.css'; @@ -79,15 +85,49 @@ function LocalUnitView(props: Props) { pathVariables: isDefined(localUnitId) ? { id: localUnitId } : undefined, }); + const previousValue = injectClientIdToResponse( + // eslint-disable-next-line max-len + localUnitPreviousResponse?.previous_data_details as unknown as (LocalUnitResponse | undefined), + ); + const newValue = isDefined(locallyChangedValue) ? locallyChangedValue - : localUnitResponse; + : injectClientIdToResponse(localUnitResponse); + const oldValue = isDefined(locallyChangedValue) - ? localUnitResponse - : (localUnitPreviousResponse?.previous_data_details as unknown as LocalUnitResponse); + ? injectClientIdToResponse(localUnitResponse) + : previousValue; + + const getPreviousProfileValue = useCallback((profileClientId: string) => ( + oldValue?.health?.other_profiles?.find( + (previousProfile) => injectClientId(previousProfile)?.client_id === profileClientId, + ) + ), [oldValue]); + + const removedOtherProfiles = useMemo(() => ( + oldValue?.health?.other_profiles?.filter(({ client_id: oldClientId }) => ( + newValue?.health?.other_profiles?.findIndex( + ({ client_id: newClientId }) => oldClientId === newClientId, + ) === -1 + )) + ), [oldValue, newValue]); + + const changedOtherProfiles = useMemo(() => { + const potentiallyChanged = newValue?.health?.other_profiles?.filter( + (newOne) => { + const oldOne = oldValue?.health?.other_profiles?.find( + ({ client_id: oldClientId }) => newOne.client_id === oldClientId, + ); + + return hasDifferences(newOne, oldOne); + }, + ); + + return potentiallyChanged; + }, [oldValue, newValue]); const hasDifference = useMemo(() => { - if (isNotDefined(newValue) || isNotDefined(oldValue)) { + if (isNotDefined(newValue) && isNotDefined(oldValue)) { return false; } @@ -854,16 +894,42 @@ function LocalUnitView(props: Props) { + {isDefined(changedOtherProfiles) && changedOtherProfiles.length > 0 && ( + <> + + {strings.localUnitViewOtherProfiles} + + {newValue?.health?.other_profiles?.map((profile) => ( + + ))} + + )} + {isDefined(removedOtherProfiles) && removedOtherProfiles.length > 0 && ( + <> + + {strings.localUnitViewRemovedOtherProfiles} + + {removedOtherProfiles?.map((profile) => ( + + ))} + + )} ['other_profiles']>[number]; +type PreviousValueResponse = NonNullable>; +type OtherProfilesResponseFields = NonNullable['health']>['other_profiles']>[number]; + +const defaultOtherProfilesValue: OtherProfilesFormFields = { + client_id: '-1', +}; + +interface Props { + value: OtherProfilesFormFields; + previousValue?: OtherProfilesResponseFields; + showValueChanges: boolean; + showChanges: boolean; + error: ArrayError | undefined; + onChange: (value: SetValueArg, index: number) => void; + onRemove: (index: number) => void; + index: number; + readOnly?: boolean; +} + +function OtherProfilesInput(props: Props) { + const strings = useTranslation(i18n); + + const { + value, + onChange, + index, + onRemove, + readOnly, + showValueChanges, + showChanges, + previousValue, + error: errorFromProps, + } = props; + + const onFieldChange = useFormObject(index, onChange, defaultOtherProfilesValue); + + const error = (value && value.client_id && errorFromProps) + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + return ( +
+ + + + + + + + +
+ ); +} + +export default OtherProfilesInput; diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/OtherProfilesInput/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/OtherProfilesInput/styles.module.css new file mode 100644 index 0000000000..1a9e1dc819 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/OtherProfilesInput/styles.module.css @@ -0,0 +1,10 @@ +.other-profiles{ + display: grid; + grid-gap: var(--go-ui-spacing-sm) var(--go-ui-spacing-md); + grid-template-columns: 10fr 4fr 1fr; + align-items: center; + + @media screen and (max-width: 30rem) { + grid-template-columns: 1fr; + } +} diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json index cd3baabd48..6a8089556e 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/i18n.json @@ -19,6 +19,7 @@ "sourceEn": "Source (En)", "sourceLocal": "Source (Local)", "addressAndContactTitle": "Address and Contact", + "qualifiersTitle": "Qualifiers", "humanResourcesTitle": "Human Resources", "addressEn": "Address (En)", "addressLocal": "Address (Local)", @@ -47,6 +48,7 @@ "otherServices": "Other Services", "bloodServices": "Blood Services", "professionalTrainingFacilities": "Professional training facilities", + "otherTrainingFacilities": "Other training facilities", "generalMedicalServices": "General medical services", "specialist": "Specialist", "primaryHealthCareCenter": "Primary health care center", @@ -65,9 +67,10 @@ "dentist": "Dentist", "nursingAid": "Nursing aid", "midwife": "Midwife", + "pharmacists": "Pharmacists", "otherMedicalHeal": "Other medical heal", - "otherProfiles": "Other profiles", "commentsNS": "Comments by the NS", + "addOtherProfilesButtonLabel": "Add Other Profiles", "submitButtonLabel": "Submit", "editButtonLabel": "Edit", "localUnitDeleteButtonLabel": "Delete", diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx index 9efa44c010..255ff4caa4 100644 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitsFormModal/LocalUnitsForm/index.tsx @@ -9,6 +9,7 @@ import { useOutletContext } from 'react-router-dom'; import { BooleanInput, Button, + Checklist, Container, DateInput, DateOutput, @@ -25,6 +26,7 @@ import { useTranslation, } from '@ifrc-go/ui/hooks'; import { + injectClientId, numericIdSelector, resolveToComponent, stringNameSelector, @@ -33,12 +35,14 @@ import { import { isDefined, isNotDefined, + listToMap, + randomString, } from '@togglecorp/fujs'; import { getErrorObject, getErrorString, - removeNull, useForm, + useFormArray, useFormObject, } from '@togglecorp/toggle-form'; @@ -54,7 +58,18 @@ import useGlobalEnums from '#hooks/domain/useGlobalEnums'; import usePermissions from '#hooks/domain/usePermissions'; import useAlert from '#hooks/useAlert'; import { getFirstTruthyString } from '#utils/common'; -import { VISIBILITY_PUBLIC } from '#utils/constants'; +import { + AMBULANCE_TYPE, + HOSPITAL_TYPE, + OTHER_AFFILIATION, + OTHER_TRAINING_FACILITIES, + OTHER_TYPE, + PRIMARY_HEALTH_TYPE, + RESIDENTIAL_TYPE, + SPECIALIZED_SERVICES_TYPE, + TRAINING_FACILITY_TYPE, + VISIBILITY_PUBLIC, +} from '#utils/constants'; import { getUserName } from '#utils/domain/user'; import { type CountryOutletContext } from '#utils/outletContext'; import { @@ -66,7 +81,7 @@ import { transformObjectError } from '#utils/restRequest/error'; import { EXTERNALLY_MANAGED, - type ManageResponse, + injectClientIdToResponse, UNVALIDATED, VALIDATED, } from '../../common'; @@ -76,6 +91,7 @@ import LocalUnitStatus from '../../LocalUnitStatus'; import LocalUnitValidateButton from '../../LocalUnitValidateButton'; import LocalUnitValidateModal from '../../LocalUnitValidateModal'; import LocalUnitViewModal from '../../LocalUnitViewModal'; +import OtherProfilesInput from './OtherProfilesInput'; import schema, { type LocalUnitsRequestPostBody, type PartialLocalUnits, @@ -85,7 +101,8 @@ import schema, { import i18n from './i18n.json'; import styles from './styles.module.css'; -type HealthLocalUnitFormFields = PartialLocalUnits['health']; +type HealthLocalUnitFormFields = NonNullable; +type OtherProfilesFormFields = NonNullable[number]; type VisibilityOptions = NonNullable['api_visibility_choices']>[number] type LocalUnitResponse = NonNullable>; @@ -119,7 +136,6 @@ interface Props { actionsContainerRef: RefObject; headingDescriptionRef?: RefObject; headerDescriptionRef: RefObject; - manageResponse: ManageResponse; } function LocalUnitsForm(props: Props) { @@ -132,7 +148,6 @@ function LocalUnitsForm(props: Props) { headingDescriptionRef, headerDescriptionRef, onDeleteActionSuccess, - manageResponse, } = props; const { isAuthenticated } = useAuth(); @@ -140,6 +155,7 @@ function LocalUnitsForm(props: Props) { const { isSuperUser, isCountryAdmin, + isRegionAdmin, isLocalUnitGlobalValidatorByType, isLocalUnitRegionValidatorByType, isLocalUnitCountryValidatorByType, @@ -201,6 +217,14 @@ function LocalUnitsForm(props: Props) { defaultHealthValue, ); + const { + setValue: onOtherProfilesChanges, + removeValue: onOtherProfilesRemove, + } = useFormArray<'other_profiles', OtherProfilesFormFields>( + 'other_profiles', + onHealthFieldChange, + ); + const { response: localUnitDetailsResponse, pending: localUnitDetailsPending, @@ -210,31 +234,53 @@ function LocalUnitsForm(props: Props) { url: '/api/v2/local-units/{id}/', pathVariables: isDefined(localUnitId) ? { id: localUnitId } : undefined, onSuccess: (response) => { - setValue(removeNull(response)); + const responseWithClientId = injectClientIdToResponse(response); + + if (isDefined(responseWithClientId)) { + setValue(responseWithClientId); + } }, }); const { response: localUnitPreviousResponse, } = useRequest({ + skip: isNotDefined(localUnitId), url: '/api/v2/local-units/{id}/latest-change-request/', pathVariables: isDefined(localUnitId) ? { id: localUnitId } : undefined, }); - const isLocked = ( - isDefined(localUnitDetailsResponse?.status) - && !(localUnitDetailsResponse.status === VALIDATED) - ); + const { + response: externallyManagedLocalUnitsResponse, + } = useRequest({ + url: '/api/v2/externally-managed-local-unit/', + query: { + country__id: countryResponse?.id, + limit: 9999, + }, + }); - const isNewLocalUnit = localUnitDetailsResponse?.status === UNVALIDATED; + const externallyManagedByLocalUnitType = useMemo(() => { + if (isNotDefined(externallyManagedLocalUnitsResponse?.results)) { + return undefined; + } + + return listToMap( + externallyManagedLocalUnitsResponse?.results, + (res) => res.local_unit_type_details.id, + (res) => res.enabled, + ); + }, [externallyManagedLocalUnitsResponse]); - const isExternallyManaged = (localUnitDetailsResponse?.status === EXTERNALLY_MANAGED - || (isDefined(value.type) - && isDefined(manageResponse) - && !!manageResponse[value.type]?.enabled)); + const isEditable = localUnitDetailsResponse?.status === VALIDATED; - const readOnly = readOnlyFromProps - || isLocked || isExternallyManaged; + const isNewlyCreated = localUnitDetailsResponse?.status === UNVALIDATED; + const isExternallyManaged = localUnitDetailsResponse?.status === EXTERNALLY_MANAGED; + const isExternallyManagedType = isDefined(value.type) + ? !!(externallyManagedByLocalUnitType?.[value.type]) + : false; + + const readOnly = readOnlyFromProps || isExternallyManaged || isExternallyManagedType; const { response: localUnitsOptions, @@ -336,16 +382,19 @@ function LocalUnitsForm(props: Props) { }, }); - const hasValidatePermission = isAuthenticated - && !isExternallyManaged - && (isSuperUser - || isLocalUnitGlobalValidatorByType(value.type) - || isLocalUnitCountryValidatorByType(countryResponse?.id, value.type) - || isLocalUnitRegionValidatorByType(countryResponse?.region, value.type)); + const hasValidatePermission = isAuthenticated && ( + isSuperUser + || isLocalUnitGlobalValidatorByType(value.type) + || isLocalUnitCountryValidatorByType(countryResponse?.id, value.type) + || isLocalUnitRegionValidatorByType(countryResponse?.region, value.type) + ); + + const hasUpdatePermission = isCountryAdmin(countryResponse?.id) + || isRegionAdmin(countryResponse?.region) + || hasValidatePermission; - const hasUpdatePermission = (isCountryAdmin(countryResponse?.id) - || hasValidatePermission) - && !isExternallyManaged; + const hasDeletePermission = isCountryAdmin(countryResponse?.id) + || hasValidatePermission; const handleFormSubmit = useCallback( () => { @@ -400,22 +449,48 @@ function LocalUnitsForm(props: Props) { localUnitPreviousResponse?.previous_data_details as unknown as LocalUnitResponse | undefined ); - const showChanges = !isNewLocalUnit - && isLocked - && showValueChanges + const diffViewEnabled = showValueChanges + && !isNewlyCreated + && !isEditable + && !isExternallyManagedType && !isExternallyManaged; - const showViewChanges = !isNewLocalUnit + const showViewChangesButton = !isNewlyCreated && isDefined(localUnitId) - && isLocked + && !isEditable && !isExternallyManaged; - const permissionError = useMemo(() => { + const isOtherTrainingFacility = useMemo(() => { + if (isNotDefined(value.health?.professional_training_facilities)) { + return false; + } + return value.health?.professional_training_facilities?.some( + (facility) => facility === OTHER_TRAINING_FACILITIES, + ); + }, [value.health?.professional_training_facilities]); + + const handleOtherProfilesAddButtonClick = useCallback( + () => { + const newOtherProfiles: OtherProfilesFormFields = { + client_id: randomString(), + }; + + onHealthFieldChange( + (oldValue: OtherProfilesFormFields[] | undefined) => ( + [...(oldValue ?? []), newOtherProfiles] + ), + 'other_profiles' as const, + ); + }, + [onHealthFieldChange], + ); + + const otherErrors = useMemo(() => { if (isExternallyManaged) { - if (isDefined(localUnitId)) { - return strings.noPermissionFormUpdateExternallyManaged; - } + return strings.noPermissionFormUpdateExternallyManaged; + } + if (isExternallyManagedType) { return strings.noPermissionFormExternallyManaged; } @@ -431,6 +506,7 @@ function LocalUnitsForm(props: Props) { }, [ localUnitId, isExternallyManaged, + isExternallyManagedType, hasUpdatePermission, strings.noPermissionFormUpdateExternallyManaged, strings.noLocalUnitAddPermission, @@ -438,6 +514,12 @@ function LocalUnitsForm(props: Props) { strings.noPermissionFormExternallyManaged, ]); + const getPreviousProfileValue = useCallback((profileClientId: string) => ( + previousData?.health?.other_profiles?.find( + (previousProfile) => injectClientId(previousProfile)?.client_id === profileClientId, + ) + ), [previousData]); + const submitButton = readOnly ? null : ( + )} + + + + + +