diff --git a/.changeset/all-weeks-grow.md b/.changeset/all-weeks-grow.md new file mode 100644 index 0000000000..6e3592bbd7 --- /dev/null +++ b/.changeset/all-weeks-grow.md @@ -0,0 +1,5 @@ +--- +"@ifrc-go/ui": minor +--- + +Add MultiTimelineHeader component to column shortcuts diff --git a/.changeset/grumpy-ways-wash.md b/.changeset/grumpy-ways-wash.md new file mode 100644 index 0000000000..cd2082fd93 --- /dev/null +++ b/.changeset/grumpy-ways-wash.md @@ -0,0 +1,19 @@ +--- +"go-web-app": minor +--- + +Implement [ERU Readiness](https://github.com/IFRCGo/go-web-app/issues/1710) + +- Restucture surge page to acommodate ERU + - Move surge deployment related sections to a new dedicated tab **Active Surge Deployments** + - Update active deployments to improve scaling of points in the map + - Add **Active Surge Support per Emergency** section + - Revamp **Surge Overview** tab + - Add **Rapid Response Personnel** sub-tab + - Update existings charts and add new related tables/charts + - Add **Emergency Response Unit** sub-tab + - Add section to visualize ERU capacity and readiness + - Add section to view ongoing ERU deployments + - Add a form to update ERU Readiness + - Add option to export ERU Readiness data +- Update **Respond > Surge/Deployments** menu to include **Active Surge Deployments** diff --git a/app/src/App/routes/SurgeRoutes.tsx b/app/src/App/routes/SurgeRoutes.tsx index d594679008..1d9b63a271 100644 --- a/app/src/App/routes/SurgeRoutes.tsx +++ b/app/src/App/routes/SurgeRoutes.tsx @@ -17,11 +17,11 @@ import { rootLayout, } from './common'; -type DefaultSurgeChild = 'overview'; +type DefaultSurgeChild = 'active-surge-deployments'; const surgeLayout = customWrapRoute({ parent: rootLayout, path: 'surge', - forwardPath: 'overview' satisfies DefaultSurgeChild, + forwardPath: 'active-surge-deployments' satisfies DefaultSurgeChild, component: { render: () => import('#views/Surge'), props: {}, @@ -40,7 +40,7 @@ const surgeIndex = customWrapRoute({ eagerLoad: true, render: Navigate, props: { - to: 'overview' satisfies DefaultSurgeChild, + to: 'active-surge-deployments' satisfies DefaultSurgeChild, replace: true, }, }, @@ -50,9 +50,25 @@ const surgeIndex = customWrapRoute({ }, }); -const surgeOverview = customWrapRoute({ +const activeSurgeDeployments = customWrapRoute({ parent: surgeLayout, - path: 'overview' satisfies DefaultSurgeChild, + path: 'active-surge-deployments', + component: { + render: () => import('#views/ActiveSurgeDeployments'), + props: {}, + }, + context: { + title: 'Active Surge Deployments', + visibility: 'anything', + }, +}); + +type DefaultSurgeOverviewChild = 'rapid-response-personnel'; + +const surgeOverviewLayout = customWrapRoute({ + parent: surgeLayout, + path: 'overview', + forwardPath: 'rapid-response-personnel' satisfies DefaultSurgeOverviewChild, component: { render: () => import('#views/SurgeOverview'), props: {}, @@ -63,6 +79,67 @@ const surgeOverview = customWrapRoute({ }, }); +const surgeOverviewIndex = customWrapRoute({ + parent: surgeOverviewLayout, + index: true, + component: { + eagerLoad: true, + render: Navigate, + props: { + to: 'rapid-response-personnel' satisfies DefaultSurgeOverviewChild, + replace: true, + }, + }, + context: { + title: 'Surge Overview', + visibility: 'anything', + }, +}); + +const rapidResponsePersonnel = customWrapRoute({ + parent: surgeOverviewLayout, + path: 'rapid-response-personnel', + component: { + render: () => import('#views/SurgeOverview/RapidResponsePersonnel'), + props: {}, + }, + context: { + title: 'Rapid Response Personnel', + visibility: 'anything', + }, +}); + +const emergencyResponseUnit = customWrapRoute({ + parent: surgeOverviewLayout, + path: 'emergency-response-unit', + component: { + render: () => import('#views/SurgeOverview/EmergencyResponseUnit'), + props: {}, + }, + context: { + title: 'Emergency Response Unit', + visibility: 'anything', + }, +}); + +const eruReadinessForm = customWrapRoute({ + parent: rootLayout, + path: 'eru-readiness', + component: { + render: () => import('#views/EruReadinessForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'ERU Readiness Update Form', + visibility: 'is-authenticated', + permissions: ({ + isRegionalOrCountryAdmin, + isSuperUser, + }) => isSuperUser || isRegionalOrCountryAdmin, + }, +}); + const surgeOperationalToolbox = customWrapRoute({ parent: surgeLayout, path: 'operational-toolbox', @@ -1264,7 +1341,7 @@ function DeploymentNavigate() { const params = useParams<{ surgeId: string }>(); const deploymentRouteMap: Record> = { - overview: surgeOverview, + overview: surgeOverviewLayout, 'operational-toolbox': surgeOperationalToolbox, personnel: allDeployedPersonnel, erus: allDeployedEmergencyResponseUnits, @@ -1276,7 +1353,7 @@ function DeploymentNavigate() { const path = isDefined(newRoute) ? newRoute.absoluteForwardPath - : surgeOverview.absoluteForwardPath; + : surgeOverviewLayout.absoluteForwardPath; return ( boolean, isRegionPerAdmin: (regionId: number | undefined) => boolean, isCountryPerAdmin: (countryId: number | undefined) => boolean, + isRegionalOrCountryAdmin: boolean, isPerAdmin: boolean, isIfrcAdmin: boolean, isSuperUser: boolean, diff --git a/app/src/components/DisplayName/index.tsx b/app/src/components/DisplayName/index.tsx new file mode 100644 index 0000000000..48b161c429 --- /dev/null +++ b/app/src/components/DisplayName/index.tsx @@ -0,0 +1,9 @@ +interface DisplayNameOutputProps { + name: string; +} + +function DisplayName({ name }: DisplayNameOutputProps) { + return name; +} + +export default DisplayName; diff --git a/app/src/components/Navbar/i18n.json b/app/src/components/Navbar/i18n.json index bb24ac64c2..e533651514 100644 --- a/app/src/components/Navbar/i18n.json +++ b/app/src/components/Navbar/i18n.json @@ -70,6 +70,7 @@ "userMenuGoResourcesItem":"GO Resources", "userMenuGoResourcesItemDescription":"Find all relevant user guides, references videos, IFRC other resources, and GO contacts on this page.", "userMenuSurveyDesignToolItem":"Survey Designer", - "userMenuSurveyDesignToolItemDescription": "Build standardised needs assessment surveys quickly and efficiently and publish them in IFRC KoboToolbox." + "userMenuSurveyDesignToolItemDescription": "Build standardised needs assessment surveys quickly and efficiently and publish them in IFRC KoboToolbox.", + "userMenuActiveSurgeDeployments": "Active Surge Deployments" } } diff --git a/app/src/components/Navbar/index.tsx b/app/src/components/Navbar/index.tsx index 13992b6251..9006ef2d93 100644 --- a/app/src/components/Navbar/index.tsx +++ b/app/src/components/Navbar/index.tsx @@ -368,7 +368,14 @@ function Navbar(props: Props) { + {strings.userMenuActiveSurgeDeployments} + + {strings.userMenuSurgeGlobalOverview} diff --git a/app/src/hooks/domain/usePermissions.ts b/app/src/hooks/domain/usePermissions.ts index eb49908fcc..337edf8d54 100644 --- a/app/src/hooks/domain/usePermissions.ts +++ b/app/src/hooks/domain/usePermissions.ts @@ -38,13 +38,17 @@ function usePermissions() { const isPerAdmin = !isGuestUser && ((userMe?.is_per_admin_for_countries.length ?? 0) > 0 - || (userMe?.is_admin_for_regions.length ?? 0) > 0); + || (userMe?.is_per_admin_for_regions.length ?? 0) > 0); const isIfrcAdmin = !isGuestUser && (!!userMe?.is_ifrc_admin || !!userMe?.email?.toLowerCase().endsWith('@ifrc.org')); const isSuperUser = !isGuestUser && !!userMe?.is_superuser; + const isRegionalOrCountryAdmin = !isGuestUser + && ((userMe?.is_admin_for_countries.length ?? 0) > 0 + || (userMe?.is_admin_for_regions.length ?? 0) > 0); + return { isDrefRegionalCoordinator, isRegionAdmin, @@ -55,6 +59,7 @@ function usePermissions() { isIfrcAdmin, isSuperUser, isGuestUser, + isRegionalOrCountryAdmin, }; }, [userMe], diff --git a/app/src/utils/common.ts b/app/src/utils/common.ts index fbffe1ff87..f387c4d06f 100644 --- a/app/src/utils/common.ts +++ b/app/src/utils/common.ts @@ -52,3 +52,10 @@ export function getFirstTruthyString( return invalidText; } + +export function joinStrings( + values: (string | undefined)[], + separator: string = ', ', +): string { + return values.filter(Boolean).join(separator); +} diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 905ccb8ac2..79eee57999 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -191,3 +191,7 @@ export const multiMonthSelectDefaultValue = listToMap( (key) => key, () => false, ); + +export const ERU_READINESS_READY = 1; +export const ERU_READINESS_CAN_CONTRIBUTE = 2; +export const ERU_READINESS_NO_CAPACITY = 3; diff --git a/app/src/utils/domain/eru.ts b/app/src/utils/domain/eru.ts new file mode 100644 index 0000000000..ca80fbc287 --- /dev/null +++ b/app/src/utils/domain/eru.ts @@ -0,0 +1,119 @@ +import { + maxSafe, + minSafe, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { type GoApiResponse } from '#utils/restRequest'; + +type GetRapidResponseByEvent = GoApiResponse<'/api/v2/personnel_by_event/'>; +type GetERUByEvent = GoApiResponse<'/api/v2/deployed_eru_by_event/'>; +type RapidResponseByEventItem = NonNullable[number]; +type EruByEventItem= NonNullable[number]; + +interface DateRangeItem { + start_date: string | null | undefined, + end_date: string | null | undefined, +} + +function extractDates( + items: DateRangeItem[], + accessor: (data: DateRangeItem) => string | number | null | undefined, +) { + return ( + items.flatMap((item) => accessor(item)) + .filter(isDefined).map((date) => new Date(date).getTime()) + ); +} + +export function getRapidResponseEventDates(data: RapidResponseByEventItem[] | undefined) { + if (isNotDefined(data) || data.length < 1) { + return undefined; + } + const appealStartDateList = extractDates( + data.flatMap((event) => event.appeals), + (appeal) => appeal.start_date, + ); + const appealEndDateList = extractDates( + data.flatMap((event) => event.appeals), + (appeal) => appeal.end_date, + ); + + const personnelStarDateList = extractDates( + data.flatMap((event) => ( + event.deployments.flatMap((deployment) => deployment.personnel) + )), + (personnel) => personnel.start_date, + ); + + const personnelEndDateList = extractDates( + data.flatMap((event) => ( + event.deployments.flatMap((deployment) => deployment.personnel) + )), + (personnel) => personnel.end_date, + ); + + const appealStartDate = minSafe(appealStartDateList); + const appealEndDate = maxSafe(appealEndDateList); + const personnelStartDate = minSafe(personnelStarDateList); + const personnelEndDate = maxSafe(personnelEndDateList); + const timelineStartDate = minSafe([...appealStartDateList, ...personnelStarDateList]); + const timelineEndDate = maxSafe([...appealEndDateList, ...personnelEndDateList]); + + return { + appealStartDate: isDefined(appealStartDate) ? new Date(appealStartDate) : undefined, + appealEndDate: isDefined(appealEndDate) ? new Date(appealEndDate) : undefined, + personnelStartDate: isDefined(personnelStartDate) + ? new Date(personnelStartDate) : undefined, + personnelEndDate: isDefined(personnelEndDate) ? new Date(personnelEndDate) : undefined, + timelineStartDate: isDefined(timelineStartDate) ? new Date(timelineStartDate) : undefined, + timelineEndDate: isDefined(timelineEndDate) ? new Date(timelineEndDate) : undefined, + }; +} + +export function getEruEventDates(data: EruByEventItem[] | undefined) { + if (isNotDefined(data) || data.length < 1) { + return undefined; + } + const appealStartDateList = extractDates( + data.flatMap((event) => event.appeals), + (event) => event.start_date, + ); + const appealEndDateList = extractDates( + data.flatMap((event) => event.appeals), + (appeal) => appeal.end_date, + ); + + const eruStarDateList = extractDates( + data.flatMap((event) => ( + event.active_erus + )), + (eru) => eru.start_date, + ); + const eruEndDateList = extractDates( + data.flatMap((event) => ( + event.active_erus + )), + (eru) => eru.end_date, + ); + + const appealStartDate = minSafe(appealStartDateList); + const appealEndDate = maxSafe(appealEndDateList); + const eruStartDate = minSafe(eruStarDateList); + const eruEndDate = maxSafe(eruEndDateList); + const timelineStartDate = minSafe([...appealStartDateList, ...eruStarDateList]); + const timelineEndDate = maxSafe([...appealEndDateList, ...eruEndDateList]); + + return { + appealStartDate: isDefined(appealStartDate) ? new Date(appealStartDate) : undefined, + appealEndDate: isDefined(appealEndDate) ? new Date(appealEndDate) : undefined, + eruStartDate: isDefined(eruStartDate) + ? new Date(eruStartDate) : undefined, + eruEndDate: isDefined(eruEndDate) ? new Date(eruEndDate) : undefined, + timelineStartDate: isDefined(timelineStartDate) ? new Date(timelineStartDate) : undefined, + timelineEndDate: isDefined(timelineEndDate) ? new Date(timelineEndDate) : undefined, + }; +} diff --git a/app/src/views/SurgeOverview/SurgeAlertsTable/i18n.json b/app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/i18n.json similarity index 70% rename from app/src/views/SurgeOverview/SurgeAlertsTable/i18n.json rename to app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/i18n.json index 83823ee028..be891ff250 100644 --- a/app/src/views/SurgeOverview/SurgeAlertsTable/i18n.json +++ b/app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/i18n.json @@ -1,7 +1,8 @@ { - "namespace": "surgeOverview", + "namespace": "activeSurgeDeployments", "strings": { - "surgeAlertsTableHeading": "Open Surge Alerts", + "surgeAlertsTableHeading": "Active Rapid Response Alerts", + "surgeAlertsTableHeaderDescription": "To apply for an open position, contact your local surge team.", "surgeAlertsTableAlertDate": "Alert Date", "surgeAlertsTableDuration": "Duration", "surgeAlertsTableStartDate": "Start Date", diff --git a/app/src/views/SurgeOverview/SurgeAlertsTable/index.tsx b/app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/index.tsx similarity index 90% rename from app/src/views/SurgeOverview/SurgeAlertsTable/index.tsx rename to app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/index.tsx index 0dda276049..245e3646f8 100644 --- a/app/src/views/SurgeOverview/SurgeAlertsTable/index.tsx +++ b/app/src/views/ActiveSurgeDeployments/ActiveRapidResponseTable/index.tsx @@ -58,7 +58,7 @@ function getMolnixKeywords(molnixTags: SurgeAlertListItem['molnix_tags']) { return filtered.map((tag) => tag.name).join(', '); } -function SurgeAlertsTable() { +function ActiveRapidResponseAlertsTable() { const strings = useTranslation(i18n); const { sortState, @@ -125,7 +125,7 @@ function SurgeAlertsTable() { } return undefined; }, - { cellRendererClassName: styles.startColumn }, + { columnClassName: styles.startColumn }, ), createStringColumn( 'message', @@ -178,8 +178,8 @@ function SurgeAlertsTable() { return ( )} actions={( - <> - {/* {strings.wikiJsLink?.length > 0 && ( - - )} */} - - {strings.surgeAlertsViewAll} - - + + {strings.surgeAlertsViewAll} + )} > ; +type AggregatedSurgeItem = NonNullable[number]; + +interface Props { + className?: string; + emergencyId: number; + surgeItem: AggregatedSurgeItem; +} +function SurgeCard(props: Props) { + const { + className, + emergencyId, + surgeItem: { + name: emergencyName, + ifrc_severity_level: severityLevel, + deployed_eru_count: deployedERUCount, + deployed_personnel_count: deployedPersonnelCount, + deployments, + erus, + appeals, + }, + } = props; + + const strings = useTranslation(i18n); + + const operationStartDate = minSafe(appeals.map( + (a) => a.start_date, + ).filter(isDefined).map((d) => new Date(d).getTime())); + + const operationEndDate = maxSafe(appeals.map( + (a) => a.end_date, + ).filter(isDefined).map((d) => new Date(d).getTime())); + + const duration = isDefined(operationStartDate) && isDefined(operationEndDate) + ? getDuration(new Date(operationStartDate), new Date(operationEndDate)) : undefined; + + const deployedERUTypes = useMemo(() => ( + unique(erus + .map((eru) => eru.type_display) + .filter(isDefined) + .map((eruType) => ({ name: eruType }))) + ), [erus]); + + const personnel = useMemo(() => ( + deployments.flatMap((deployment) => deployment.personnel) + ), [deployments]); + + const deployedPersonnelTypes = useMemo(() => ( + unique(personnel + .map((person) => person.role) + .filter(isDefined) + .map((role) => ({ name: role }))) + ), [personnel]); + + const eruDeployingOrganizations = useMemo(() => ( + unique(erus + .map((eru) => eru.eru_owner_details.national_society_country_details.society_name) + .filter(isDefined) + .map((nationalSociety) => ({ name: nationalSociety }))) + ), [erus]); + + const personnelDeployingOrganizations = useMemo(() => ( + unique(personnel + .map((person) => (person.country_from.society_name)) + .filter(isDefined) + .map((nationalSociety) => ({ name: nationalSociety }))) + ), [personnel]); + + const rendererParams = useCallback( + (value: { name: string }) => ({ + name: value.name, + }), + [], + ); + + return ( + + {emergencyName} + + )} + headerDescription={resolveToComponent( + strings.operationTimeline, + { + startDate: ( + + ), + duration, + }, + )} + icons={severityLevel ? ( + + ) : undefined} + childrenContainerClassName={styles.content} + > + {deployedERUCount > 0 && ( + <> + +
+ + + )} + label={strings.surgeDeployingOrganizations} + strongValue + /> + + )} + {deployedERUCount > 0 && deployedPersonnelCount > 0 && ( +
+ )} + {deployedPersonnelCount > 0 && ( + <> + +
+ + + )} + label={strings.surgeDeployingOrganizations} + strongValue + /> + + )} + + ); +} + +export default SurgeCard; diff --git a/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/SurgeCard/styles.module.css b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/SurgeCard/styles.module.css new file mode 100644 index 0000000000..7ff5069f5b --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/SurgeCard/styles.module.css @@ -0,0 +1,31 @@ +.surge-card { + border-radius: var(--go-ui-border-radius-lg); + box-shadow: var(--go-ui-box-shadow-md); + + .severity-indicator { + font-size: var(--go-ui-font-size-xl); + } + + .content { + display: grid; + grid-template-columns: 4fr var(--go-ui-width-separator-thin) 5fr; + gap: var(--go-ui-spacing-md); + + .figure { + padding: 0; + } + + .separator { + grid-column: span 3; + border-top: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + } + + .vertical-separator { + border-left: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + } + + .deploying-organizations { + grid-column: span 3; + } + } +} diff --git a/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/i18n.json b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/i18n.json new file mode 100644 index 0000000000..b16a151b02 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/i18n.json @@ -0,0 +1,6 @@ +{ + "namespace": "activeSurgeDeployments", + "strings": { + "activeSurgeSupportHeading": "Active Surge Support Per Emergency" + } +} diff --git a/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/index.tsx b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/index.tsx new file mode 100644 index 0000000000..24367e64eb --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/ActiveSurgeSupport/index.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react'; +import { + Container, + Pager, + RawList, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import useFilterState from '#hooks/useFilterState'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import SurgeCard from './SurgeCard'; + +import i18n from './i18n.json'; + +type AggregatedSurgeResponse = GoApiResponse<'/api/v2/aggregated-eru-and-rapid-response/'>; +type AggregatedSurgeItem = NonNullable[number]; + +function aggregatedSurgeKeySelector(item: AggregatedSurgeItem) { + return item.id; +} + +const PAGE_SIZE = 6; + +function ActiveSurgeSupport() { + const strings = useTranslation(i18n); + + const { + limit, + offset, + page, + setPage, + } = useFilterState({ + filter: {}, + pageSize: PAGE_SIZE, + }); + + const { + pending: aggregatedSurgePending, + response: aggregatedSurgeResponse, + error: aggregatedSurgeResponseError, + } = useRequest({ + url: '/api/v2/aggregated-eru-and-rapid-response/', + query: { + limit, + offset, + }, + preserveResponse: true, + }); + + const rendererParams = useCallback((id: number, surgeItem: AggregatedSurgeItem) => ({ + emergencyId: id, + surgeItem, + }), []); + + return ( + + )} + > + + + ); +} + +export default ActiveSurgeSupport; diff --git a/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/i18n.json b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/i18n.json new file mode 100644 index 0000000000..b914db5cff --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/i18n.json @@ -0,0 +1,18 @@ +{ + "namespace": "activeSurgeDeployments", + "strings": { + "eruHeading": "Ongoing ERU Deployments", + "eruEmergency": "Emergency / Name", + "eruOrganisation": "Deploying Organisation", + "eruName": "Name", + "eruViewAll": "View All", + "eruTypes": "ERU Types", + "ongoingEmergencyStartDate": "Emergency Start Date", + "ongoingEmergencyEndDate": "Emergency End Date", + "eruStartDate": "Deployment Start Date", + "eruEndDate": "Deployment End Date", + "eruEmergencyTimeline": "Emergency Timeframe", + "eruDeploymentTimeline": "Deployment Timeline", + "eruDeploymentCountry": "Deployed To" + } +} diff --git a/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/index.tsx b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/index.tsx new file mode 100644 index 0000000000..d164d00559 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/index.tsx @@ -0,0 +1,329 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + LegendItem, + Pager, + SelectInput, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { type RowOptions } from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createEmptyColumn, + createExpandColumn, + createMultiTimelineColumn, + createStringColumn, + createTimelineColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useFilterState from '#hooks/useFilterState'; +import { + COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, +} from '#utils/constants'; +import { getEruEventDates } from '#utils/domain/eru'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type DeploymentsEruTypeEnum = components<'read'>['schemas']['DeploymentsEruTypeEnum']; + +type GetEruByEventResponse = GoApiResponse<'/api/v2/deployed_eru_by_event/'>; +type EruByEvent = NonNullable[number]; +type EruListItem = NonNullable[number]; + +const deployedEruKeySelector = (item: EruByEvent) => item.id; + +const emergencyResponseUnitTypeKeySelector = (item: DeploymentsEruTypeEnum) => item.key; +const emergencyResponseUnitTypeLabelSelector = (item: DeploymentsEruTypeEnum) => item.value ?? '?'; + +const PAGE_SIZE = 5; + +function OngoingEruDeployments() { + const strings = useTranslation(i18n); + + const { + sortState, + page, + setPage, + limit, + offset, + filter, + rawFilter, + filtered, + setFilterField, + } = useFilterState<{type? : DeploymentsEruTypeEnum['key']}>({ + filter: {}, + pageSize: PAGE_SIZE, + }); + + const { + deployments_eru_type: eruTypes, + } = useGlobalEnums(); + + const [expandedRow, setExpandedRow] = useState(); + + const { + pending: deployedEruResponsePending, + response: deployedEruResponse, + } = useRequest({ + url: '/api/v2/deployed_eru_by_event/', + preserveResponse: true, + query: { + limit, + offset, + eru_type: isDefined(filter.type) ? filter.type : undefined, + }, + }); + + const eruEventDates = useMemo(() => { + if (isNotDefined(deployedEruResponse)) { + return undefined; + } + return getEruEventDates(deployedEruResponse.results); + }, [deployedEruResponse]); + + const timelineDateRange = useMemo(() => { + if (isNotDefined(eruEventDates)) { + return undefined; + } + if (isNotDefined(eruEventDates.timelineStartDate) + || isNotDefined(eruEventDates.timelineEndDate)) { + return undefined; + } + return { + start: eruEventDates.timelineStartDate, + end: eruEventDates.timelineEndDate, + }; + }, [eruEventDates]); + + const handleExpandClick = useCallback( + (row: EruByEvent) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const columns = useMemo( + () => ([ + createLinkColumn( + 'name', + strings.eruEmergency, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { + emergencyId: String(item.id), + }, + }), + ), + createStringColumn( + 'organisation', + strings.eruOrganisation, + () => '', + { + defaultEmptyValue: '', + columnClassName: styles.organisation, + }, + ), + createStringColumn( + 'country', + strings.eruDeploymentCountry, + () => '', + { + defaultEmptyValue: '', + columnClassName: styles.country, + }, + ), + createMultiTimelineColumn( + 'timeline', + timelineDateRange, + (item) => { + const itemDateRange = getEruEventDates([item]); + return { + startDate: itemDateRange?.appealStartDate, + endDate: itemDateRange?.appealEndDate, + highlightedStartDate: itemDateRange?.eruStartDate, + highlightedEndDate: itemDateRange?.eruEndDate, + startDateLabel: strings.ongoingEmergencyStartDate, + endDateLabel: strings.ongoingEmergencyEndDate, + highlightedStartDateLabel: strings.eruStartDate, + highlightedEndDateLabel: strings.eruEndDate, + }; + }, + { columnClassName: styles.timeline }, + ), + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + }), + ), + ]), + [ + handleExpandClick, + expandedRow, + timelineDateRange, + strings.eruEmergency, + strings.eruOrganisation, + strings.ongoingEmergencyStartDate, + strings.ongoingEmergencyEndDate, + strings.eruStartDate, + strings.eruEndDate, + strings.eruDeploymentCountry, + ], + ); + + const eruColumns = useMemo( + () => ([ + createStringColumn( + 'name', + strings.eruName, + (item) => item?.type_display, + ), + createStringColumn( + 'society_name', + strings.eruOrganisation, + (item) => item?.eru_owner_details?.national_society_country_details.society_name, + ), + createLinkColumn( + 'country', + strings.eruDeploymentCountry, + (item) => item.deployed_to.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { + countryId: item.deployed_to.id, + }, + }), + ), + createTimelineColumn( + 'timeline', + timelineDateRange, + (item) => ({ + startDate: item.start_date, + endDate: item.end_date, + }), + { columnClassName: styles.timeline }, + ), + createEmptyColumn(), + ]), + [ + timelineDateRange, + strings.eruOrganisation, + strings.eruDeploymentCountry, + strings.eruName, + ], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (datum.id !== expandedRow?.id) { + return row; + } + + const subRows = datum.active_erus; + + return ( + <> + {row} + + + ); + }, + [ + expandedRow, + eruColumns, + ], + ); + + return ( + + )} + actions={( + + {strings.eruViewAll} + + )} + filters={( + + )} + footerContent={( + <> + + + + )} + > + +
+ + + ); +} + +export default OngoingEruDeployments; diff --git a/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/styles.module.css b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/styles.module.css new file mode 100644 index 0000000000..7aabe0bc2d --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingEruDeployments/styles.module.css @@ -0,0 +1,21 @@ +.ongoing-eru-deployments { + .table { + min-height: 5rem; + .organisation { + min-width: 10rem; + } + + .country { + min-width: 10rem; + } + + .timeline { + min-width: 24rem; + } + + .sub-cell { + border-bottom: unset; + background-color: var(--go-ui-color-gray-20); + } + } +} diff --git a/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/i18n.json b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/i18n.json new file mode 100644 index 0000000000..edba9be07b --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/i18n.json @@ -0,0 +1,19 @@ +{ "namespace": "activeSurgeDeployments", + "strings": { + "rapidResponseEmergency": "Emergency / Name", + "rapidResponsePosition": "Position", + "rapidResponseDeployingOrganisation": "Deploying Organisation", + "rapidResponseName": "Name", + "rapidResponseRole": "Role", + "rapidResponseOrganisation": "Organisation", + "rapidResponseDeploymentHeading": "Ongoing Rapid Response Deployments", + "rapidResponseViewAll": "View All", + "emergencyStartDate": "Emergency Start Date", + "emergencyEndDate": "Emergency End Date", + "deploymentStartDate": "Deployment Start Date", + "deploymentEndDate": "Deployment End Date", + "emergencyTimeline": "Emergency Timeframe", + "deploymentDate": "Deployment Timeline", + "rapidResponseDeploymentCountry": "Deployed To" + } +} diff --git a/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/index.tsx b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/index.tsx new file mode 100644 index 0000000000..402d21f3bf --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/index.tsx @@ -0,0 +1,328 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + LegendItem, + Pager, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { type RowOptions } from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createEmptyColumn, + createExpandColumn, + createMultiTimelineColumn, + createStringColumn, + createTimelineColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { + COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, +} from '#utils/constants'; +import { getRapidResponseEventDates } from '#utils/domain/eru'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetRapidResponseByEvent = GoApiResponse<'/api/v2/personnel_by_event/'>; +type RapidResponseByEventItem = NonNullable[number]; +type Personnel = NonNullable[number]['personnel']>[number] & { + country_deployed_to?: NonNullable[number]['country_deployed_to']; +}; + +const rapidResponsesKeySelector = (item: RapidResponseByEventItem) => item.id; + +const PAGE_SIZE = 5; + +function OngoingRapidResponseDeployments() { + const strings = useTranslation(i18n); + + const { + sortState, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: PAGE_SIZE, + }); + + const [expandedRow, setExpandedRow] = useState(); + + const { + pending: rapidResponsePending, + response: rapidResponse, + } = useRequest({ + url: '/api/v2/personnel_by_event/', + preserveResponse: true, + query: { + limit, + offset, + }, + }); + + const rapidResponseEventDates = useMemo(() => { + if (isNotDefined(rapidResponse)) { + return undefined; + } + return getRapidResponseEventDates(rapidResponse.results); + }, [rapidResponse]); + + const timelineDateRange = useMemo(() => { + if (isNotDefined(rapidResponseEventDates)) { + return undefined; + } + if (isNotDefined(rapidResponseEventDates.timelineStartDate) + || isNotDefined(rapidResponseEventDates.timelineEndDate)) { + return undefined; + } + return { + start: rapidResponseEventDates.timelineStartDate, + end: rapidResponseEventDates.timelineEndDate, + }; + }, [rapidResponseEventDates]); + + const handleExpandClick = useCallback( + (row: RapidResponseByEventItem) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const columns = useMemo( + () => ([ + createLinkColumn( + 'emergency', + strings.rapidResponseEmergency, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { + emergencyId: String(item.id), + }, + }), + { + columnClassName: styles.name, + }, + ), + createStringColumn( + 'role', + strings.rapidResponsePosition, + () => '', + { + defaultEmptyValue: '', + columnClassName: styles.role, + }, + ), + createStringColumn( + 'organisation', + strings.rapidResponseDeployingOrganisation, + () => '', + { + columnClassName: styles.organisation, + defaultEmptyValue: '', + }, + ), + createStringColumn( + 'country', + strings.rapidResponseDeploymentCountry, + () => '', + { + defaultEmptyValue: '', + columnClassName: styles.country, + }, + ), + createMultiTimelineColumn( + 'timeline', + timelineDateRange, + (item) => { + const itemDateRange = getRapidResponseEventDates([item]); + return { + startDate: itemDateRange?.appealStartDate, + endDate: itemDateRange?.appealEndDate, + highlightedStartDate: itemDateRange?.personnelStartDate, + highlightedEndDate: itemDateRange?.personnelEndDate, + startDateLabel: strings.emergencyStartDate, + endDateLabel: strings.emergencyEndDate, + highlightedStartDateLabel: strings.deploymentStartDate, + highlightedEndDateLabel: strings.deploymentEndDate, + }; + }, + { columnClassName: styles.timeline }, + ), + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + }), + ), + ]), + [ + handleExpandClick, + expandedRow, + timelineDateRange, + strings.rapidResponseEmergency, + strings.rapidResponsePosition, + strings.rapidResponseDeployingOrganisation, + strings.rapidResponseDeploymentCountry, + strings.emergencyEndDate, + strings.emergencyStartDate, + strings.deploymentStartDate, + strings.deploymentEndDate, + ], + ); + + const personnelColumns = useMemo( + () => ([ + createStringColumn( + 'name', + strings.rapidResponseName, + (item) => item?.name, + { + columnClassName: styles.name, + }, + ), + createStringColumn( + 'role', + strings.rapidResponseRole, + (item) => item?.role, + { + columnClassName: styles.role, + }, + ), + createStringColumn( + 'country_from', + strings.rapidResponseOrganisation, + (item) => item?.country_from?.society_name, + ), + createLinkColumn( + 'country', + strings.rapidResponseDeploymentCountry, + (item) => item?.country_deployed_to?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { + countryId: item?.country_deployed_to?.id, + }, + }), + ), + createTimelineColumn( + 'timeline', + timelineDateRange, + (item) => ({ + startDate: item.start_date, + endDate: item.end_date, + }), + { columnClassName: styles.timeline }, + ), + createEmptyColumn(), + ]), + [ + timelineDateRange, + strings.rapidResponseRole, + strings.rapidResponseName, + strings.rapidResponseOrganisation, + strings.rapidResponseDeploymentCountry, + ], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (datum.id !== expandedRow?.id) { + return row; + } + + const subRows = datum.deployments?.flatMap((deployment) => ({ + ...deployment.personnel, + country_deployed_to: deployment.country_deployed_to, + })); + + return ( + <> + {row} + + + ); + }, + [ + expandedRow, + personnelColumns, + ], + ); + + return ( + + )} + actions={( + + {strings.rapidResponseViewAll} + + )} + footerContent={( + <> + + + + )} + > + +
+ + + ); +} + +export default OngoingRapidResponseDeployments; diff --git a/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/styles.module.css b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/styles.module.css new file mode 100644 index 0000000000..9f3a9a396f --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/OngoingRapidResponseDeployments/styles.module.css @@ -0,0 +1,30 @@ +.rapid-response-deployments { + .table { + min-height: 5rem; + .name { + min-width: 8rem; + } + + .role { + min-width: 12rem; + } + + .organisation { + min-width: 12rem; + } + + .country { + min-width: 8rem; + } + + .timeline { + width: 24rem; + } + + .sub-cell { + border-bottom: unset; + background-color: var(--go-ui-color-gray-20); + } + } +} + diff --git a/app/src/views/ActiveSurgeDeployments/SurgeMap/i18n.json b/app/src/views/ActiveSurgeDeployments/SurgeMap/i18n.json new file mode 100644 index 0000000000..759b09ab59 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/SurgeMap/i18n.json @@ -0,0 +1,27 @@ +{ + "namespace": "activeSurgeDeployments", + "strings": { + "deployedEru": "Deployed ERU(s)", + "eventPopoverEmpty": "No Current Deployments", + "deployedPersonnel": "Deployed Personnel", + "surgeEruOnly": "Emergency Response Unit(ERU)", + "surgePersonnelOnly": "Rapid Response Personnel(RR)", + "surgeDownloadMapTitle": "Rapid Response Deployments", + "eruAndPersonnel": "ERU & RR", + "eruLabel": "ERU", + "personnelLabel": "RR", + "explanationScalePoints": "Scale points by", + "surgeMapTitle": "Active Deployments", + "disasterTypeLabel": "Disaster Type", + "disasterTypePlaceholder": "All Disaster Types", + "surgeMechanismsLabel": "Surge Mechanism", + "surgeMechanismsPlaceholder": "All Surge Mechanisms", + "clearFilters": "Clear Filters", + "emergencyResponseUnit": "Emergency Response Unit", + "rapidResponsePersonnel": "Rapid Response Personnel", + "eruType": "ERU Type(s)", + "deployingNS": "Deploying NS", + "roleProfile": "Role Profile(s)" + + } +} diff --git a/app/src/views/SurgeOverview/SurgeMap/index.tsx b/app/src/views/ActiveSurgeDeployments/SurgeMap/index.tsx similarity index 58% rename from app/src/views/SurgeOverview/SurgeMap/index.tsx rename to app/src/views/ActiveSurgeDeployments/SurgeMap/index.tsx index 15b63f6931..2c1e0391bb 100644 --- a/app/src/views/SurgeOverview/SurgeMap/index.tsx +++ b/app/src/views/ActiveSurgeDeployments/SurgeMap/index.tsx @@ -4,31 +4,42 @@ import { useState, } from 'react'; import { + Button, Container, LegendItem, RadioInput, + ReducedListDisplay, + SelectInput, TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; -import { sumSafe } from '@ifrc-go/ui/utils'; +import { + numericIdSelector, + stringNameSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; import { _cs, isDefined, isNotDefined, listToGroupList, mapToList, + unique, } from '@togglecorp/fujs'; import { MapLayer, MapSource, } from '@togglecorp/re-map'; +import DisplayName from '#components/DisplayName'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; import GlobalMap, { type AdminZeroFeatureProperties } from '#components/domain/GlobalMap'; import Link from '#components/Link'; import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; import MapPopup from '#components/MapPopup'; import useCountryRaw from '#hooks/domain/useCountryRaw'; import useInputState from '#hooks/useInputState'; +import { MAX_PAGE_LIMIT } from '#utils/constants'; import { useRequest } from '#utils/restRequest'; import { @@ -45,12 +56,15 @@ import { import i18n from './i18n.json'; import styles from './styles.module.css'; -const now = new Date().toISOString(); - const sourceOptions: mapboxgl.GeoJSONSourceRaw = { type: 'geojson', }; +const SURGE_MECHANISM_ERU = 1; +const SURGE_MECHANISM_RR = 2; + +const now = new Date().toISOString(); + interface ClickedPoint { properties: AdminZeroFeatureProperties; lngLat: mapboxgl.LngLatLike; @@ -65,7 +79,17 @@ function SurgeMap(props: Props) { className, } = props; + const [ + disasterFilter, + setDisasterFilter, + ] = useInputState(undefined); + const [ + surgeMechanismFilter, + setSurgeMechanismFilter, + ] = useInputState(undefined); + const strings = useTranslation(i18n); + const [ clickedPointProperties, setClickedPointProperties, @@ -79,7 +103,8 @@ function SurgeMap(props: Props) { url: '/api/v2/eru/', query: { deployed_to__isnull: false, - limit: 9999, + disaster_type: disasterFilter, + limit: MAX_PAGE_LIMIT, }, }); @@ -89,12 +114,34 @@ function SurgeMap(props: Props) { url: '/api/v2/personnel/', query: { end_date__gt: now, - limit: 9999, + is_active: true, + dtype: disasterFilter, + limit: MAX_PAGE_LIMIT, }, }); + const surgeMechanisms = useMemo(() => ( + [ + { + id: SURGE_MECHANISM_ERU, + name: strings.emergencyResponseUnit, + }, + { + id: SURGE_MECHANISM_RR, + name: strings.rapidResponsePersonnel, + }, + ] + ), [strings.rapidResponsePersonnel, strings.emergencyResponseUnit]); + const countryResponse = useCountryRaw(); + const rendererParams = useCallback( + (value: { name: string }) => ({ + name: value.name, + }), + [], + ); + const [ scaleOptions, legendOptions, @@ -104,11 +151,16 @@ function SurgeMap(props: Props) { ]), [strings]); const countryGroupedErus = useMemo(() => { + if (surgeMechanismFilter === SURGE_MECHANISM_RR) { + return undefined; + } const erusWithCountry = eruResponse?.results ?.filter((eru) => isDefined(eru.deployed_to.iso3)) ?.map((eru) => ({ units: eru.units, deployedTo: eru.deployed_to, + deployingNS: eru.eru_owner.national_society_country.society_name, + eruType: eru.type_display, event: { id: eru.event?.id, name: eru.event?.name }, })) ?? []; @@ -118,9 +170,12 @@ function SurgeMap(props: Props) { (eru) => eru.deployedTo.id, ) ); - }, [eruResponse]); + }, [eruResponse, surgeMechanismFilter]); const countryGroupedPersonnel = useMemo(() => { + if (surgeMechanismFilter === SURGE_MECHANISM_ERU) { + return undefined; + } const personnelWithCountry = personnelResponse?.results ?.map((personnel) => { if (isNotDefined(personnel.deployment.country_deployed_to)) { @@ -130,6 +185,8 @@ function SurgeMap(props: Props) { return { units: 1, deployedTo: personnel.deployment.country_deployed_to, + deployingNS: personnel.country_from?.society_name, + roleProfile: personnel.role, event: { id: personnel.deployment.event_deployed_to?.id, name: personnel.deployment.event_deployed_to?.name, @@ -143,7 +200,7 @@ function SurgeMap(props: Props) { (personnel) => personnel.deployedTo?.id ?? '', ) ); - }, [personnelResponse]); + }, [personnelResponse, surgeMechanismFilter]); const countryCentroidGeoJson = useMemo( (): GeoJSON.FeatureCollection => ({ @@ -158,7 +215,7 @@ function SurgeMap(props: Props) { return undefined; } - const eruList = countryGroupedErus[country.id]; + const eruList = countryGroupedErus?.[country.id]; const personnelList = countryGroupedPersonnel?.[country.id]; if (isNotDefined(eruList) && isNotDefined(personnelList)) { return undefined; @@ -189,11 +246,17 @@ function SurgeMap(props: Props) { ? { eruDeployedEvents: mapToList( listToGroupList( - countryGroupedErus[clickedPointProperties.properties.country_id] ?? [], + countryGroupedErus?.[clickedPointProperties.properties.country_id] ?? [], (eru) => eru.event.id ?? -1, ), (eru) => ({ ...eru[0].event, + eruType: unique( + eru.map((e) => e.eruType).filter(isDefined), + ).map((eruType) => ({ name: eruType })), + deployingNS: unique( + eru.map((e) => e.deployingNS).filter(isDefined), + ).map((deployingNS) => ({ name: deployingNS })), units: sumSafe(eru.map((e) => e.units)) ?? 0, }), ), @@ -204,6 +267,12 @@ function SurgeMap(props: Props) { ), (personnel) => ({ ...personnel[0].event, + roleProfile: unique(personnel.map( + (p) => p.roleProfile, + ).filter(isDefined)).map((roleProfile) => ({ name: roleProfile })), + deployingNS: unique(personnel.map( + (p) => p.deployingNS, + ).filter(isDefined)).map((deployingNS) => ({ name: deployingNS })), units: sumSafe(personnel.map((p) => p.units)) ?? 0, }), ), @@ -228,9 +297,48 @@ function SurgeMap(props: Props) { [setClickedPointProperties], ); + const handleClearFiltersButtonClick = useCallback(() => { + setDisasterFilter(undefined); + setSurgeMechanismFilter(undefined); + }, [setDisasterFilter, setSurgeMechanismFilter]); + return ( + + +
+ +
+ + )} > ( + + )} + label={strings.eruType} + strongLabel + /> + + )} + label={strings.deployingNS} + strongLabel + /> ), )} {popupDetails?.personnelDeployedEvents?.map( (event) => ( + + )} + label={strings.roleProfile} + strongLabel + /> + + )} + label={strings.deployingNS} + strongLabel + /> ), )} diff --git a/app/src/views/SurgeOverview/SurgeMap/styles.module.css b/app/src/views/ActiveSurgeDeployments/SurgeMap/styles.module.css similarity index 66% rename from app/src/views/SurgeOverview/SurgeMap/styles.module.css rename to app/src/views/ActiveSurgeDeployments/SurgeMap/styles.module.css index dff75a21e1..0325c3fb5b 100644 --- a/app/src/views/SurgeOverview/SurgeMap/styles.module.css +++ b/app/src/views/ActiveSurgeDeployments/SurgeMap/styles.module.css @@ -1,8 +1,25 @@ .surge-map { + .clear-button { + display: flex; + flex-direction: column; + justify-content: flex-end; + } + .map-container { height: 40rem; } + .text-output { + display: flex; + flex-wrap: nowrap; + + .label { + white-space: nowrap; + word-break: keep-all; + overflow-wrap: normal; + } + } + .footer { display: flex; align-items: flex-end; @@ -27,15 +44,5 @@ .popup-content { display: flex; flex-direction: column; - gap: var(--go-ui-spacing-md); - - .popup-item { - gap: var(--go-ui-spacing-xs); - - .popup-item-detail { - display: flex; - flex-direction: column; - font-size: var(--go-ui-font-size-sm); - } - } + gap: var(--go-ui-spacing-lg); } diff --git a/app/src/views/SurgeOverview/SurgeMap/utils.ts b/app/src/views/ActiveSurgeDeployments/SurgeMap/utils.ts similarity index 88% rename from app/src/views/SurgeOverview/SurgeMap/utils.ts rename to app/src/views/ActiveSurgeDeployments/SurgeMap/utils.ts index ae27996e60..355474d011 100644 --- a/app/src/views/SurgeOverview/SurgeMap/utils.ts +++ b/app/src/views/ActiveSurgeDeployments/SurgeMap/utils.ts @@ -55,7 +55,7 @@ const circleColor: CirclePaint['circle-color'] = [ ]; const basePointPaint: CirclePaint = { - 'circle-radius': 5, + 'circle-radius': 6, 'circle-color': circleColor, 'circle-opacity': 0.8, }; @@ -74,20 +74,15 @@ const outerCirclePaintForEru: CirclePaint = { ...baseOuterCirclePaint, 'circle-radius': [ 'interpolate', - ['linear', 1], + ['linear'], ['get', 'units'], - 2, - 5, - 4, - 7, - 6, - 9, - 8, - 11, - 10, - 13, - 12, - 15, + 1, 6, + 2, 7, + 3, 8, + 5, 10, + 7, 12, + 9, 16, + 10, 22, ], }; @@ -95,21 +90,17 @@ const outerCirclePaintForPersonnel: CirclePaint = { ...baseOuterCirclePaint, 'circle-radius': [ 'interpolate', - ['linear', 1], + ['linear'], ['get', 'personnel'], - - 2, - 5, - 4, - 7, - 6, - 9, - 8, - 11, - 10, - 13, - 12, - 15, + 1, 6, + 3, 8, + 5, 10, + 8, 12, + 12, 14, + 18, 16, + 25, 18, + 35, 20, + 50, 22, ], }; diff --git a/app/src/views/ActiveSurgeDeployments/i18n.json b/app/src/views/ActiveSurgeDeployments/i18n.json new file mode 100644 index 0000000000..4ae798fdb5 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/i18n.json @@ -0,0 +1,6 @@ +{ + "namespace": "activeSurgeDeployments", + "strings": { + "activeSurgeDeploymentsPageTitle": "Active Surge Deployments" + } +} diff --git a/app/src/views/ActiveSurgeDeployments/index.tsx b/app/src/views/ActiveSurgeDeployments/index.tsx new file mode 100644 index 0000000000..af2ed3edb9 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/index.tsx @@ -0,0 +1,30 @@ +import { Container } from '@ifrc-go/ui'; + +import ActiveRapidResponseTable from './ActiveRapidResponseTable'; +import ActiveSurgeSupport from './ActiveSurgeSupport'; +import OngoingEruDeployments from './OngoingEruDeployments'; +import OngoingRapidResponseDeployments from './OngoingRapidResponseDeployments'; +import SurgeMap from './SurgeMap'; + +import styles from './styles.module.css'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + return ( + + + + + + + + ); +} + +Component.displayName = 'ActiveSurgeDeployments'; diff --git a/app/src/views/ActiveSurgeDeployments/styles.module.css b/app/src/views/ActiveSurgeDeployments/styles.module.css new file mode 100644 index 0000000000..1083a2d851 --- /dev/null +++ b/app/src/views/ActiveSurgeDeployments/styles.module.css @@ -0,0 +1,11 @@ +.active-surge-deployments { + display: flex; + flex-direction: column; + padding: var(--go-ui-spacing-2xl) 0; + + .content { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-2xl); + } +} diff --git a/app/src/views/EmergencySurge/index.tsx b/app/src/views/EmergencySurge/index.tsx index 316d42d759..e3cfeafb29 100644 --- a/app/src/views/EmergencySurge/index.tsx +++ b/app/src/views/EmergencySurge/index.tsx @@ -38,14 +38,14 @@ export function Component() { } className={styles.keyFigure} - value={deploymentResponse?.active_deployments} + value={deploymentResponse?.active_rapid_response_personnel} compactValue label={strings.emergencyActiveDeployments} /> } className={styles.keyFigure} - value={deploymentResponse?.active_erus} + value={deploymentResponse?.active_emergency_response_units} compactValue label={strings.emergencyActiveErus} /> diff --git a/app/src/views/EruReadinessForm/EruInputItem/i18n.json b/app/src/views/EruReadinessForm/EruInputItem/i18n.json new file mode 100644 index 0000000000..661579b877 --- /dev/null +++ b/app/src/views/EruReadinessForm/EruInputItem/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "eruReadinessForm", + "strings": { + "eruEquipmentReadiness": "Equipment Readiness", + "eruPeopleReadiness": "People Readiness", + "eruFundingReadiness": "Funding Readiness", + "eruComments": "Comments", + "eruLead": "Confirm that you have capacity to lead this type of ERU", + "eruSupport": "Confirm that you have capacity to support this type of ERU" + } +} diff --git a/app/src/views/EruReadinessForm/EruInputItem/index.tsx b/app/src/views/EruReadinessForm/EruInputItem/index.tsx new file mode 100644 index 0000000000..0ad94dde3e --- /dev/null +++ b/app/src/views/EruReadinessForm/EruInputItem/index.tsx @@ -0,0 +1,130 @@ +import { useMemo } from 'react'; +import { + InputSection, + RadioInput, + TextArea, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { randomString } from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { type GoApiResponse } from '#utils/restRequest'; + +import { type PartialEruItem } from '../schema'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; + +type ReadinessOption = NonNullable[number]; + +function readinessKeySelector(option: ReadinessOption) { + return option.key; +} + +function readinessLabelSelector(option: ReadinessOption) { + return option.value; +} + +const defaultCollectionValue: PartialEruItem = { + client_id: randomString(), +}; + +interface Props { + index: number; + value: PartialEruItem; + onChange: (value: SetValueArg, index: number) => void; + error: ArrayError | undefined; +} + +function EruInputItem(props: Props) { + const { + index, + value, + onChange, + error: errorFromProps, + } = props; + + const strings = useTranslation(i18n); + + const { + deployments_eru_type: eruTypeOptions, + deployments_eru_readiness_status, + } = useGlobalEnums(); + + const onFieldChange = useFormObject( + index, + onChange, + defaultCollectionValue, + ); + + const error = (value && value.client_id && errorFromProps) + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + const title = useMemo(() => ( + eruTypeOptions?.find((eruType) => eruType.key === value.type)?.value + ), [eruTypeOptions, value.type]); + + return ( + + + + +