From 8be3b41963475d5abc43bf833873c964478f80a7 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 29 Apr 2026 23:11:43 +0545 Subject: [PATCH 1/2] feat(emergency): revamp emergency pages - Add new routes and tab pages - Split emergency routes to a new file, update casing of route files - Add routes, placeholder views for OperationStrategy, ActionsSummary, Background - Add actions taken in emergency actions summary - Clean-up emergency details page - Add background section in emergency pages - Add new key figures - Add timeline view - Add lessons learned section in emergency pages - Refactor Ops. Learning - Rename KeyInsights to OpsLearningKeyInsights - Move OpsLearningKeyInsights to components/domain - Rename Sources to OpsLearningSources - Move OpsLearningSources to compnents/domain - Re-use OpsLearningKeyInsights to show lessons learned from previous operations in Emergency pages - Rename details to overview - Rename reports/documents to documents - Add cover image in page header - Add maxWidth for img elements - use new emergency endpoint --- .../{CountryRoutes.tsx => countryRoutes.tsx} | 6 +- app/src/App/routes/emergencyRoutes.tsx | 328 +++++++ app/src/App/routes/index.tsx | 213 +---- .../{RegionRoutes.tsx => regionRoutes.tsx} | 0 .../{SurgeRoutes.tsx => surgeRoutes.tsx} | 0 app/src/components/EventTimeline/index.tsx | 123 +++ .../EventTimeline/styles.module.css | 108 +++ app/src/components/Page/index.tsx | 13 + app/src/components/Page/styles.module.css | 25 + .../domain/CountryPastEventsChart}/i18n.json | 2 +- .../domain/CountryPastEventsChart}/index.tsx | 9 +- .../CountryPastEventsChart}/styles.module.css | 2 +- .../domain/CountrySeasonalCalendar/i18n.json | 13 + .../domain/CountrySeasonalCalendar/index.tsx | 270 ++++++ .../styles.module.css | 0 .../i18n.json | 6 + .../index.tsx | 86 ++ .../OperationCard/index.tsx | 2 +- .../domain/OpsLearningKeyInsights}/i18n.json | 3 +- .../domain/OpsLearningKeyInsights/index.tsx | 129 +++ .../AllExtractsModal/Extract/i18n.json | 4 +- .../AllExtractsModal/Extract/index.tsx | 2 +- .../AllExtractsModal/i18n.json | 2 +- .../AllExtractsModal/index.tsx | 0 .../OpsLearningSources}/Emergency/index.tsx | 2 +- .../domain/OpsLearningSources}/i18n.json | 4 +- .../domain/OpsLearningSources}/index.tsx | 4 +- app/src/utils/constants.ts | 4 + app/src/utils/domain/emergency.ts | 61 +- app/src/utils/domain/opsLearning.ts | 9 + app/src/utils/outletContext.ts | 3 +- app/src/views/AllAppeals/index.tsx | 9 + .../views/CountryProfileOverview/i18n.json | 10 +- .../views/CountryProfileOverview/index.tsx | 250 +----- .../CountryProfilePreviousEvents/index.tsx | 4 +- .../Emergency/TimelineProgressBar/index.tsx | 109 +++ .../TimelineProgressBar/styles.module.css | 37 + app/src/views/Emergency/i18n.json | 11 +- app/src/views/Emergency/index.tsx | 287 ++++--- .../views/EmergencyActionsSummary/i18n.json | 10 + .../views/EmergencyActionsSummary/index.tsx | 84 ++ app/src/views/EmergencyBackground/index.tsx | 61 ++ .../FieldReportStats/i18n.json | 26 - .../FieldReportStats/index.tsx | 306 ------- app/src/views/EmergencyDetails/index.tsx | 438 ---------- .../i18n.json | 2 +- .../index.tsx | 27 +- .../EmergencyOperationStrategy/i18n.json | 9 + .../EmergencyOperationStrategy/index.tsx | 254 ++++++ .../styles.module.css | 16 + .../EmergencyMap/i18n.json | 2 +- .../EmergencyMap/index.tsx | 2 +- .../i18n.json | 18 +- app/src/views/EmergencyOverview/index.tsx | 801 ++++++++++++++++++ .../OperationalLearning/KeyInsights/index.tsx | 138 --- .../OperationalLearning/Summary/index.tsx | 4 +- app/src/views/OperationalLearning/i18n.json | 1 + app/src/views/OperationalLearning/index.tsx | 31 +- app/src/views/ThreeWActivityDetail/index.tsx | 2 +- go-api | 2 +- .../components/HtmlOutput/styles.module.css | 5 + .../ui/src/components/PageContainer/index.tsx | 17 +- .../PageContainer/styles.module.css | 12 + .../ui/src/components/PageHeader/index.tsx | 3 + 64 files changed, 2885 insertions(+), 1536 deletions(-) rename app/src/App/routes/{CountryRoutes.tsx => countryRoutes.tsx} (98%) create mode 100644 app/src/App/routes/emergencyRoutes.tsx rename app/src/App/routes/{RegionRoutes.tsx => regionRoutes.tsx} (100%) rename app/src/App/routes/{SurgeRoutes.tsx => surgeRoutes.tsx} (100%) create mode 100644 app/src/components/EventTimeline/index.tsx create mode 100644 app/src/components/EventTimeline/styles.module.css rename app/src/{views/CountryProfilePreviousEvents/PastEventsChart => components/domain/CountryPastEventsChart}/i18n.json (87%) rename app/src/{views/CountryProfilePreviousEvents/PastEventsChart => components/domain/CountryPastEventsChart}/index.tsx (96%) rename app/src/{views/CountryProfilePreviousEvents/PastEventsChart => components/domain/CountryPastEventsChart}/styles.module.css (71%) create mode 100644 app/src/components/domain/CountrySeasonalCalendar/i18n.json create mode 100644 app/src/components/domain/CountrySeasonalCalendar/index.tsx rename app/src/{views/CountryProfileOverview => components/domain/CountrySeasonalCalendar}/styles.module.css (100%) create mode 100644 app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/i18n.json create mode 100644 app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/index.tsx rename app/src/{views/OperationalLearning/KeyInsights => components/domain/OpsLearningKeyInsights}/i18n.json (87%) create mode 100644 app/src/components/domain/OpsLearningKeyInsights/index.tsx rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/AllExtractsModal/Extract/i18n.json (70%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/AllExtractsModal/Extract/index.tsx (97%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/AllExtractsModal/i18n.json (84%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/AllExtractsModal/index.tsx (100%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/Emergency/index.tsx (97%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/i18n.json (76%) rename app/src/{views/OperationalLearning/Sources => components/domain/OpsLearningSources}/index.tsx (98%) create mode 100644 app/src/utils/domain/opsLearning.ts create mode 100644 app/src/views/Emergency/TimelineProgressBar/index.tsx create mode 100644 app/src/views/Emergency/TimelineProgressBar/styles.module.css create mode 100644 app/src/views/EmergencyActionsSummary/i18n.json create mode 100644 app/src/views/EmergencyActionsSummary/index.tsx create mode 100644 app/src/views/EmergencyBackground/index.tsx delete mode 100644 app/src/views/EmergencyDetails/FieldReportStats/i18n.json delete mode 100644 app/src/views/EmergencyDetails/FieldReportStats/index.tsx delete mode 100644 app/src/views/EmergencyDetails/index.tsx rename app/src/views/{EmergencyReportAndDocument => EmergencyDocuments}/i18n.json (93%) rename app/src/views/{EmergencyReportAndDocument => EmergencyDocuments}/index.tsx (96%) create mode 100644 app/src/views/EmergencyOperationStrategy/i18n.json create mode 100644 app/src/views/EmergencyOperationStrategy/index.tsx create mode 100644 app/src/views/EmergencyOperationStrategy/styles.module.css rename app/src/views/{EmergencyDetails => EmergencyOverview}/EmergencyMap/i18n.json (76%) rename app/src/views/{EmergencyDetails => EmergencyOverview}/EmergencyMap/index.tsx (98%) rename app/src/views/{EmergencyDetails => EmergencyOverview}/i18n.json (50%) create mode 100644 app/src/views/EmergencyOverview/index.tsx delete mode 100644 app/src/views/OperationalLearning/KeyInsights/index.tsx diff --git a/app/src/App/routes/CountryRoutes.tsx b/app/src/App/routes/countryRoutes.tsx similarity index 98% rename from app/src/App/routes/CountryRoutes.tsx rename to app/src/App/routes/countryRoutes.tsx index 76838c346a..fada14ab89 100644 --- a/app/src/App/routes/CountryRoutes.tsx +++ b/app/src/App/routes/countryRoutes.tsx @@ -15,7 +15,7 @@ import { customWrapRoute, rootLayout, } from './common'; -import regionRoutes from './RegionRoutes'; +import regionRoutes from './regionRoutes'; import SmartNavigate from './SmartNavigate'; type DefaultCountriesChild = 'ongoing-activities'; @@ -34,12 +34,12 @@ const countriesLayout = customWrapRoute({ }, }); -interface Props { +interface CountryNavigateProps { to?: string; } // eslint-disable-next-line react-refresh/only-export-components -function CountryNavigate(props: Props) { +function CountryNavigate(props: CountryNavigateProps) { // FIXME: this function might not be necessary anymore const { to } = props; diff --git a/app/src/App/routes/emergencyRoutes.tsx b/app/src/App/routes/emergencyRoutes.tsx new file mode 100644 index 0000000000..75dc380a46 --- /dev/null +++ b/app/src/App/routes/emergencyRoutes.tsx @@ -0,0 +1,328 @@ +import { + generatePath, + Navigate, + useParams, +} from 'react-router-dom'; +import { isTruthyString } from '@togglecorp/fujs'; + +import Auth from '../Auth'; +import { + customWrapRoute, + rootLayout, +} from './common'; +import SmartNavigate from './SmartNavigate'; + +const emergencies = customWrapRoute({ + parent: rootLayout, + path: 'emergencies', + component: { + render: () => import('#views/Emergencies'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergencies', + visibility: 'anything', + }, +}); + +type DefaultEmergenciesChild = 'overview'; +const emergenciesLayout = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/:emergencyId', + forwardPath: 'overview' satisfies DefaultEmergenciesChild, + component: { + render: () => import('#views/Emergency'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencySlug = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/slug/:slug', + component: { + render: () => import('#views/EmergencySlug'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencyFollow = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/:emergencyId/follow', + component: { + render: () => import('#views/EmergencyFollow'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Follow Emergency', + visibility: 'is-authenticated', + }, +}); + +const emergencyIndex = customWrapRoute({ + parent: emergenciesLayout, + index: true, + component: { + eagerLoad: true, + render: SmartNavigate, + props: { + to: 'overview' satisfies DefaultEmergenciesChild, + replace: true, + hashToRouteMap: { + '#details': 'details', + '#reports': 'reports', + '#activities': 'activities', + '#surge': 'surge', + }, + // TODO: make this typesafe + forwardUnmatchedHashTo: 'additional-info', + }, + }, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencyOverview = customWrapRoute({ + parent: emergenciesLayout, + path: 'overview' satisfies DefaultEmergenciesChild, + component: { + render: () => import('#views/EmergencyOverview'), + props: {}, + }, + context: { + title: 'Emergency Details', + visibility: 'anything', + }, +}); + +// eslint-disable-next-line react-refresh/only-export-components +function EmergencyNavigateToOverview() { + const params = useParams<{ emergencyId: string }>(); + + const emergencyId = isTruthyString(params.emergencyId) + ? parseInt(params.emergencyId, 10) + : undefined; + + return ( + + ); +} + +// NOTE: redirect from details to overview +const emergencyDetails = customWrapRoute({ + parent: emergenciesLayout, + path: 'details', + component: { + eagerLoad: true, + render: EmergencyNavigateToOverview, + props: {}, + }, + context: { + title: 'Emergency Details', + visibility: 'anything', + }, +}); + +const emergencyDocuments = customWrapRoute({ + parent: emergenciesLayout, + path: 'documents', + component: { + render: () => import('#views/EmergencyDocuments'), + props: {}, + }, + context: { + title: 'Emergency Documents', + visibility: 'anything', + }, +}); + +// eslint-disable-next-line react-refresh/only-export-components +function EmergencyNavigateToDocuments() { + const params = useParams<{ emergencyId: string }>(); + + const emergencyId = isTruthyString(params.emergencyId) + ? parseInt(params.emergencyId, 10) + : undefined; + + return ( + + ); +} + +// NOTE: redirect from reports to documents +const emergencyReportsAndDocuments = customWrapRoute({ + parent: emergenciesLayout, + path: 'reports', + component: { + eagerLoad: true, + render: EmergencyNavigateToDocuments, + props: {}, + }, + context: { + title: 'Emergency Documents', + visibility: 'anything', + }, +}); + +const emergencyBackground = customWrapRoute({ + parent: emergenciesLayout, + path: 'background', + component: { + render: () => import('#views/EmergencyBackground'), + props: {}, + }, + context: { + title: 'Emergency Background', + visibility: 'anything', + }, +}); + +const emergencyActionsSummary = customWrapRoute({ + parent: emergenciesLayout, + path: 'actions-summary', + component: { + render: () => import('#views/EmergencyActionsSummary'), + props: {}, + }, + context: { + title: 'Emergency Actions Summary', + visibility: 'anything', + }, +}); + +const emergencyOperationStrategy = customWrapRoute({ + parent: emergenciesLayout, + path: 'operation-strategy', + component: { + render: () => import('#views/EmergencyOperationStrategy'), + props: {}, + }, + context: { + title: 'Emergency Operation Strategy', + visibility: 'anything', + }, +}); + +const emergencyActivities = customWrapRoute({ + parent: emergenciesLayout, + path: 'activities', + component: { + render: () => import('#views/EmergencyActivities'), + props: {}, + }, + context: { + title: 'Emergency Activities', + visibility: 'anything', + }, +}); +const emergencySurge = customWrapRoute({ + parent: emergenciesLayout, + path: 'surge', + component: { + render: () => import('#views/EmergencySurge'), + props: {}, + }, + context: { + title: 'Emergency Surge', + visibility: 'anything', + }, +}); + +// TODO: remove this route +const emergencyAdditionalInfoOne = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-1', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 1, + }, + }, + context: { + title: 'Emergency Additional Tab 1', + visibility: 'anything', + }, +}); +// TODO: remove this route +const emergencyAdditionalInfoTwo = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-2', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 2, + }, + }, + context: { + title: 'Emergency Additional Tab 2', + visibility: 'anything', + }, +}); +// TODO: remove this route +const emergencyAdditionalInfoThree = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-3', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 3, + }, + }, + context: { + title: 'Emergency Additional Tab 3', + visibility: 'anything', + }, +}); + +const emergencyAdditionalInfo = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info/:tabId?', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: {}, + }, + context: { + title: 'Emergency Additional Info Tab', + visibility: 'anything', + }, +}); + +export default { + emergencies, + emergencySlug, + emergencyFollow, + emergenciesLayout, + emergencyDetails, + emergencyOverview, + emergencyIndex, + emergencyReportsAndDocuments, + emergencyDocuments, + emergencyActivities, + emergencyActionsSummary, + emergencyBackground, + emergencyOperationStrategy, + emergencySurge, + emergencyAdditionalInfoOne, + emergencyAdditionalInfoTwo, + emergencyAdditionalInfoThree, + emergencyAdditionalInfo, +}; diff --git a/app/src/App/routes/index.tsx b/app/src/App/routes/index.tsx index d3cceb192e..abe267c8b6 100644 --- a/app/src/App/routes/index.tsx +++ b/app/src/App/routes/index.tsx @@ -11,10 +11,11 @@ import { customWrapRoute, rootLayout, } from './common'; -import countryRoutes from './CountryRoutes'; -import regionRoutes from './RegionRoutes'; +import countryRoutes from './countryRoutes'; +import emergencyRoutes from './emergencyRoutes'; +import regionRoutes from './regionRoutes'; import SmartNavigate from './SmartNavigate'; -import surgeRoutes from './SurgeRoutes'; +import surgeRoutes from './surgeRoutes'; const fourHundredFour = customWrapRoute({ parent: rootLayout, @@ -113,19 +114,6 @@ const home = customWrapRoute({ }, }); -const emergencies = customWrapRoute({ - parent: rootLayout, - path: 'emergencies', - component: { - render: () => import('#views/Emergencies'), - props: {}, - }, - wrapperComponent: Auth, - context: { - title: 'Emergencies', - visibility: 'anything', - }, -}); const cookiePolicy = customWrapRoute({ parent: rootLayout, path: 'cookie-policy', @@ -140,185 +128,6 @@ const cookiePolicy = customWrapRoute({ }, }); -type DefaultEmergenciesChild = 'details'; -const emergenciesLayout = customWrapRoute({ - parent: rootLayout, - path: 'emergencies/:emergencyId', - forwardPath: 'details' satisfies DefaultEmergenciesChild, - component: { - render: () => import('#views/Emergency'), - props: {}, - }, - wrapperComponent: Auth, - context: { - title: 'Emergency', - visibility: 'anything', - }, -}); - -const emergencySlug = customWrapRoute({ - parent: rootLayout, - path: 'emergencies/slug/:slug', - component: { - render: () => import('#views/EmergencySlug'), - props: {}, - }, - wrapperComponent: Auth, - context: { - title: 'Emergency', - visibility: 'anything', - }, -}); - -const emergencyFollow = customWrapRoute({ - parent: rootLayout, - path: 'emergencies/:emergencyId/follow', - component: { - render: () => import('#views/EmergencyFollow'), - props: {}, - }, - wrapperComponent: Auth, - context: { - title: 'Follow Emergency', - visibility: 'is-authenticated', - }, -}); - -const emergencyIndex = customWrapRoute({ - parent: emergenciesLayout, - index: true, - component: { - eagerLoad: true, - render: SmartNavigate, - props: { - to: 'details' satisfies DefaultEmergenciesChild, - replace: true, - hashToRouteMap: { - '#details': 'details', - '#reports': 'reports', - '#activities': 'activities', - '#surge': 'surge', - }, - // TODO: make this typesafe - forwardUnmatchedHashTo: 'additional-info', - }, - }, - context: { - title: 'Emergency', - visibility: 'anything', - }, -}); - -const emergencyDetails = customWrapRoute({ - parent: emergenciesLayout, - path: 'details' satisfies DefaultEmergenciesChild, - component: { - render: () => import('#views/EmergencyDetails'), - props: {}, - }, - context: { - title: 'Emergency Details', - visibility: 'anything', - }, -}); - -const emergencyReportsAndDocuments = customWrapRoute({ - parent: emergenciesLayout, - path: 'reports', - component: { - render: () => import('#views/EmergencyReportAndDocument'), - props: {}, - }, - context: { - title: 'Emergency Reports and Documents', - visibility: 'anything', - }, -}); - -const emergencyActivities = customWrapRoute({ - parent: emergenciesLayout, - path: 'activities', - component: { - render: () => import('#views/EmergencyActivities'), - props: {}, - }, - context: { - title: 'Emergency Activities', - visibility: 'anything', - }, -}); -const emergencySurge = customWrapRoute({ - parent: emergenciesLayout, - path: 'surge', - component: { - render: () => import('#views/EmergencySurge'), - props: {}, - }, - context: { - title: 'Emergency Surge', - visibility: 'anything', - }, -}); - -// TODO: remove this route -const emergencyAdditionalInfoOne = customWrapRoute({ - parent: emergenciesLayout, - path: 'additional-info-1', - component: { - render: () => import('#views/EmergencyAdditionalTab'), - props: { - infoPageId: 1, - }, - }, - context: { - title: 'Emergency Additional Tab 1', - visibility: 'anything', - }, -}); -// TODO: remove this route -const emergencyAdditionalInfoTwo = customWrapRoute({ - parent: emergenciesLayout, - path: 'additional-info-2', - component: { - render: () => import('#views/EmergencyAdditionalTab'), - props: { - infoPageId: 2, - }, - }, - context: { - title: 'Emergency Additional Tab 2', - visibility: 'anything', - }, -}); -// TODO: remove this route -const emergencyAdditionalInfoThree = customWrapRoute({ - parent: emergenciesLayout, - path: 'additional-info-3', - component: { - render: () => import('#views/EmergencyAdditionalTab'), - props: { - infoPageId: 3, - }, - }, - context: { - title: 'Emergency Additional Tab 3', - visibility: 'anything', - }, -}); - -const emergencyAdditionalInfo = customWrapRoute({ - parent: emergenciesLayout, - path: 'additional-info/:tabId?', - component: { - render: () => import('#views/EmergencyAdditionalTab'), - props: {}, - }, - context: { - title: 'Emergency Additional Info Tab', - visibility: 'anything', - }, -}); - type DefaultDrefDetailChild = 'dref-detail'; const drefProcessLayout = customWrapRoute({ parent: rootLayout, @@ -1455,20 +1264,7 @@ const wrappedRoutes = { recoverAccountConfirm, resendValidationEmail, home, - emergencies, cookiePolicy, - emergencySlug, - emergencyFollow, - emergenciesLayout, - emergencyDetails, - emergencyIndex, - emergencyReportsAndDocuments, - emergencyActivities, - emergencySurge, - emergencyAdditionalInfoOne, - emergencyAdditionalInfoTwo, - emergencyAdditionalInfoThree, - emergencyAdditionalInfo, preparednessLayout, preparednessGlobalSummary, preparednessGlobalPerformance, @@ -1534,6 +1330,7 @@ const wrappedRoutes = { ...regionRoutes, ...countryRoutes, ...surgeRoutes, + ...emergencyRoutes, // TODO: Remove me after implementation of DrefFinalReport for imminent oldDrefFinalReportForm, diff --git a/app/src/App/routes/RegionRoutes.tsx b/app/src/App/routes/regionRoutes.tsx similarity index 100% rename from app/src/App/routes/RegionRoutes.tsx rename to app/src/App/routes/regionRoutes.tsx diff --git a/app/src/App/routes/SurgeRoutes.tsx b/app/src/App/routes/surgeRoutes.tsx similarity index 100% rename from app/src/App/routes/SurgeRoutes.tsx rename to app/src/App/routes/surgeRoutes.tsx diff --git a/app/src/components/EventTimeline/index.tsx b/app/src/components/EventTimeline/index.tsx new file mode 100644 index 0000000000..3192986610 --- /dev/null +++ b/app/src/components/EventTimeline/index.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { + DateOutput, + ListView, + Message, +} from '@ifrc-go/ui'; +import { getNumberOfDays } from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +export interface EventTimelineItem { + key: string | number; + date: Date; + label: React.ReactNode; + isMarker?: boolean; +} + +interface Props { + events: EventTimelineItem[]; +} + +function EventTimeline(props: Props) { + const { events } = props; + + const timelineRenderEvents = useMemo(() => { + if (isNotDefined(events) || events.length === 0) { + return undefined; + } + + const now = new Date(); + const firstEvent = events[0]!; + const lastEvent = events[events.length - 1]!; + + const minDate = firstEvent.date; + const maxDate = lastEvent.date; + + const totalNumDays = getNumberOfDays(minDate, maxDate); + + const currentEvents = events.map((event) => { + const numDaysSinceStart = getNumberOfDays(minDate, event.date); + const relativePosition = (100 * numDaysSinceStart) / totalNumDays; + + return { + data: event, + relativePosition, + }; + }, []); + + if (now > minDate && now < maxDate) { + currentEvents.push({ + data: { + key: 'today', + // FIXME: use strings + label: 'Today', + date: now, + isMarker: true, + }, + relativePosition: (100 * getNumberOfDays(minDate, now)) / totalNumDays, + }); + } + + return currentEvents; + }, [events]); + + if (isNotDefined(timelineRenderEvents) || timelineRenderEvents.length === 0) { + return ( + + ); + } + + const numEvents = timelineRenderEvents.length; + + return ( +
+
+ {timelineRenderEvents.map((event, i) => { + if (event.data.isMarker) { + return ( +
+
+ {event.data.label} +
+
+ ); + } + + return ( +
+
+
+
+ + + {event.data.label} + +
+ ); + })} +
+
+
+ ); +} + +export default EventTimeline; diff --git a/app/src/components/EventTimeline/styles.module.css b/app/src/components/EventTimeline/styles.module.css new file mode 100644 index 0000000000..e64f317eee --- /dev/null +++ b/app/src/components/EventTimeline/styles.module.css @@ -0,0 +1,108 @@ +.event-timeline { + --event-height: 12rem; + --event-width: 10rem; + + position: relative; + + .events-container { + display: flex; + position: relative; + align-items: center; + margin: var(--go-ui-spacing-md); + width: calc(100% - var(--event-width)); + height: calc(2 * var(--event-height)); + isolation: isolate; + line-height: 1; + + .event { + --event-border-width: var(--go-ui-width-separator-md); + --event-active-border-width: var(--go-ui-width-separator-lg); + + display: flex; + position: absolute; + width: var(--event-width); + height: var(--event-height); + font-size: var(--go-ui-font-size-sm); + + .border { + position: absolute; + left: 0; + height: 100%; + border-inline-start: var(--event-border-width) solid var(--go-ui-color-separator); + + .highlight { + position: absolute; + left: calc(-1 * (var(--event-border-width) + var(--event-active-border-width)) / 2); + border-inline-start: var(--event-active-border-width) solid var(--go-ui-color-primary-blue); + height: 1.35rem; + } + } + + &:nth-child(2n) { + height: var(--event-height); + } + + &:nth-child(2n + 1) { + height: var(--event-height); + } + + &:nth-child(4n) { + height: calc(var(--event-height) / 2); + } + + &:nth-child(4n-1) { + height: calc(var(--event-height) / 2); + } + + &:nth-child(even) { + top: 50%; + align-items: end; + + .border { + .highlight { + bottom: 0; + } + } + } + + &:nth-child(odd) { + bottom: 50%; + align-items: start; + } + + .content { + width: 100%; + + &:hover { + border-radius: var(--go-ui-border-radius-md); + box-shadow: var(--go-ui-box-shadow-sm); + background-color: rgba(255, 255, 255, .8); + } + } + + &:has(.content:hover) { + z-index: 1; + } + } + + .marker { + position: absolute; + height: calc(2 * var(--event-height)); + border-inline-start: var(--go-ui-width-separator-sm) dashed var(--go-ui-color-separator); + + .label { + position: absolute; + top: 50%; + transform: translate(-50%, -100%); + background-color: var(--go-ui-color-foreground); + } + } + } + + .event-line { + position: absolute; + top: 50%; + border-bottom: var(--go-ui-width-separator-md) solid var(--go-ui-color-primary-blue); + width: 100%; + } +} diff --git a/app/src/components/Page/index.tsx b/app/src/components/Page/index.tsx index fb1adb4cc5..477a7a97f4 100644 --- a/app/src/components/Page/index.tsx +++ b/app/src/components/Page/index.tsx @@ -17,6 +17,7 @@ import { _cs, isDefined, isNotDefined, + isTruthyString, } from '@togglecorp/fujs'; import { type components } from '#generated/types'; @@ -44,6 +45,7 @@ interface Props { blockingContent?: React.ReactNode; contentOriginalLanguage?: TranslationModuleOriginalLanguageEnum; beforeHeaderContent?: React.ReactNode; + headerBackgroundUrl?: string; } function Page(props: Props) { @@ -64,6 +66,7 @@ function Page(props: Props) { blockingContent, contentOriginalLanguage, beforeHeaderContent, + headerBackgroundUrl, } = props; const currentLanguage = useCurrentLanguage(); @@ -120,6 +123,16 @@ function Page(props: Props) { heading={heading} description={description} info={info} + background={isTruthyString(headerBackgroundUrl) && ( +
+ +
+
+ )} /> )} {isNotDefined(blockingContent) && ( diff --git a/app/src/components/Page/styles.module.css b/app/src/components/Page/styles.module.css index 02110e41f8..39459675e5 100644 --- a/app/src/components/Page/styles.module.css +++ b/app/src/components/Page/styles.module.css @@ -12,6 +12,31 @@ .page-header { background-color: var(--go-ui-color-background); + + .header-background { + position: relative; + width: 100%; + height: 100%; + isolation: isolate; + + .header-background-image { + object-fit: cover; + object-position: top center; + width: 100%; + height: 100%; + filter: sepia(100%) blur(1px) hue-rotate(156deg); + } + + .header-background-overlay { + position: absolute; + top: 0; + left: 0; + opacity: 0.8; + background-color: color-mix(in srgb, var(--go-ui-color-primary-blue) 20%, #fff); + width: 100%; + height: 100%; + } + } } .main-section-container { diff --git a/app/src/views/CountryProfilePreviousEvents/PastEventsChart/i18n.json b/app/src/components/domain/CountryPastEventsChart/i18n.json similarity index 87% rename from app/src/views/CountryProfilePreviousEvents/PastEventsChart/i18n.json rename to app/src/components/domain/CountryPastEventsChart/i18n.json index a0813dff3c..efee5be55b 100644 --- a/app/src/views/CountryProfilePreviousEvents/PastEventsChart/i18n.json +++ b/app/src/components/domain/CountryPastEventsChart/i18n.json @@ -1,5 +1,5 @@ { - "namespace": "countryProfilePreviousEvents", + "namespace": "countryPastEventsChart", "strings": { "pastEventsChartEvents": "Past events", "pastEventsTargetedPopulation": "Targeted population", diff --git a/app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx b/app/src/components/domain/CountryPastEventsChart/index.tsx similarity index 96% rename from app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx rename to app/src/components/domain/CountryPastEventsChart/index.tsx index b280b8c157..2c12631211 100644 --- a/app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx +++ b/app/src/components/domain/CountryPastEventsChart/index.tsx @@ -46,12 +46,14 @@ function eventMetricKeySelector({ key }: { key: EventMetricKey }) { interface Props { className?: string; countryId: string | undefined; + disasterType?: number | null; } -function PastEventsChart(props: Props) { +function CountryPastEventsChart(props: Props) { const { className, countryId, + disasterType, } = props; const strings = useTranslation(i18n); @@ -131,6 +133,7 @@ function PastEventsChart(props: Props) { query: isDefined(selectedTimePeriod) ? { start_date_from: encodeDate(selectedTimePeriod.startDate), start_date_to: encodeDate(selectedTimePeriod.endDate), + dtype: disasterType ?? undefined, } : undefined, }); @@ -153,7 +156,7 @@ function PastEventsChart(props: Props) { return ( = { + 'Planting and growing': '#d8e800', + Harvest: '#cbbb73', + 'Seasonal hazard': '#f69650', + 'Lean season': '#c88d5b', + Livestock: '#fedf65', + Outbreak: '#fd3900', +}; + +const colorList = mapToList( + colorMap, + (color, label) => ({ label, color }), +); + +type DatabankResponse = GoApiResponse<'/api/v2/country/{id}/databank/'>; + +interface SeasonalCalendarEventProps { + data: DatabankResponse['acaps'][number] & { + monthIndices: { key: number; start: number; end: number; }[]; + } +} + +function SeasonalCalendarEvent(props: SeasonalCalendarEventProps) { + const { data } = props; + const strings = useTranslation(i18n); + + if (isNotDefined(data)) { + return null; + } + + const { + event_type, + } = data; + + if (isNotDefined(event_type)) { + return null; + } + + return data.monthIndices.map(({ key, start, end }) => ( +
+ {data.label} + + + + + + + )} + /> +
+ )); +} + +interface Props { + acapsEvents: DatabankResponse['acaps'] | undefined; +} + +function CountrySeasonalCalendar(props: Props) { + const { acapsEvents } = props; + const strings = useTranslation(i18n); + + // NOTE: these are keys in the data + const monthsWithOrder = [ + { month: 'January', order: 1 }, + { month: 'February', order: 2 }, + { month: 'March', order: 3 }, + { month: 'April', order: 4 }, + { month: 'May', order: 5 }, + { month: 'June', order: 6 }, + { month: 'July', order: 7 }, + { month: 'August', order: 8 }, + { month: 'September', order: 9 }, + { month: 'October', order: 10 }, + { month: 'November', order: 11 }, + { month: 'December', order: 12 }, + ]; + + const monthToOrderMap = listToMap( + monthsWithOrder, + ({ month }) => month, + ({ order }) => order, + ); + + const seasonalCalendarData = ( + acapsEvents?.map( + ({ month, event_type, ...otherProps }) => { + if (isNotDefined(month) || isNotDefined(event_type)) { + return undefined; + } + + const orderedMonth = month.toSorted( + (a, b) => compareNumber(monthToOrderMap[a], monthToOrderMap[b]), + ); + + const monthIndices = orderedMonth.map( + (monthName) => monthToOrderMap[monthName]!, + ); + + const discreteMonthIndices = splitList( + monthIndices, + (item, index): item is number => ( + index === 0 + ? false + : (item - monthIndices[index - 1]!) > 1 + ), + true, + ); + + return { + ...otherProps, + event_type, + month: orderedMonth, + monthIndices: discreteMonthIndices.map( + (continuousList, i) => ({ + key: i, + start: continuousList[0]!, + end: continuousList[continuousList.length - 1]! + 1, + }), + ).sort((a, b) => compareNumber(a.start, b.start)), + startMonth: monthToOrderMap[orderedMonth[0]!], + }; + }, + ).filter(isDefined).sort( + (a, b) => compareNumber(a.startMonth, b.startMonth), + ) + ); + + const eventTypeGroupedData = mapToList( + listToGroupList( + seasonalCalendarData, + ({ event_type }) => event_type[0]!, + ), + (list, key) => ({ + event_type: key, + events: list, + }), + ); + + return ( + + {strings.acaps} + + )} + /> + )} + footer={( + + {colorList.map( + ({ label, color }) => ( + + ), + )} + + )} + > +
+
+ {monthsWithOrder.map( + ({ month, order }) => ( +
+ {month.substring(0, 3)} +
+ ), + )} +
+ {(isNotDefined(eventTypeGroupedData) + || eventTypeGroupedData.length === 0 + ) && ( + + )} + {eventTypeGroupedData?.map( + ({ event_type, events }) => ( +
+ {events.map((event) => ( + + ))} +
+ ), + )} +
+
+ ); +} + +export default CountrySeasonalCalendar; diff --git a/app/src/views/CountryProfileOverview/styles.module.css b/app/src/components/domain/CountrySeasonalCalendar/styles.module.css similarity index 100% rename from app/src/views/CountryProfileOverview/styles.module.css rename to app/src/components/domain/CountrySeasonalCalendar/styles.module.css diff --git a/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/i18n.json b/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/i18n.json new file mode 100644 index 0000000000..a9dfbf4f34 --- /dev/null +++ b/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/i18n.json @@ -0,0 +1,6 @@ +{ + "namespace": "emergencyLessonsLearnedFromPreviousOperations", + "strings": { + "heading": "Lessons learned from previous operations" + } +} diff --git a/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/index.tsx b/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/index.tsx new file mode 100644 index 0000000000..e41ea81cb0 --- /dev/null +++ b/app/src/components/domain/EmergencyLessonsLearnedFromPreviousOperations/index.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { Container } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isNotDefined } from '@togglecorp/fujs'; + +import OpsLearningKeyInsights from '#components/domain/OpsLearningKeyInsights'; +import { PER_LEARNING_LESSONS_LEARNED } from '#utils/constants'; +import { + SUMMARY_NO_EXTRACT_AVAILABLE, + SUMMARY_STATUS_FAILED, + SUMMARY_STATUS_PENDING, + SUMMARY_STATUS_SUCCESS, +} from '#utils/domain/opsLearning'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type OpsLearningSummaryResponse = GoApiResponse<'/api/v2/ops-learning/summary/'>; + +interface Props { + disasterType: number; + country: number; +} + +function EmergencyLessonsLearnedFromPreviousOperations(props: Props) { + const { + disasterType, + country, + } = props; + + const strings = useTranslation(i18n); + + const [tmpResponse, setTmpResponse] = useState( + undefined, + ); + + const { + response: summaryResponse = tmpResponse, + pending: summaryPending, + } = useRequest({ + url: '/api/v2/ops-learning/summary/', + query: { + appeal_code__country: country, + appeal_code__dtype: disasterType, + type_validated: PER_LEARNING_LESSONS_LEARNED, + }, + shouldPoll: (poll) => { + const { errored, value } = poll; + + const stopPolling = errored + || value?.status === SUMMARY_STATUS_FAILED + || value?.status === SUMMARY_STATUS_SUCCESS + || value?.status === SUMMARY_NO_EXTRACT_AVAILABLE; + + if (stopPolling) { + setTmpResponse(value as OpsLearningSummaryResponse); + return -1; + } + + return 5000; + }, + preserveResponse: true, + }); + + return ( + + + + ); +} + +export default EmergencyLessonsLearnedFromPreviousOperations; diff --git a/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx index 4ae729b53d..8ef3c6a985 100644 --- a/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx +++ b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx @@ -246,7 +246,7 @@ function OperationCard(props: Props) { value={amountRequested} label={( diff --git a/app/src/views/OperationalLearning/KeyInsights/i18n.json b/app/src/components/domain/OpsLearningKeyInsights/i18n.json similarity index 87% rename from app/src/views/OperationalLearning/KeyInsights/i18n.json rename to app/src/components/domain/OpsLearningKeyInsights/i18n.json index c496e74373..e2b35a593d 100644 --- a/app/src/views/OperationalLearning/KeyInsights/i18n.json +++ b/app/src/components/domain/OpsLearningKeyInsights/i18n.json @@ -1,7 +1,6 @@ { - "namespace": "operationalLearning", + "namespace": "opsLearningKeyInsights", "strings": { - "opsLearningSummariesHeading": "Summary of learning", "keyInsightsDisclaimer": "These summaries were generated using AI (GPT 4o-mini). They represent {numOfExtractsUsed} prioritised extracts out of {totalNumberOfExtracts} from the DREF and EA documents between {appealsFromDate} - {appealsToDate}. An initial automatic assessment of the quality of the summaries resulted in around 78% performance in terms of relevancy, coherence, consistency and fluency. To see the methodology behind the prioritisation {methodologyLink}.", "methodologyLinkLabel": "click here", "keyInsightsReportIssue": "Report an issue", diff --git a/app/src/components/domain/OpsLearningKeyInsights/index.tsx b/app/src/components/domain/OpsLearningKeyInsights/index.tsx new file mode 100644 index 0000000000..9df45f156d --- /dev/null +++ b/app/src/components/domain/OpsLearningKeyInsights/index.tsx @@ -0,0 +1,129 @@ +import { AlertLineIcon } from '@ifrc-go/icons'; +import { + Container, + Description, + ExpandableContainer, + ListView, + NumberOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + formatDate, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; + +import OpsLearningSources from '#components/domain/OpsLearningSources'; +import Link from '#components/Link'; +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type OpsLearningSummaryResponse = GoApiResponse<'/api/v2/ops-learning/summary/'>; + +interface Props { + opsLearningSummaryResponse: OpsLearningSummaryResponse | undefined; +} + +function OpsLearningKeyInsights(props: Props) { + const { opsLearningSummaryResponse } = props; + + const strings = useTranslation(i18n); + + return ( + + + {isDefined(opsLearningSummaryResponse?.insights1_title) && ( + + {opsLearningSummaryResponse.insights1_content} + + )} + {isDefined(opsLearningSummaryResponse?.insights2_title) && ( + + {opsLearningSummaryResponse.insights2_content} + + )} + {isDefined(opsLearningSummaryResponse?.insights3_title) && ( + + {opsLearningSummaryResponse.insights3_content} + + )} + + + {resolveToComponent(strings.keyInsightsDisclaimer, { + numOfExtractsUsed: ( + + ), + totalNumberOfExtracts: ( + + ), + appealsFromDate: formatDate( + opsLearningSummaryResponse?.earliest_appeal_date, + 'MMM-yyyy', + ), + appealsToDate: formatDate( + opsLearningSummaryResponse?.latest_appeal_date, + 'MMM-yyyy', + ), + methodologyLink: ( + + {strings.methodologyLinkLabel} + + ), + })} + + {isDefined(opsLearningSummaryResponse) && ( + } + external + withLinkIcon + colorVariant="primary" + > + {strings.keyInsightsReportIssue} + + )} + withToggleButtonOnFooter + toggleButtonLabel={[ + strings.keyInsightsSeeSources, + strings.keyInsightsCloseSources, + ]} + > + + + )} + + ); +} + +export default OpsLearningKeyInsights; diff --git a/app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/i18n.json b/app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/i18n.json similarity index 70% rename from app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/i18n.json rename to app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/i18n.json index b45cc342a6..4804034237 100644 --- a/app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/i18n.json +++ b/app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/i18n.json @@ -1,7 +1,7 @@ { - "namespace": "operationalLearning", + "namespace": "opsLearningSources", "strings": { "source": "Source", "dateOfOperation": "Date of Operation" } -} \ No newline at end of file +} diff --git a/app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/index.tsx b/app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/index.tsx similarity index 97% rename from app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/index.tsx rename to app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/index.tsx index 91808d1412..96c3cb5ba3 100644 --- a/app/src/views/OperationalLearning/Sources/AllExtractsModal/Extract/index.tsx +++ b/app/src/components/domain/OpsLearningSources/AllExtractsModal/Extract/index.tsx @@ -34,7 +34,7 @@ function Extract(props: Props) { headingLevel={5} headerDescription={( ; type EventListItem = NonNullable[number]; -// eslint-disable-next-line import/prefer-default-export +type EmergencyStage = components['schemas']['ApiEmergencyStageEnumKey']; +export const STAGE_EMERGENCY_APPEAL = 1 satisfies EmergencyStage; +export const STAGE_DREF_APPLICATION = 2 satisfies EmergencyStage; +export const STAGE_OPERATIONAL_UPDATE = 3 satisfies EmergencyStage; +export const STAGE_FINAL_REPORT = 4 satisfies EmergencyStage; +export const STAGE_FIELD_REPORT = 5 satisfies EmergencyStage; +export const STAGE_DREF_APPEAL_ONLY = 6 satisfies EmergencyStage; + export function getNumAffected(event: EventListItem) { const latestFieldReport = max( event.field_reports, @@ -18,3 +31,47 @@ export function getNumAffected(event: EventListItem) { latestFieldReport?.num_affected, ]); } + +type EmergencyDetail = GoApiResponse<'/api/v2/emergency/{id}/'>; + +export function getEmergencyMeta(emergency: EmergencyDetail | undefined) { + if (isNotDefined(emergency)) { + return undefined; + } + + const { + disaster_start_date, + appeal, + dref, + stage, + } = emergency; + + const amountRequested = stage === STAGE_DREF_APPLICATION + && dref.type_of_dref === DREF_TYPE_IMMINENT + ? dref?.total_cost + : dref?.amount_requested ?? appeal?.amount_requested ?? dref?.total_cost; + + const drefPlannedBudget = sumSafe( + dref?.planned_interventions?.map(({ budget }) => budget).filter(isDefined), + ) ?? dref?.total_cost; + + const amountFunded = drefPlannedBudget ?? appeal?.amount_funded; + + const startDate = dref?.final_report_details?.operation_start_date + ?? dref?.operational_update_details?.new_operational_start_date + ?? dref?.date_of_approval + ?? appeal?.start_date + ?? disaster_start_date; + + const endDate = dref?.final_report_details?.operation_end_date + ?? dref?.operational_update_details?.new_operational_end_date + ?? dref?.end_date + ?? appeal?.end_date; + + return { + startDate, + endDate, + amountFunded, + amountRequested, + }; +} diff --git a/app/src/utils/domain/opsLearning.ts b/app/src/utils/domain/opsLearning.ts new file mode 100644 index 0000000000..79ce1c7eed --- /dev/null +++ b/app/src/utils/domain/opsLearning.ts @@ -0,0 +1,9 @@ +import { type components } from '#generated/types'; + +type SummaryStatusEnum = components<'read'>['schemas']['OpsLearningSummaryStatusEnum']; + +export const SUMMARY_STATUS_PENDING = 1 satisfies SummaryStatusEnum; +export const SUMMARY_STATUS_STARTED = 2 satisfies SummaryStatusEnum; +export const SUMMARY_STATUS_SUCCESS = 3 satisfies SummaryStatusEnum; +export const SUMMARY_NO_EXTRACT_AVAILABLE = 4 satisfies SummaryStatusEnum; +export const SUMMARY_STATUS_FAILED = 5 satisfies SummaryStatusEnum; diff --git a/app/src/utils/outletContext.ts b/app/src/utils/outletContext.ts index 7a0fe8c1e3..bb2917822b 100644 --- a/app/src/utils/outletContext.ts +++ b/app/src/utils/outletContext.ts @@ -2,7 +2,7 @@ import type { GoApiResponse } from '#utils/restRequest'; // FIXME: move this to context -type EmergencyResponse = GoApiResponse<'/api/v2/event/{id}/'>; +type EmergencyResponse = GoApiResponse<'/api/v2/emergency/{id}/'>; type EmergencySnippetsResponse = GoApiResponse<'/api/v2/event_snippet/'>; type Snippets = EmergencySnippetsResponse['results']; @@ -17,6 +17,7 @@ interface EmergencyAdditionalTabs { export interface EmergencyOutletContext { emergencyResponse: EmergencyResponse | undefined; + emergencyResponsePending: boolean; emergencyAdditionalTabs: EmergencyAdditionalTabs[] | undefined; } diff --git a/app/src/views/AllAppeals/index.tsx b/app/src/views/AllAppeals/index.tsx index 14932371bc..a87796d4bb 100644 --- a/app/src/views/AllAppeals/index.tsx +++ b/app/src/views/AllAppeals/index.tsx @@ -137,6 +137,12 @@ export function Component() { (country) => country, ); + const [filterHasEvent] = useUrlSearchState( + 'has_event', + (value) => value?.toLowerCase() === 'true', + (value) => (value ? String(value) : undefined), + ); + const defaultOrdering = '-start_date'; const orderingWithFallback = useMemo(() => { if (isNotDefined(ordering)) { @@ -166,8 +172,11 @@ export function Component() { region: isDefined(filterRegion) ? [filterRegion] : undefined, start_date__gte: filter.startDateAfter, start_date__lte: filter.startDateBefore, + has_event: filterHasEvent, + needs_confirmation: filterHasEvent ? false : undefined, }), [ + filterHasEvent, limit, offset, orderingWithFallback, diff --git a/app/src/views/CountryProfileOverview/i18n.json b/app/src/views/CountryProfileOverview/i18n.json index fb5a8f16e3..d8ddcee23a 100644 --- a/app/src/views/CountryProfileOverview/i18n.json +++ b/app/src/views/CountryProfileOverview/i18n.json @@ -14,14 +14,6 @@ "sources": "Sources", "dataBank": "The World Bank", "unicef": "UNICEF", - "hdr": "HDR", - "seasonalCalendarHeading": "Seasonal Calendar", - "seasonalCalendarTooltipEventLabel": "Event", - "seasonalCalendarTooltipEventTypeLabel": "Event type", - "seasonalCalendarTooltipMonthsLabel": "Months", - "seasonalCalendarTooltipSourceLabel": "Source", - "seasonalCalenderDataNotAvailable": "Data is not available!", - "source": "Source", - "acaps": "ACAPS" + "hdr": "HDR" } } diff --git a/app/src/views/CountryProfileOverview/index.tsx b/app/src/views/CountryProfileOverview/index.tsx index 211e614b7c..da149f0923 100644 --- a/app/src/views/CountryProfileOverview/index.tsx +++ b/app/src/views/CountryProfileOverview/index.tsx @@ -1,119 +1,29 @@ import { useOutletContext } from 'react-router-dom'; import { Container, - LegendItem, ListView, - Message, TextOutput, - Tooltip, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; import { getPercentage, joinList, - splitList, } from '@ifrc-go/ui/utils'; import { - compareNumber, isDefined, isNotDefined, - listToGroupList, - listToMap, - mapToList, } from '@togglecorp/fujs'; +import CountrySeasonalCalendar from '#components/domain/CountrySeasonalCalendar'; import Link from '#components/Link'; import TabPage from '#components/TabPage'; import { type CountryOutletContext } from '#utils/outletContext'; -import { - type GoApiResponse, - useRequest, -} from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; import ClimateChart from './ClimateChart'; import PopulationMap from './PopulationMap'; import i18n from './i18n.json'; -import styles from './styles.module.css'; - -// NOTE: these labels should not be translated -const colorMap: Record = { - 'Planting and growing': '#d8e800', - Harvest: '#cbbb73', - 'Seasonal hazard': '#f69650', - 'Lean season': '#c88d5b', - Livestock: '#fedf65', - Outbreak: '#fd3900', -}; - -const colorList = mapToList( - colorMap, - (color, label) => ({ label, color }), -); - -interface SeasonalCalendarEventProps { - data: GoApiResponse<'/api/v2/country/{id}/databank/'>['acaps'][number] & { - monthIndices: { key: number; start: number; end: number; }[]; - } -} - -function SeasonalCalendarEvent(props: SeasonalCalendarEventProps) { - const { data } = props; - const strings = useTranslation(i18n); - - if (isNotDefined(data)) { - return null; - } - - const { - event_type, - } = data; - - if (isNotDefined(event_type)) { - return null; - } - - return data.monthIndices.map(({ key, start, end }) => ( -
- {data.label} - - - - - - - )} - /> -
- )); -} /** @knipignore */ // eslint-disable-next-line import/prefer-default-export @@ -139,85 +49,6 @@ export function Component() { databankResponse?.world_bank_population, ); - // NOTE: these are keys in the data - const monthsWithOrder = [ - { month: 'January', order: 1 }, - { month: 'February', order: 2 }, - { month: 'March', order: 3 }, - { month: 'April', order: 4 }, - { month: 'May', order: 5 }, - { month: 'June', order: 6 }, - { month: 'July', order: 7 }, - { month: 'August', order: 8 }, - { month: 'September', order: 9 }, - { month: 'October', order: 10 }, - { month: 'November', order: 11 }, - { month: 'December', order: 12 }, - ]; - - const monthToOrderMap = listToMap( - monthsWithOrder, - ({ month }) => month, - ({ order }) => order, - ); - - // FIXME(frozenhelium): useMemo removed for React Compiler compatibility - const seasonalCalendarData = ( - databankResponse?.acaps?.map( - ({ month, event_type, ...otherProps }) => { - if (isNotDefined(month) || isNotDefined(event_type)) { - return undefined; - } - - // FIXME: this sort will mutate the data - const orderedMonth = month.sort( - (a, b) => compareNumber(monthToOrderMap[a], monthToOrderMap[b]), - ); - - const monthIndices = orderedMonth.map( - (monthName) => monthToOrderMap[monthName]!, - ); - - const discreteMonthIndices = splitList( - monthIndices, - (item, index): item is number => ( - index === 0 - ? false - : (item - monthIndices[index - 1]!) > 1 - ), - true, - ); - - return { - ...otherProps, - event_type, - month: orderedMonth, - monthIndices: discreteMonthIndices.map( - (continuousList, i) => ({ - key: i, - start: continuousList[0]!, - end: continuousList[continuousList.length - 1]! + 1, - }), - ).sort((a, b) => compareNumber(a.start, b.start)), - startMonth: monthToOrderMap[orderedMonth[0]!], - }; - }, - ).filter(isDefined).sort( - (a, b) => compareNumber(a.startMonth, b.startMonth), - ) - ); - - const eventTypeGroupedData = mapToList( - listToGroupList( - seasonalCalendarData, - ({ event_type }) => event_type[0]!, - ), - (list, key) => ({ - event_type: key, - events: list, - }), - ); - return ( {isDefined(databankResponse) && ( {isDefined(databankResponse) && isDefined(databankResponse.acaps) && ( - - {strings.acaps} - - )} - /> - )} - footer={( - - {colorList.map( - ({ label, color }) => ( - - ), - )} - - )} - > -
-
- {monthsWithOrder.map( - ({ month, order }) => ( -
- {month.substring(0, 3)} -
- ), - )} -
- {(isNotDefined(eventTypeGroupedData) - || eventTypeGroupedData.length === 0 - ) && ( - - )} - {eventTypeGroupedData?.map( - ({ event_type, events }) => ( -
- {events.map((event) => ( - - ))} -
- ), - )} -
-
+ )}
); diff --git a/app/src/views/CountryProfilePreviousEvents/index.tsx b/app/src/views/CountryProfilePreviousEvents/index.tsx index a59656bbc2..fd580271d2 100644 --- a/app/src/views/CountryProfilePreviousEvents/index.tsx +++ b/app/src/views/CountryProfilePreviousEvents/index.tsx @@ -19,6 +19,7 @@ import { } from '@togglecorp/fujs'; import AppealsTable from '#components/domain/AppealsTable'; +import CountryPastEventsChart from '#components/domain/CountryPastEventsChart'; import TabPage from '#components/TabPage'; import { type CountryOutletContext } from '#utils/outletContext'; import { @@ -28,7 +29,6 @@ import { import CountryHistoricalKeyFigures from './CountryHistoricalKeyFigures'; import EmergenciesOverMonth from './EmergenciesOverMonth'; -import PastEventsChart from './PastEventsChart'; import i18n from './i18n.json'; @@ -203,7 +203,7 @@ export function Component() {
- {isDefined(countryId) && ( diff --git a/app/src/views/Emergency/TimelineProgressBar/index.tsx b/app/src/views/Emergency/TimelineProgressBar/index.tsx new file mode 100644 index 0000000000..b2954da403 --- /dev/null +++ b/app/src/views/Emergency/TimelineProgressBar/index.tsx @@ -0,0 +1,109 @@ +import { useMemo } from 'react'; +import { + ListView, + TextOutput, +} from '@ifrc-go/ui'; +import { + type DateLike, + getNumberOfDays, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +interface Props { + startDate: DateLike | undefined | null; + endDate: DateLike | undefined | null; +} + +function TimelineProgressBar(props: Props) { + const { + startDate, + endDate, + } = props; + + const start = useMemo(() => ( + isDefined(startDate) ? new Date(startDate) : undefined + ), [startDate]); + const end = useMemo(() => ( + isDefined(endDate) ? new Date(endDate) : undefined + ), [endDate]); + const today = useMemo(() => new Date(), []); + + const progress = useMemo(() => { + if (isNotDefined(start) || isNotDefined(end)) { + return 0; + } + + if (today <= start) { + return 0; + } + + if (today >= end) { + return 100; + } + + const total = getNumberOfDays(start, end); + const numDaysSinceStart = getNumberOfDays(start, today); + return (100 * numDaysSinceStart) / total; + }, [end, start, today]); + + if (isNotDefined(start) || isNotDefined(end)) { + return null; + } + + return ( + +
+ +
+
+
+
+ + + + + +
+ + ); +} + +export default TimelineProgressBar; diff --git a/app/src/views/Emergency/TimelineProgressBar/styles.module.css b/app/src/views/Emergency/TimelineProgressBar/styles.module.css new file mode 100644 index 0000000000..fbc6123ca2 --- /dev/null +++ b/app/src/views/Emergency/TimelineProgressBar/styles.module.css @@ -0,0 +1,37 @@ +.timeline-progress-bar { + .end-border, + .start-border { + align-self: stretch; + border-left: var(--go-ui-width-separator-thin) dashed var(--go-ui-color-gray-40); + } + + .progress-section { + flex-grow: 1; + + .bar-track { + position: relative; + background-color: var(--go-ui-color-gray-30); + height: 0.25rem; + + .progress { + background-color: var(--go-ui-color-primary-red); + height: 100%; + } + + .thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + aspect-ratio: 1; + border: var(--go-ui-width-separator-md) solid var(--go-ui-color-primary-red); + border-radius: 50%; + background-color: var(--go-ui-color-foreground); + width: 1rem; + } + } + } + + .end-label { + text-align: end; + } +} diff --git a/app/src/views/Emergency/i18n.json b/app/src/views/Emergency/i18n.json index 0e4d37c8c1..98047ef64f 100644 --- a/app/src/views/Emergency/i18n.json +++ b/app/src/views/Emergency/i18n.json @@ -3,13 +3,16 @@ "strings": { "emergencyPageTitle": "IFRC GO - Emergency - {emergencyName}", "emergencyPageTitleFallback": "IFRC GO - Emergency", - "emergencyTabDetails":"Emergency Details", - "emergencyTabReports":"Reports/Documents", + "emergencyTabDetails":"Emergency Overview", + "emergencyTabActionsSummary": "Actions Summary", + "emergencyTabOperationStrategy": "Operation Strategy", + "emergencyTabBackground": "Background", + "emergencyTabReports":"Documents", "emergencyTabActivities":"Activities", "emergencyTabSurge":"Surge", - "emergencyPeopleTargetedLabel":"People Targeted", + "emergencyPeopleTargetedLabel":"Targeted Population", "emergencyFundingRequirementsLabel":"Funding Requirements (CHF)", - "emergencyFundingLabel":"Funding (CHF)", + "operationTimelineLabel": "Operation Timeline", "home": "Home", "emergencies": "Emergencies", "emergencyEdit": "Edit Event", diff --git a/app/src/views/Emergency/index.tsx b/app/src/views/Emergency/index.tsx index 8be8a851fa..fba616a4c5 100644 --- a/app/src/views/Emergency/index.tsx +++ b/app/src/views/Emergency/index.tsx @@ -6,24 +6,20 @@ import { Outlet, useParams, } from 'react-router-dom'; -import { - FundingCoverageIcon, - FundingIcon, - PencilFillIcon, - TargetedPopulationIcon, -} from '@ifrc-go/icons'; +import { PencilFillIcon } from '@ifrc-go/icons'; import { Breadcrumbs, Button, - KeyFigureView, + Container, + KeyFigure, + Label, ListView, + Message, NavigationTabList, + ProgressBar, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; -import { - resolveToString, - sumSafe, -} from '@ifrc-go/ui/utils'; +import { resolveToString } from '@ifrc-go/ui/utils'; import { isDefined, isNotDefined, @@ -39,6 +35,15 @@ import useAuth from '#hooks/domain/useAuth'; import usePermissions from '#hooks/domain/usePermissions'; import useRegion from '#hooks/domain/useRegion'; import useUserMe from '#hooks/domain/useUserMe'; +import { DREF_TYPE_IMMINENT } from '#utils/constants'; +import { + getEmergencyMeta, + STAGE_DREF_APPLICATION, + STAGE_EMERGENCY_APPEAL, + STAGE_FIELD_REPORT, + STAGE_FINAL_REPORT, + STAGE_OPERATIONAL_UPDATE, +} from '#utils/domain/emergency'; import { type EmergencyOutletContext } from '#utils/outletContext'; import { resolveUrl } from '#utils/resolveUrl'; import { @@ -46,6 +51,8 @@ import { useRequest, } from '#utils/restRequest'; +import TimelineProgressBar from './TimelineProgressBar'; + import i18n from './i18n.json'; /* @@ -63,16 +70,18 @@ export function Component() { const { response: emergencyResponse, + error: emergencyResponseError, pending: emergencyPending, } = useRequest({ // FIXME: need to check if emergencyId can be '' skip: isNotDefined(emergencyId), - url: '/api/v2/event/{id}/', + url: '/api/v2/emergency/{id}/', pathVariables: { id: Number(emergencyId), }, }); + // FIXME: this can be moved to EmergencySnippet component const { response: emergencySnippetResponse, pending: emergencySnippetPending, @@ -85,19 +94,6 @@ export function Component() { }, }); - // FIXME: show surge tab for the emergency if there is surge alerts to it - // This could be done by adding surge alert count to the emergency instance API in future - const { - response: surgeAlertsResponse, - } = useRequest({ - url: '/api/v2/surge_alert/', - preserveResponse: true, - query: { - limit: 5, - event: Number(emergencyId), - }, - }); - const { pending: addSubscriptionPending, trigger: triggerAddSubscription, @@ -148,23 +144,6 @@ export function Component() { const country = emergencyResponse?.countries[0]; const region = useRegion({ id: Number(country?.region) }); - const peopleTargeted = sumSafe( - emergencyResponse?.appeals.map( - (appeal) => appeal.num_beneficiaries, - ), - ); - const fundingRequirements = sumSafe( - emergencyResponse?.appeals.map( - (appeal) => appeal.amount_requested, - ), - ); - - const funding = sumSafe( - emergencyResponse?.appeals.map( - (appeal) => appeal.amount_funded, - ), - ); - const emergencyAdditionalTabs = useMemo(() => { if ( isNotDefined(emergencyResponse) @@ -213,22 +192,66 @@ export function Component() { ].filter((tabInfo) => tabInfo.snippets.length > 0); }, [emergencyResponse, emergencySnippetResponse]); + const showSurgeTab = (emergencyResponse?.surge_alerts_count ?? 0) > 0 + || (emergencyResponse?.active_deployments_count ?? 0) > 0; + + const pageTitle = (isDefined(emergencyResponse) && isDefined(emergencyResponse.name)) + ? resolveToString( + strings.emergencyPageTitle, + { emergencyName: emergencyResponse.name }, + ) : strings.emergencyPageTitleFallback; + + /* + const isDref = emergencyResponse?.stage === STAGE_FINAL_REPORT + || emergencyResponse?.stage === STAGE_OPERATIONAL_UPDATE + || emergencyResponse?.stage === STAGE_DREF_APPLICATION; + */ + + /* + const isImminentDref = emergencyResponse?.stage === STAGE_DREF_APPLICATION + && emergencyResponse.dref.type_of_dref === DREF_TYPE_IMMINENT; + */ + + const meta = useMemo(() => ( + getEmergencyMeta(emergencyResponse) + ), [emergencyResponse]); + const outletContext = useMemo( () => ({ emergencyResponse, emergencyAdditionalTabs, + emergencyResponsePending: emergencyPending, }), - [emergencyResponse, emergencyAdditionalTabs], + [ + emergencyResponse, + emergencyAdditionalTabs, + emergencyPending, + ], ); - const showSurgeTab = (surgeAlertsResponse?.count ?? 0) > 0 - || (emergencyResponse?.active_deployments ?? 0) > 0; + const peopleTargeted = emergencyResponse?.appeal?.num_beneficiaries; - const pageTitle = (isDefined(emergencyResponse) && isDefined(emergencyResponse.name)) - ? resolveToString( - strings.emergencyPageTitle, - { emergencyName: emergencyResponse.name }, - ) : strings.emergencyPageTitleFallback; + const hasResponseActivity = isDefined(emergencyResponse?.response_activity_count) + && emergencyResponse.response_activity_count > 0; + + if (isDefined(emergencyResponseError)) { + // FIXME: we need to implement error display in Page itself + return ( + + + + ); + } + + // FIXME: use translations + const stageDisplay = (emergencyResponse?.stage === STAGE_DREF_APPLICATION + && emergencyResponse.dref.type_of_dref === DREF_TYPE_IMMINENT + ) ? 'Imminent DREF' : emergencyResponse?.stage_display; return ( {emergencyResponse?.name} @@ -295,72 +318,134 @@ export function Component() { )} - info={( - - {isDefined(peopleTargeted) && ( - } - value={peopleTargeted} - valueType="number" - valueOptions={{ compact: true }} - label={strings.emergencyPeopleTargetedLabel} - /> - )} - {isDefined(fundingRequirements) && ( - } - value={fundingRequirements} - valueType="number" - valueOptions={{ compact: true }} - label={strings.emergencyFundingRequirementsLabel} - /> - - )} - {isDefined(funding) && ( - } - value={funding} - valueType="number" - valueOptions={{ compact: true }} - label={strings.emergencyFundingLabel} - /> - )} - - )} + info={isDefined(emergencyResponse?.stage) + && emergencyResponse.stage !== STAGE_FIELD_REPORT + && ( + + + + + + + + + + + + + + + + + + + + + + + )} contentOriginalLanguage={emergencyResponse?.translation_module_original_language} + headerBackgroundUrl={ + emergencyResponse?.dref?.final_report_details?.cover_image_file?.file + ?? emergencyResponse?.dref?.operational_update_details?.cover_image_file?.file + ?? emergencyResponse?.dref?.cover_image_file?.file + } > {strings.emergencyTabDetails} - - {strings.emergencyTabReports} - - {(emergencyResponse?.response_activity_count ?? 0) > 0 && ( + {emergencyResponse?.stage === STAGE_FIELD_REPORT && ( - {strings.emergencyTabActivities} + {strings.emergencyTabActionsSummary} + + )} + {(emergencyResponse?.stage === STAGE_DREF_APPLICATION + || emergencyResponse?.stage === STAGE_OPERATIONAL_UPDATE + || emergencyResponse?.stage === STAGE_FINAL_REPORT + ) && ( + + {strings.emergencyTabOperationStrategy} )} - {(showSurgeTab) && ( + {isDefined(emergencyResponse) && emergencyResponse.stage !== STAGE_FIELD_REPORT && ( + <> + + {strings.emergencyTabReports} + + {showSurgeTab && ( + + {strings.emergencyTabSurge} + + )} + + )} + {emergencyResponse?.stage === STAGE_EMERGENCY_APPEAL && hasResponseActivity && ( - {strings.emergencyTabSurge} + {strings.emergencyTabActivities} )} + + {strings.emergencyTabBackground} + {emergencyAdditionalTabs.map((tab) => ( ))} - + ); } diff --git a/app/src/views/EmergencyActionsSummary/i18n.json b/app/src/views/EmergencyActionsSummary/i18n.json new file mode 100644 index 0000000000..babf69e7c0 --- /dev/null +++ b/app/src/views/EmergencyActionsSummary/i18n.json @@ -0,0 +1,10 @@ +{ + "namespace": "emergencyActionsSummary", + "strings": { + "actionsTakenSectionTitle": "Actions Taken", + "nationalSocietyTitle": "Actions Taken by National Society", + "ifrcTitle": "Actions Taken by IFRC", + "otherRCRCActorsTitle": "Actions Taken by Other RCRC Actors", + "govTitle": "Actions Taken by Government" + } +} diff --git a/app/src/views/EmergencyActionsSummary/index.tsx b/app/src/views/EmergencyActionsSummary/index.tsx new file mode 100644 index 0000000000..19903baaa6 --- /dev/null +++ b/app/src/views/EmergencyActionsSummary/index.tsx @@ -0,0 +1,84 @@ +import { useOutletContext } from 'react-router-dom'; +import { + ButtonLayout, + Container, + Description, + ListView, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isNotDefined } from '@togglecorp/fujs'; + +import TabPage from '#components/TabPage'; +import { type OrganizationType } from '#utils/constants'; +import { type EmergencyOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { + emergencyResponse, + emergencyResponsePending, + } = useOutletContext(); + + const strings = useTranslation(i18n); + + const latestFieldReport = emergencyResponse?.field_report; + + const titleByOrganizationMap: Record = { + NTLS: strings.nationalSocietyTitle, + FDRN: strings.ifrcTitle, + PNS: strings.otherRCRCActorsTitle, + GOV: strings.govTitle, + }; + + return ( + + + + {latestFieldReport?.actions_taken?.map((actionTaken) => ( + + + + {actionTaken.actions_details?.map((action) => ( + + {action.name} + + ))} + + + {actionTaken.summary} + + + + ))} + + + + ); +} + +Component.displayName = 'EmergencyActions Summary'; diff --git a/app/src/views/EmergencyBackground/index.tsx b/app/src/views/EmergencyBackground/index.tsx new file mode 100644 index 0000000000..1fbb6b5b7a --- /dev/null +++ b/app/src/views/EmergencyBackground/index.tsx @@ -0,0 +1,61 @@ +import { useOutletContext } from 'react-router-dom'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import CountryPastEventsChart from '#components/domain/CountryPastEventsChart'; +import CountrySeasonalCalendar from '#components/domain/CountrySeasonalCalendar'; +import EmergencyLessonsLearnedFromPreviousOperations from '#components/domain/EmergencyLessonsLearnedFromPreviousOperations'; +import TabPage from '#components/TabPage'; +import { STAGE_FIELD_REPORT } from '#utils/domain/emergency'; +import { type EmergencyOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { + emergencyResponse, + emergencyResponsePending, + } = useOutletContext(); + + const countryId = emergencyResponse?.countries[0]?.id; + + const { + pending: databankResponsePending, + response: databankResponse, + // error: databankResponseError, + } = useRequest({ + url: '/api/v2/country/{id}/databank/', + skip: isNotDefined(countryId), + pathVariables: isDefined(countryId) ? { + id: Number(countryId), + } : undefined, + }); + + const country = emergencyResponse?.countries?.[0]; + + return ( + + {emergencyResponse?.stage !== STAGE_FIELD_REPORT + && isDefined(emergencyResponse) + && isDefined(emergencyResponse.dtype) + && isDefined(country) && ( + + )} + + + + ); +} + +Component.displayName = 'EmergencyBackground'; diff --git a/app/src/views/EmergencyDetails/FieldReportStats/i18n.json b/app/src/views/EmergencyDetails/FieldReportStats/i18n.json deleted file mode 100644 index cc3370a2bb..0000000000 --- a/app/src/views/EmergencyDetails/FieldReportStats/i18n.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "namespace": "emergencyDetails", - "strings": { - "potentiallyAffected": "Potentially Affected", - "highestRisk": "Highest Risk", - "affectedPopCenters": "Affected Population Centers", - "assistedByGovernment": "Number of People Assisted by Government - Early Action'", - "assistedByRCRC": "Number of People Assisted by RCRC Movement - Early Action", - "source": "Source: {link} {date}", - "cases": "Cases", - "suspectedCases": "Suspected Cases", - "probableCases": "Probable Cases", - "confirmedCases": "Confirmed Cases", - "numberOfDead": "Dead", - "epiSource": "Source", - "assisted": "Assisted", - "localStaff": "Local Staff", - "volunteers": "Volunteers", - "delegates": "Delegates", - "affected": "Affected", - "injured": "Injured", - "dead": "Dead", - "missing": "Missing", - "displaced": "Displaced" - } -} diff --git a/app/src/views/EmergencyDetails/FieldReportStats/index.tsx b/app/src/views/EmergencyDetails/FieldReportStats/index.tsx deleted file mode 100644 index 1c67a40dee..0000000000 --- a/app/src/views/EmergencyDetails/FieldReportStats/index.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { useMemo } from 'react'; -import { - DateOutput, - ListView, - TextOutput, -} from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToComponent } from '@ifrc-go/ui/utils'; - -import Link from '#components/Link'; -import { - DISASTER_TYPE_EPIDEMIC, - FIELD_REPORT_STATUS_EARLY_WARNING, - type ReportType, -} from '#utils/constants'; -import { type GoApiResponse } from '#utils/restRequest'; - -import i18n from './i18n.json'; - -type EventItem = GoApiResponse<'/api/v2/event/{id}'>; -type FieldReport = EventItem['field_reports'][number]; - -interface Props { - disasterType: EventItem['dtype']; - report: FieldReport; -} - -function FieldReportStats(props: Props) { - const { - disasterType, - report, - } = props; - - const strings = useTranslation(i18n); - - const reportLink = resolveToComponent( - strings.source, - { - link: ( - - {report.summary} - - ), - date: ( - - ), - }, - ); - - const numAffected = report.num_affected - ?? report.gov_num_affected ?? report.other_num_affected; - const numInjured = report.num_injured - ?? report.gov_num_injured ?? report.other_num_injured; - const numDead = report.num_dead - ?? report.gov_num_dead ?? report.other_num_dead; - const numMissing = report.num_missing - ?? report.gov_num_missing ?? report.other_num_missing; - const numDisplaced = report.num_displaced - ?? report.gov_num_displaced ?? report.other_num_displaced; - const numAssisted = report.num_assisted - ?? report.gov_num_assisted ?? report.other_num_assisted; - - const reportType: ReportType = useMemo(() => { - if (report.status === FIELD_REPORT_STATUS_EARLY_WARNING) { - return 'EW'; - } - - if (report.is_covid_report) { - return 'COVID'; - } - - if (disasterType === DISASTER_TYPE_EPIDEMIC) { - return 'EPI'; - } - - return 'EVT'; - }, [report, disasterType]); - - if (reportType === 'EW') { - const numPotentiallyAffected = report.num_potentially_affected - ?? report.gov_num_potentially_affected ?? report.other_num_potentially_affected; - const numHighestRisk = report.num_highest_risk - ?? report.gov_num_highest_risk ?? report.other_num_highest_risk; - const affectedPopulationCenters = report.affected_pop_centres - ?? report.gov_affected_pop_centres ?? report.other_affected_pop_centres; - - return ( - - - - - - - {reportLink} - - ); - } - - if (reportType === 'COVID') { - return ( - - - - - - - - - ); - } - - if (reportType === 'EPI') { - return ( - - - - - - - - - - - - - ); - } - - return ( - - - - - - - - - - - - ); -} - -export default FieldReportStats; diff --git a/app/src/views/EmergencyDetails/index.tsx b/app/src/views/EmergencyDetails/index.tsx deleted file mode 100644 index 56eb8eb8b6..0000000000 --- a/app/src/views/EmergencyDetails/index.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { useMemo } from 'react'; -import { useOutletContext } from 'react-router-dom'; -import { - Container, - Description, - HtmlOutput, - InfoPopup, - KeyFigureView, - ListView, - TextOutput, -} from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToString } from '@ifrc-go/ui/utils'; -import { - compareDate, - isDefined, - isNotDefined, - isTruthyString, - listToGroupList, - listToMap, -} from '@togglecorp/fujs'; - -import SeverityIndicator from '#components/domain/SeverityIndicator'; -import Link from '#components/Link'; -import TabPage from '#components/TabPage'; -import useDisasterType from '#hooks/domain/useDisasterType'; -import useGlobalEnums from '#hooks/domain/useGlobalEnums'; -import { type EmergencyOutletContext } from '#utils/outletContext'; -import { type GoApiResponse } from '#utils/restRequest'; - -import EmergencyMap from './EmergencyMap'; -import FieldReportStats from './FieldReportStats'; - -import i18n from './i18n.json'; - -type EventItem = GoApiResponse<'/api/v2/event/{id}'>; -type FieldReport = EventItem['field_reports'][number]; - -function getFieldReport( - reports: FieldReport[], - compareFunction: ( - a?: string, - b?: string, - direction?: number - ) => number, - direction?: number, -): FieldReport | undefined { - if (reports.length === 0) { - return undefined; - } - - // FIXME: use max function - return reports.reduce(( - selectedReport: FieldReport | undefined, - currentReport: FieldReport | undefined, - ) => { - if (isNotDefined(selectedReport) - || compareFunction( - currentReport?.updated_at, - selectedReport.updated_at, - direction, - ) > 0) { - return currentReport; - } - return selectedReport; - }, undefined); -} - -/** @knipignore */ -// eslint-disable-next-line import/prefer-default-export -export function Component() { - const strings = useTranslation(i18n); - const disasterTypes = useDisasterType(); - const { emergencyResponse } = useOutletContext(); - const { api_visibility_choices } = useGlobalEnums(); - - const visibilityMap = useMemo( - () => listToMap( - api_visibility_choices, - ({ key }) => key, - ({ value }) => value, - ), - [api_visibility_choices], - ); - - const hasKeyFigures = isDefined(emergencyResponse) - && emergencyResponse.key_figures.length !== 0; - - const disasterType = disasterTypes?.find( - (typeOfDisaster) => typeOfDisaster.id === emergencyResponse?.dtype, - ); - - const mdrCode = isDefined(emergencyResponse) - && isDefined(emergencyResponse?.appeals) - && emergencyResponse.appeals.length > 0 - ? emergencyResponse?.appeals[0]?.code : undefined; - - const hasFieldReports = isDefined(emergencyResponse) - && isDefined(emergencyResponse?.field_reports) - && emergencyResponse?.field_reports.length > 0; - - const firstFieldReport = hasFieldReports - ? getFieldReport(emergencyResponse.field_reports, compareDate, -1) : undefined; - const assistanceIsRequestedByNS = firstFieldReport?.ns_request_assistance; - const assistanceIsRequestedByCountry = firstFieldReport?.request_assistance; - const latestFieldReport = hasFieldReports - ? getFieldReport(emergencyResponse.field_reports, compareDate) : undefined; - - const emergencyContacts = emergencyResponse?.contacts; - - const groupedContacts = useMemo( - () => { - type Contact = Omit[number], 'event'>; - let contactsToProcess: Contact[] | undefined = emergencyContacts; - if (!contactsToProcess || contactsToProcess.length <= 0) { - contactsToProcess = latestFieldReport?.contacts; - } - const grouped = listToGroupList( - contactsToProcess?.map( - (contact) => { - if (isNotDefined(contact)) { - return undefined; - } - - const { ctype } = contact; - if (isNotDefined(ctype)) { - return undefined; - } - - return { - ...contact, - ctype, - }; - }, - ).filter(isDefined) ?? [], - (contact) => ( - contact.email.endsWith('ifrc.org') - ? 'IFRC' - : 'National Societies' - ), - ); - return grouped; - }, - [emergencyContacts, latestFieldReport], - ); - - return ( - - {hasKeyFigures && ( - - - {emergencyResponse?.key_figures.map( - (keyFigure) => ( - -
- {keyFigure.deck} -
-
- {resolveToString( - strings.sourceLabel, - { source: keyFigure.source }, - )} -
-
- )} - withShadow - /> - ), - )} - -
- )} - {isDefined(emergencyResponse) && ( - - - - {emergencyResponse.ifrc_severity_level_display} - - {emergencyResponse.ifrc_severity_level_update_date && ( - - )} - /> - )} - - )} - strongValue - /> - - - - - - - - - - )} - {isDefined(emergencyResponse) - && isDefined(emergencyResponse?.summary) - && isTruthyString(emergencyResponse.summary) - && ( - - - - )} - {isDefined(emergencyResponse) - && isDefined(emergencyResponse?.links) - && emergencyResponse.links.length > 0 - && ( - - - {emergencyResponse.links.map((link) => ( - - - {link.title} - - - {link.description} - - - ))} - - - )} - - {emergencyResponse && !emergencyResponse.hide_field_report_map && ( - - - - )} - {hasFieldReports - && isDefined(latestFieldReport) - && !emergencyResponse.hide_attached_field_reports && ( - - - - )} - - {isDefined(groupedContacts) && Object.keys(groupedContacts).length > 0 - && ( - - - {/* FIXME: lets not use Object.entries here */} - {Object.entries(groupedContacts).map(([contactGroup, contacts]) => ( - - - {contacts.map((contact) => ( - - - - {contact.title} - - - - {contact.ctype} - - {isTruthyString(contact.email) && ( - - {contact.email} - - )} - {isTruthyString(contact.phone) && ( - - {contact.phone} - - )} - - - - ))} - - - ))} - - - )} -
- ); -} - -Component.displayName = 'EmergencyDetails'; diff --git a/app/src/views/EmergencyReportAndDocument/i18n.json b/app/src/views/EmergencyDocuments/i18n.json similarity index 93% rename from app/src/views/EmergencyReportAndDocument/i18n.json rename to app/src/views/EmergencyDocuments/i18n.json index ca8bdc066f..6535b173b8 100644 --- a/app/src/views/EmergencyReportAndDocument/i18n.json +++ b/app/src/views/EmergencyDocuments/i18n.json @@ -1,5 +1,5 @@ { - "namespace": "emergencyReportAndDocument", + "namespace": "emergencyDocuments", "strings": { "featuredDocuments": "Featured Documents", "responseDocuments": "Response Documents", diff --git a/app/src/views/EmergencyReportAndDocument/index.tsx b/app/src/views/EmergencyDocuments/index.tsx similarity index 96% rename from app/src/views/EmergencyReportAndDocument/index.tsx rename to app/src/views/EmergencyDocuments/index.tsx index dac6b34195..143497276a 100644 --- a/app/src/views/EmergencyReportAndDocument/index.tsx +++ b/app/src/views/EmergencyDocuments/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, } from 'react'; -import { useOutletContext } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { DownloadFillIcon } from '@ifrc-go/icons'; import { Container, @@ -42,15 +42,17 @@ import { createRegionListColumn, createTitleColumn, } from '#utils/domain/tableHelpers'; -import { type EmergencyOutletContext } from '#utils/outletContext'; import { resolveUrl } from '#utils/resolveUrl'; -import { useRequest } from '#utils/restRequest'; -import { type GoApiResponse } from '#utils/restRequest'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; import i18n from './i18n.json'; +type EventResponse = GoApiResponse<'/api/v2/event/{id}/'>; type SituationReportType = NonNullable>['results']>[number]; -type FieldReportListItem = NonNullable>['field_reports']>[number] & { regions: Region[] }; +type FieldReportListItem = NonNullable[number] & { regions: Region[] }; type AppealDocumentType = NonNullable>['results']>[number]; const PAGE_SIZE = 10; @@ -59,7 +61,18 @@ const PAGE_SIZE = 10; // eslint-disable-next-line import/prefer-default-export export function Component() { const strings = useTranslation(i18n); - const { emergencyResponse } = useOutletContext(); + const { emergencyId } = useParams<{ emergencyId: string }>(); + + const { + response: emergencyResponse, + } = useRequest({ + skip: isNotDefined(emergencyId), + url: '/api/v2/event/{id}/', + pathVariables: { + id: Number(emergencyId), + }, + }); + const { page: appealDocumentsPage, offset: appealDocumentsOffset, @@ -435,4 +448,4 @@ export function Component() { ); } -Component.displayName = 'EmergencyReportAndDocument'; +Component.displayName = 'EmergencyDocuments'; diff --git a/app/src/views/EmergencyOperationStrategy/i18n.json b/app/src/views/EmergencyOperationStrategy/i18n.json new file mode 100644 index 0000000000..29578371a0 --- /dev/null +++ b/app/src/views/EmergencyOperationStrategy/i18n.json @@ -0,0 +1,9 @@ +{ + "namespace": "emergencyOperationStrategy", + "strings": { + "plannedOperationHeading": "Planned Operations", + "plannedOperationBudgetLabel": "Budget(CHF)", + "plannedOperationPeopleTargetedLabel": "People Targeted", + "plannedOperationPeopleReachedLabel": "People Reached" + } +} diff --git a/app/src/views/EmergencyOperationStrategy/index.tsx b/app/src/views/EmergencyOperationStrategy/index.tsx new file mode 100644 index 0000000000..1ea0d2b4fa --- /dev/null +++ b/app/src/views/EmergencyOperationStrategy/index.tsx @@ -0,0 +1,254 @@ +import { + Fragment, + useMemo, + useState, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + ArrowDownSmallFillIcon, + ArrowUpSmallFillIcon, +} from '@ifrc-go/icons'; +import { + Button, + Container, + Description, + Label, + ListView, + NumberOutput, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { DescriptionText } from '@ifrc-go/ui/printable'; +import { + _cs, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import TabPage from '#components/TabPage'; +import { type components } from '#generated/types'; +import { DREF_TYPE_IMMINENT } from '#utils/constants'; +import { + STAGE_DREF_APPLICATION, + STAGE_FINAL_REPORT, + STAGE_OPERATIONAL_UPDATE, +} from '#utils/domain/emergency'; +import { type EmergencyOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type PlannedIntervention = components<'read'>['schemas']['PlannedIntervention']; + +interface InterventionProps { + data: PlannedIntervention; + stage: number | undefined; +} + +function Intervention(props: InterventionProps) { + const { + data: intervention, + stage, + } = props; + + const strings = useTranslation(i18n); + + const [showDetails, setShowDetails] = useState(false); + + return ( + +
+ + {intervention.title} + + + + + {stage !== STAGE_DREF_APPLICATION && ( + + )} + +
+ {showDetails && ( + + + + + {intervention.indicators?.map((indicator) => ( + + + + + ))} + + {intervention.description && ( + + + {intervention.description} + + + )} + {intervention.challenges && ( + + + {intervention.challenges} + + + )} + {intervention.narrative_description_of_achievements && ( + + + {intervention.narrative_description_of_achievements} + + + )} + + )} +
+ ); +} + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { + emergencyResponse, + emergencyResponsePending, + } = useOutletContext(); + const { + pending: sectorsPending, + response: sectorsResponse, + } = useRequest({ + url: '/api/v2/primarysector', + }); + + const sectorMap = listToMap(sectorsResponse, ({ key }) => key); + const strings = useTranslation(i18n); + + const drefDetails = useMemo(() => { + if (emergencyResponse?.stage === STAGE_FINAL_REPORT) { + return emergencyResponse.dref.final_report_details; + } + + if (emergencyResponse?.stage === STAGE_OPERATIONAL_UPDATE) { + return emergencyResponse?.dref.operational_update_details; + } + + return emergencyResponse?.dref; + }, [emergencyResponse]); + + const isImminent = emergencyResponse?.dref.type_of_dref === DREF_TYPE_IMMINENT + && emergencyResponse?.stage === STAGE_DREF_APPLICATION; + + return ( + + {isImminent && emergencyResponse?.dref.proposed_action.map((action) => ( + + {action.activities?.map((activity) => ( +
+ + + {activity.activity} + +
+ ))} +
+ ))} + {!isImminent && ( + + {drefDetails?.planned_interventions?.map((intervention) => ( + + ))} + + )} +
+ ); +} + +Component.displayName = 'EmergencyOperationStrategy'; diff --git a/app/src/views/EmergencyOperationStrategy/styles.module.css b/app/src/views/EmergencyOperationStrategy/styles.module.css new file mode 100644 index 0000000000..3499d70c3f --- /dev/null +++ b/app/src/views/EmergencyOperationStrategy/styles.module.css @@ -0,0 +1,16 @@ +.planned-operations { + .sector-icon { + height: 2rem; + aspect-ratio: 1; + } + + .operation-row { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr max-content; + align-items: center; + + &.application-stage { + grid-template-columns: 3fr 1fr 1fr max-content; + } + } +} diff --git a/app/src/views/EmergencyDetails/EmergencyMap/i18n.json b/app/src/views/EmergencyOverview/EmergencyMap/i18n.json similarity index 76% rename from app/src/views/EmergencyDetails/EmergencyMap/i18n.json rename to app/src/views/EmergencyOverview/EmergencyMap/i18n.json index 8c196222be..b03781c45c 100644 --- a/app/src/views/EmergencyDetails/EmergencyMap/i18n.json +++ b/app/src/views/EmergencyOverview/EmergencyMap/i18n.json @@ -1,5 +1,5 @@ { - "namespace": "emergencyDetails", + "namespace": "emergencyOverview", "strings": { "affectedCountry": "Affected Country", "affectedProvince": "Affected Province" diff --git a/app/src/views/EmergencyDetails/EmergencyMap/index.tsx b/app/src/views/EmergencyOverview/EmergencyMap/index.tsx similarity index 98% rename from app/src/views/EmergencyDetails/EmergencyMap/index.tsx rename to app/src/views/EmergencyOverview/EmergencyMap/index.tsx index aaf55375c9..e5e317bfc3 100644 --- a/app/src/views/EmergencyDetails/EmergencyMap/index.tsx +++ b/app/src/views/EmergencyOverview/EmergencyMap/index.tsx @@ -29,7 +29,7 @@ import { type GoApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; -type EventItem = GoApiResponse<'/api/v2/event/{id}'>; +type EventItem = GoApiResponse<'/api/v2/emergency/{id}/'>; interface Props { event: EventItem; diff --git a/app/src/views/EmergencyDetails/i18n.json b/app/src/views/EmergencyOverview/i18n.json similarity index 50% rename from app/src/views/EmergencyDetails/i18n.json rename to app/src/views/EmergencyOverview/i18n.json index 28b56d96be..a1599dcce3 100644 --- a/app/src/views/EmergencyDetails/i18n.json +++ b/app/src/views/EmergencyOverview/i18n.json @@ -1,8 +1,20 @@ { - "namespace": "emergencyDetails", + "namespace": "emergencyOverview", "strings": { + "keyFigureInjuredLabel": "Injured", + "keyFigureDeadLabel": "Dead", + "keyFigureMissingLabel": "Missing", + "keyFigureAffectedLabel": "Affected", + "keyFigureDisplacedLabel": "Displaced", + "keyFigurePotentiallyAffectedLabel": "Potentially Affected", + "keyFigureHighestRiskLabel": "People at Highest Risk", "emergencyKeyFiguresTitle": "Key Figures", "emergencyOverviewTitle": "Emergency Overview", + "operationalTimelineTitle": "Operational Timeline", + "overviewCountryLabel": "Country", + "overviewEmergencyAppealLabel": "Emergency Appeal", + "overviewOperationTypeLabel": "Operation Type", + "overviewDREFLabel": "DREF", "disasterCategorization": "Disaster Categorization", "disasterType": "Disaster Type", "GLIDENumber": "GLIDE Number", @@ -13,9 +25,7 @@ "assistanceRequestedByGovernment": "Government Requests International Assistance", "situationalOverviewTitle": "Situational Overview", "linksTitle": "Links", - "emergencyMapTitle": "Affected Provinces", "severityLevelUpdateDateLabel": "Last update", - "contactsTitle": "Contacts", - "sourceLabel": "Source {source}" + "contactsTitle": "Contacts" } } diff --git a/app/src/views/EmergencyOverview/index.tsx b/app/src/views/EmergencyOverview/index.tsx new file mode 100644 index 0000000000..72dea372db --- /dev/null +++ b/app/src/views/EmergencyOverview/index.tsx @@ -0,0 +1,801 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Container, + DateOutput, + Description, + HtmlOutput, + InfoPopup, + InlineLayout, + KeyFigureView, + Label, + ListView, + Message, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + compareDate, + isDefined, + isFalsyString, + isNotDefined, + isTruthyString, + listToGroupList, + listToMap, +} from '@togglecorp/fujs'; + +import EmergencyLessonsLearnedFromPreviousOperations from '#components/domain/EmergencyLessonsLearnedFromPreviousOperations'; +import SeverityIndicator from '#components/domain/SeverityIndicator'; +import EventTimeline, { type EventTimelineItem } from '#components/EventTimeline'; +import Link from '#components/Link'; +import TabPage from '#components/TabPage'; +import useDisasterType from '#hooks/domain/useDisasterType'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { + DREF_TYPE_IMMINENT, + FIELD_REPORT_STATUS_EARLY_WARNING, + FIELD_REPORT_STATUS_EVENT, +} from '#utils/constants'; +import { + STAGE_DREF_APPEAL_ONLY, + STAGE_DREF_APPLICATION, + STAGE_EMERGENCY_APPEAL, + STAGE_FIELD_REPORT, + STAGE_FINAL_REPORT, + STAGE_OPERATIONAL_UPDATE, +} from '#utils/domain/emergency'; +import { type EmergencyOutletContext } from '#utils/outletContext'; + +import EmergencyMap from './EmergencyMap'; + +import i18n from './i18n.json'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const disasterTypes = useDisasterType(); + const { + emergencyResponse, + emergencyResponsePending, + } = useOutletContext(); + const { + api_request_choices, + api_visibility_choices, + } = useGlobalEnums(); + + const visibilityMap = useMemo( + () => listToMap( + api_visibility_choices, + ({ key }) => key, + ({ value }) => value, + ), + [api_visibility_choices], + ); + + const requestMap = useMemo( + () => listToMap( + api_request_choices, + ({ key }) => key, + ({ value }) => value, + ), + [api_request_choices], + ); + + const disasterType = disasterTypes?.find( + (typeOfDisaster) => typeOfDisaster.id === emergencyResponse?.dtype, + ) ?? emergencyResponse?.dref.disaster_type_details; + + const mdrCode = emergencyResponse?.appeal?.code + ?? emergencyResponse?.dref?.appeal_code; + + const latestFieldReport = emergencyResponse?.field_report; + + const latestAppeal = emergencyResponse?.appeal; + const dref = emergencyResponse?.dref; + const drefOpsUpdate = dref?.operational_update_details; + const drefFinalReport = dref?.final_report_details; + + const stage = emergencyResponse?.stage; + const isFieldReportStage = stage === STAGE_FIELD_REPORT; + // STAGE_DREF_APPEAL_ONLY behaves like Emergency Appeal — no embedded DREF data is available. + // FIXME: variable name should be more generic + const isEmergencyAppealStage = stage === STAGE_EMERGENCY_APPEAL + || stage === STAGE_DREF_APPEAL_ONLY; + const isDrefStage = stage === STAGE_DREF_APPLICATION + || stage === STAGE_OPERATIONAL_UPDATE + || stage === STAGE_FINAL_REPORT; + + // The new endpoint encodes the first field report's assistance flags on the + // attached field_report via `first_fr_*` fields. + const assistanceIsRequestedByNS = latestFieldReport?.first_fr_ns_request_assistance; + const assistanceIsRequestedByCountry = latestFieldReport?.first_fr_request_assistance; + + const emergencyContacts = emergencyResponse?.contacts; + + const groupedContacts = useMemo( + () => { + type Contact = Omit[number], 'event'>; + let contactsToProcess: Contact[] | undefined = emergencyContacts; + if (!contactsToProcess || contactsToProcess.length <= 0) { + contactsToProcess = latestFieldReport?.contacts; + } + const grouped = listToGroupList( + contactsToProcess?.map( + (contact) => { + if (isNotDefined(contact)) { + return undefined; + } + + const { ctype } = contact; + if (isNotDefined(ctype)) { + return undefined; + } + + return { + ...contact, + ctype, + }; + }, + ).filter(isDefined) ?? [], + (contact) => ( + // FIXME: this logic can be improved + contact.email.endsWith('ifrc.org') + ? 'IFRC' + : 'National Societies' + ), + ); + return grouped; + }, + [emergencyContacts, latestFieldReport], + ); + + const timelineEvents = useMemo( + () => { + if (isFieldReportStage) { + return undefined; + } + + const events: EventTimelineItem[] = []; + + if (dref?.type_of_dref === DREF_TYPE_IMMINENT + && isDefined(dref.date_of_approval) + ) { + events.push({ + key: `imminent-dref-start-${dref.id}`, + date: new Date(dref.date_of_approval), + label: ( + <> + + + DREF Application + + + ), + }); + } + + if (dref?.type_of_dref === DREF_TYPE_IMMINENT + && emergencyResponse?.stage === STAGE_DREF_APPLICATION + && isDefined(dref.hazard_date) + ) { + events.push({ + key: `event-forecasted-${dref.id}`, + date: new Date(dref.hazard_date), + label: ( + + ), + }); + } + + emergencyResponse?.timeline_field_reports.forEach((fr) => { + if (isDefined(fr.report_date)) { + events.push({ + key: `fr-${fr.id}`, + date: new Date(fr.report_date), + label: ( + <> + + + View report + + + ), + }); + } + }); + + if (isEmergencyAppealStage) { + if (isDefined(emergencyResponse) + && isDefined(emergencyResponse.disaster_start_date) + ) { + events.push({ + key: `ea-start-${emergencyResponse.id}`, + date: new Date(emergencyResponse.disaster_start_date), + // FIXME: use strings + label: 'Beginning of the Disaster', + }); + } + + if (isDefined(latestAppeal?.start_date)) { + events.push({ + key: `ea-start-${latestAppeal.id}`, + date: new Date(latestAppeal.start_date), + // FIXME: use strings + label: 'Start of the Operation', + }); + } + + if (isDefined(latestAppeal?.end_date)) { + events.push({ + key: `ea-end-${latestAppeal.id}`, + date: new Date(latestAppeal.end_date), + // FIXME: use strings + label: 'End of the Operation', + }); + } + } + + if (isDrefStage && isDefined(dref)) { + if (dref?.type_of_dref !== DREF_TYPE_IMMINENT && isDefined(dref?.event_date)) { + events.push({ + key: `dref-event-start-${dref.id}`, + date: new Date(dref.event_date), + // FIXME: use strings + label: ( + + ), + }); + } + + if (isDefined(dref?.date_of_approval)) { + events.push({ + key: `dref-operation-start-${dref.id}`, + date: new Date(dref.date_of_approval), + label: ( + + ), + }); + } + + dref.timeline_operational_updates.forEach((opsUpdate) => { + if (isDefined(opsUpdate.date_of_approval)) { + events.push({ + key: `dref-operation-update-${opsUpdate.id}`, + date: new Date(opsUpdate.date_of_approval), + // FIXME: use strings + label: ( + <> + + + )} + withHeaderBorder + > + + + + {opsUpdate.summary_of_change} + + + )} + /> + + ), + }); + } + }); + + const endDate = drefFinalReport?.operation_end_date + ?? drefOpsUpdate?.new_operational_end_date + ?? dref.end_date; + + if (isDefined(endDate)) { + events.push({ + key: 'end-of-dref-operations', + date: new Date(endDate), + // FIXME: use strings + label: ( + + ), + }); + } + + const lastFinalReportUpdate = drefFinalReport?.date_of_approval + ?? drefFinalReport?.modified_at; + + if (isDefined(drefFinalReport) && isDefined(lastFinalReportUpdate)) { + events.push({ + key: `final-report-${drefFinalReport.id}`, + date: new Date(lastFinalReportUpdate), + // FIXME: use strings + label: ( + <> + + + View report + + + ), + }); + } + } + + return events.toSorted((a, b) => compareDate(a.date, b.date)); + }, + [ + isFieldReportStage, + isEmergencyAppealStage, + isDrefStage, + latestAppeal, + emergencyResponse, + dref, + drefOpsUpdate, + drefFinalReport, + ], + ); + + const country = emergencyResponse?.countries?.[0]; + + const startDate = isFieldReportStage + ? latestFieldReport?.start_date + : emergencyResponse?.disaster_start_date; + + const lfrDisasterTypeName = latestFieldReport?.dtype?.name; + + const numInjured = emergencyResponse?.num_injured + ?? latestFieldReport?.num_injured + ?? latestFieldReport?.gov_num_injured + ?? latestFieldReport?.other_num_injured; + const numDead = emergencyResponse?.num_dead + ?? latestFieldReport?.num_dead + ?? latestFieldReport?.gov_num_dead + ?? latestFieldReport?.other_num_dead; + const numMissing = emergencyResponse?.num_missing + ?? latestFieldReport?.num_missing + ?? latestFieldReport?.gov_num_missing + ?? latestFieldReport?.other_num_missing; + const numAffected = emergencyResponse?.num_affected + ?? latestFieldReport?.num_affected + ?? latestFieldReport?.gov_num_affected + ?? latestFieldReport?.other_num_affected; + const numDisplaced = emergencyResponse?.num_displaced + ?? latestFieldReport?.num_displaced + ?? latestFieldReport?.gov_num_displaced + ?? latestFieldReport?.other_num_displaced; + const numPotentiallyAffected = latestFieldReport?.num_potentially_affected + ?? latestFieldReport?.gov_num_potentially_affected + ?? latestFieldReport?.other_num_potentially_affected; + const numHighestRisk = latestFieldReport?.num_highest_risk + ?? latestFieldReport?.gov_num_highest_risk + ?? latestFieldReport?.other_num_highest_risk; + + if (isNotDefined(emergencyResponse)) { + return ( + + ); + } + + return ( + + {isFieldReportStage + && latestFieldReport?.status === FIELD_REPORT_STATUS_EARLY_WARNING && ( + + + + + + + )} + {((isFieldReportStage && latestFieldReport?.status === FIELD_REPORT_STATUS_EVENT) + || isEmergencyAppealStage) && ( + + + + + + + + + + )} + {!isFieldReportStage && ( + + + + + )} + /> + )} + + + + + + {!isFieldReportStage && ( + <> + + + + {emergencyResponse.ifrc_severity_level_display} + {emergencyResponse.ifrc_severity_level_update_date && ( + + )} + /> + )} + + )} + strongValue + /> + + )} + + + + {isFieldReportStage && ( + <> + + + + )} + + + {!isDrefStage && ( + + + + + )} + + + + {isDefined(timelineEvents) && timelineEvents.length > 0 && ( + + + + )} + + {/* FIXME(frozenhelium): handle condition where there is no summary */} + + {isDrefStage && ( + + {dref?.event_scope} + + )} + {!isDrefStage && ( + + )} + + + + {isFieldReportStage + && isDefined(emergencyResponse) + && isDefined(emergencyResponse.dtype) + && isDefined(country) && ( + + )} + {isDefined(emergencyResponse?.links) + && emergencyResponse.links.length > 0 + && ( + + + {emergencyResponse.links.map((link) => ( + + + {link.title} + + + {link.description} + + + ))} + + + )} + {isDefined(groupedContacts) && Object.keys(groupedContacts).length > 0 + && ( + + + {/* FIXME: lets not use Object.entries here */} + {Object.entries(groupedContacts).map(([contactGroup, contacts]) => ( + + + {contacts.map((contact) => ( + + + + {contact.title} + + + + {contact.ctype} + + {isTruthyString(contact.email) && ( + + {contact.email} + + )} + {isTruthyString(contact.phone) && ( + + {contact.phone} + + )} + + + + ))} + + + ))} + + + )} + + ); +} + +Component.displayName = 'EmergencyOverview'; diff --git a/app/src/views/OperationalLearning/KeyInsights/index.tsx b/app/src/views/OperationalLearning/KeyInsights/index.tsx deleted file mode 100644 index 63dd20ce7e..0000000000 --- a/app/src/views/OperationalLearning/KeyInsights/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { AlertLineIcon } from '@ifrc-go/icons'; -import { - Container, - Description, - ExpandableContainer, - ListView, - NumberOutput, -} from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; -import { - formatDate, - resolveToComponent, -} from '@ifrc-go/ui/utils'; -import { isDefined } from '@togglecorp/fujs'; - -import Link from '#components/Link'; -import { type GoApiResponse } from '#utils/restRequest'; - -import Sources from '../Sources'; - -import i18n from './i18n.json'; - -type OpsLearningSummaryResponse = GoApiResponse<'/api/v2/ops-learning/summary/'>; - -interface Props { - opsLearningSummaryResponse: OpsLearningSummaryResponse; -} - -function KeyInsights(props: Props) { - const { - opsLearningSummaryResponse, - } = props; - - const strings = useTranslation(i18n); - - return ( - - - - {isDefined(opsLearningSummaryResponse?.insights1_title) && ( - - {opsLearningSummaryResponse.insights1_content} - - )} - {isDefined(opsLearningSummaryResponse?.insights2_title) && ( - - {opsLearningSummaryResponse.insights2_content} - - )} - {isDefined(opsLearningSummaryResponse?.insights3_title) && ( - - {opsLearningSummaryResponse.insights3_content} - - )} - - - {resolveToComponent(strings.keyInsightsDisclaimer, { - numOfExtractsUsed: ( - - ), - totalNumberOfExtracts: ( - - ), - appealsFromDate: formatDate( - opsLearningSummaryResponse.earliest_appeal_date, - 'MMM-yyyy', - ), - appealsToDate: formatDate( - opsLearningSummaryResponse.latest_appeal_date, - 'MMM-yyyy', - ), - methodologyLink: ( - - {strings.methodologyLinkLabel} - - ), - })} - - } - external - withLinkIcon - colorVariant="primary" - > - {strings.keyInsightsReportIssue} - - )} - withToggleButtonOnFooter - toggleButtonLabel={[ - strings.keyInsightsSeeSources, - strings.keyInsightsCloseSources, - ]} - > - - - - - ); -} - -export default KeyInsights; diff --git a/app/src/views/OperationalLearning/Summary/index.tsx b/app/src/views/OperationalLearning/Summary/index.tsx index dca001f23d..3dbb75f17a 100644 --- a/app/src/views/OperationalLearning/Summary/index.tsx +++ b/app/src/views/OperationalLearning/Summary/index.tsx @@ -5,7 +5,7 @@ import { import { useTranslation } from '@ifrc-go/ui/hooks'; import { resolveToString } from '@ifrc-go/ui/utils'; -import Sources from '../Sources'; +import OpsLearningSources from '#components/domain/OpsLearningSources'; import i18n from './i18n.json'; @@ -55,7 +55,7 @@ function Summary(props: Props) { toggleButtonLabel={[strings.seeSources, strings.closeSources]} spacing="lg" > - diff --git a/app/src/views/OperationalLearning/i18n.json b/app/src/views/OperationalLearning/i18n.json index 28dc7176a1..ba0d69fb70 100644 --- a/app/src/views/OperationalLearning/i18n.json +++ b/app/src/views/OperationalLearning/i18n.json @@ -5,6 +5,7 @@ "operationalLearningHeadingDescription":"Operational learning in emergencies is the lesson learned from managing and dealing with crises, refining protocols for resource allocation, decision-making, communication strategies, and others. The summaries are generated using AI and Large Language Models, based on data coming from Final DREF Reports, Emergency Appeal reports and others.", "byComponentTitle": "Component", "bySectorTitle": "Sector", + "opsLearningSummariesHeading": "Summary of learning", "pendingMessage": "Request for generating operational learning summary is currently pending. Please wait a moment.", "startedMessage": "Process of generating operational learning summary has started. This may take a few moments.", "errorMessage": "Encountered an error while attempting to generate operational learning summary. Please try again later. If the issue continues, please contact us at {link}.", diff --git a/app/src/views/OperationalLearning/index.tsx b/app/src/views/OperationalLearning/index.tsx index 8e4e866114..324b8d15b6 100644 --- a/app/src/views/OperationalLearning/index.tsx +++ b/app/src/views/OperationalLearning/index.tsx @@ -43,10 +43,10 @@ import { saveAs } from 'file-saver'; import Papa from 'papaparse'; import ExportButton from '#components/domain/ExportButton'; +import OpsLearningKeyInsights from '#components/domain/OpsLearningKeyInsights'; import { type RegionOption } from '#components/domain/RegionSelectInput'; import Link from '#components/Link'; import Page from '#components/Page'; -import { type components } from '#generated/types'; import useCountry from '#hooks/domain/useCountry'; import useDisasterTypes, { type DisasterType } from '#hooks/domain/useDisasterType'; import useGlobalEnums from '#hooks/domain/useGlobalEnums'; @@ -54,6 +54,13 @@ import useSecondarySector from '#hooks/domain/useSecondarySector'; import useAlert from '#hooks/useAlert'; import useFilterState from '#hooks/useFilterState'; import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { + SUMMARY_NO_EXTRACT_AVAILABLE, + SUMMARY_STATUS_FAILED, + SUMMARY_STATUS_PENDING, + SUMMARY_STATUS_STARTED, + SUMMARY_STATUS_SUCCESS, +} from '#utils/domain/opsLearning'; import { getFormattedComponentName } from '#utils/domain/per'; import { type GoApiResponse, @@ -65,20 +72,12 @@ import Filters, { type FilterValue, type PerLearningType, } from './Filters'; -import KeyInsights from './KeyInsights'; import Stats from './Stats'; import Summary, { type Props as SummaryProps } from './Summary'; import i18n from './i18n.json'; -type SummaryStatusEnum = components<'read'>['schemas']['OpsLearningSummaryStatusEnum']; const opsLearningDashboardURL = 'https://app.powerbi.com/view?r=eyJrIjoiMTM4Y2ZhZGEtNGZmMS00ODZhLWFjZjQtMTE2ZTIyYTI0ODc4IiwidCI6ImEyYjUzYmU1LTczNGUtNGU2Yy1hYjBkLWQxODRmNjBmZDkxNyIsImMiOjh9&pageName=ReportSectionfa0be9512521e929ae4a'; -const SUMMARY_STATUS_PENDING = 1 satisfies SummaryStatusEnum; -const SUMMARY_STATUS_STARTED = 2 satisfies SummaryStatusEnum; -const SUMMARY_STATUS_SUCCESS = 3 satisfies SummaryStatusEnum; -const SUMMARY_NO_EXTRACT_AVAILABLE = 4 satisfies SummaryStatusEnum; -const SUMMARY_STATUS_FAILED = 5 satisfies SummaryStatusEnum; - type OpsLearningSummaryResponse = GoApiResponse<'/api/v2/ops-learning/summary/'>; type OpsLearningSectorSummary = OpsLearningSummaryResponse['sectors'][number]; type OpsLearningComponentSummary = OpsLearningSummaryResponse['components'][number]; @@ -503,9 +502,17 @@ export function Component() { {showKeyInsights && ( - + + + )} diff --git a/go-api b/go-api index 05524db6e6..36c0105875 160000 --- a/go-api +++ b/go-api @@ -1 +1 @@ -Subproject commit 05524db6e6c5dcd01555937560a4d6ece15aa58d +Subproject commit 36c0105875717f5805e2b17a3ccc24d7cdc7ee7a diff --git a/packages/ui/src/components/HtmlOutput/styles.module.css b/packages/ui/src/components/HtmlOutput/styles.module.css index ba0345d49d..937c44335f 100644 --- a/packages/ui/src/components/HtmlOutput/styles.module.css +++ b/packages/ui/src/components/HtmlOutput/styles.module.css @@ -3,6 +3,11 @@ margin: var(--go-ui-spacing-xs); } + img { + max-width: 100%; + object-fit: contain; + } + iframe { width: 100%; } diff --git a/packages/ui/src/components/PageContainer/index.tsx b/packages/ui/src/components/PageContainer/index.tsx index c0a5f6ca89..cb59f13e2f 100644 --- a/packages/ui/src/components/PageContainer/index.tsx +++ b/packages/ui/src/components/PageContainer/index.tsx @@ -5,11 +5,12 @@ import styles from './styles.module.css'; type SupportedElements = 'div' | 'nav' | 'header' | 'footer' | 'main'; export interface Props { - className?: string; - contentClassName?: string; - children: React.ReactNode; - contentAs?: SupportedElements; - containerAs?: SupportedElements; + className?: string; + contentClassName?: string; + children: React.ReactNode; + contentAs?: SupportedElements; + containerAs?: SupportedElements; + background?: React.ReactNode; } function PageContainer(props: Props) { @@ -19,6 +20,7 @@ function PageContainer(props: Props) { children, contentAs = 'div', containerAs = 'div', + background, } = props; const ContentElement = contentAs as React.ElementType; @@ -26,6 +28,11 @@ function PageContainer(props: Props) { return ( + {background && ( +
+ {background} +
+ )} {children} diff --git a/packages/ui/src/components/PageContainer/styles.module.css b/packages/ui/src/components/PageContainer/styles.module.css index e21d6de677..c0b95f9d7c 100644 --- a/packages/ui/src/components/PageContainer/styles.module.css +++ b/packages/ui/src/components/PageContainer/styles.module.css @@ -1,4 +1,16 @@ .page-container { + position: relative; + isolation: isolate; + + .background { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + } + .content { margin: 0 auto; padding: var(--go-ui-spacing-lg); diff --git a/packages/ui/src/components/PageHeader/index.tsx b/packages/ui/src/components/PageHeader/index.tsx index 769b9b2e31..89c5f89c15 100644 --- a/packages/ui/src/components/PageHeader/index.tsx +++ b/packages/ui/src/components/PageHeader/index.tsx @@ -17,6 +17,7 @@ export interface Props { breadCrumbs?: React.ReactNode; info?: React.ReactNode; wikiLink?: React.ReactNode; + background?: React.ReactNode; } function PageHeader(props: Props) { @@ -28,6 +29,7 @@ function PageHeader(props: Props) { breadCrumbs, info, wikiLink, + background, } = props; if (!(actions || breadCrumbs || info || description || heading)) { @@ -41,6 +43,7 @@ function PageHeader(props: Props) { styles.pageHeader, className, )} + background={background} > Date: Tue, 19 May 2026 15:10:46 +0545 Subject: [PATCH 2/2] feat: update dref application to link with emergency - Update DREF approval workflow --- app/src/components/SelectOutput/index.tsx | 3 + .../domain/EventSearchSelectInput.tsx | 13 +- .../domain/FieldReportSearchSelectInput.tsx | 77 ------ .../ActiveDrefTable/index.tsx | 6 + .../DrefTableActions/i18n.json | 17 +- .../DrefTableActions/index.tsx | 231 +++++++++++++++--- app/src/views/AllSurgeAlerts/index.tsx | 5 +- .../Overview/CopyFieldReportSection/i18n.json | 10 - .../Overview/EventInputSection/i18n.json | 11 + .../index.tsx | 121 +++++---- .../DrefApplicationForm/Overview/index.tsx | 32 ++- app/src/views/DrefApplicationForm/common.ts | 2 +- app/src/views/DrefApplicationForm/index.tsx | 10 +- app/src/views/DrefApplicationForm/schema.ts | 2 +- .../src/components/RadioInput/Radio/index.tsx | 5 +- packages/ui/src/utils/selectors.ts | 4 + 16 files changed, 348 insertions(+), 201 deletions(-) delete mode 100644 app/src/components/domain/FieldReportSearchSelectInput.tsx delete mode 100644 app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/i18n.json create mode 100644 app/src/views/DrefApplicationForm/Overview/EventInputSection/i18n.json rename app/src/views/DrefApplicationForm/Overview/{CopyFieldReportSection => EventInputSection}/index.tsx (73%) diff --git a/app/src/components/SelectOutput/index.tsx b/app/src/components/SelectOutput/index.tsx index 621b272b56..e55e197a8d 100644 --- a/app/src/components/SelectOutput/index.tsx +++ b/app/src/components/SelectOutput/index.tsx @@ -10,6 +10,7 @@ export interface Props { labelSelector: (datum: OPTION) => React.ReactNode; label?: React.ReactNode; withBackground?: boolean; + strongValue?: boolean; } function SelectOutput(props: Props) { @@ -21,6 +22,7 @@ function SelectOutput(props: Props) { labelSelector, label, withBackground, + strongValue = false, } = props; const selectedOption = useMemo(() => options?.find( @@ -39,6 +41,7 @@ function SelectOutput(props: Props) { label={label} value={valueLabel} strongLabel + strongValue={strongValue} withBackground={withBackground} /> ); diff --git a/app/src/components/domain/EventSearchSelectInput.tsx b/app/src/components/domain/EventSearchSelectInput.tsx index 838ee45252..d9c5808490 100644 --- a/app/src/components/domain/EventSearchSelectInput.tsx +++ b/app/src/components/domain/EventSearchSelectInput.tsx @@ -13,7 +13,15 @@ import { type GetEventParams = GoApiUrlQuery<'/api/v2/event/mini/'>; type GetEventResponse = GoApiResponse<'/api/v2/event/mini/'>; -export type EventItem = Pick[number], 'id' | 'name' | 'dtype'>; +export type EventItem = Omit< + Pick< + NonNullable[number], + 'id' | 'name' | 'dtype' | 'latest_field_report_id' + >, + 'latest_field_report_id' +> & { + latest_field_report_id?: NonNullable[number]['latest_field_report_id']; +}; const keySelector = (d: EventItem) => d.id; const labelSelector = (d: EventItem) => d.name ?? '???'; @@ -28,7 +36,6 @@ type EventSelectInputProps = SearchSelectInputProps< | 'keySelector' | 'labelSelector' | 'totalOptionsCount' | 'onShowDropdownChange' | 'selectedOnTop' > & { - autoGeneratedSource?: GetEventParams['auto_generated_source']; countryId?: number; }; @@ -38,7 +45,6 @@ function EventSearchSelectInput( const { className, countryId, - autoGeneratedSource, ...otherProps } = props; @@ -49,7 +55,6 @@ function EventSearchSelectInput( const query: GetEventParams | undefined = { search: debouncedSearchText, countries__in: countryId, - auto_generated_source: autoGeneratedSource, limit: 20, }; diff --git a/app/src/components/domain/FieldReportSearchSelectInput.tsx b/app/src/components/domain/FieldReportSearchSelectInput.tsx deleted file mode 100644 index 08a3e989d5..0000000000 --- a/app/src/components/domain/FieldReportSearchSelectInput.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState } from 'react'; -import { - SearchSelectInput, - type SearchSelectInputProps, -} from '@ifrc-go/ui'; - -import useDebouncedValue from '#hooks/useDebouncedValue'; -import { - type GoApiResponse, - type GoApiUrlQuery, - useRequest, -} from '#utils/restRequest'; - -type GetFieldReportParams = GoApiUrlQuery<'/api/v2/field-report/'>; -type GetFieldReportResponse = GoApiResponse<'/api/v2/field-report/'>; -export type FieldReportItem = Pick[number], 'id' | 'summary'>; - -const keySelector = (d: FieldReportItem) => d.id; -const labelSelector = (d: FieldReportItem) => d.summary || '???'; - -type Def = { containerClassName?: string;} -type FieldReportSelectInputProps = SearchSelectInputProps< - number, - NAME, - FieldReportItem, - Def, - 'onSearchValueChange' | 'searchOptions' | 'optionsPending' | 'keySelector' | 'labelSelector' | 'totalOptionsCount' | 'onShowDropdownChange' - | 'selectedOnTop' -> & { nationalSociety?: number }; - -function FieldReportSearchSelectInput( - props: FieldReportSelectInputProps, -) { - const { - className, - nationalSociety, - ...otherProps - } = props; - - const [opened, setOpened] = useState(false); - const [searchText, setSearchText] = useState(''); - const debouncedSearchText = useDebouncedValue(searchText); - - const query: GetFieldReportParams = { - summary: debouncedSearchText, - limit: 20, - countries__in: nationalSociety, - }; - - const { - pending, - response, - } = useRequest({ - skip: !opened, - url: '/api/v2/field-report/', - query, - preserveResponse: true, - }); - - return ( - - ); -} - -export default FieldReportSearchSelectInput; diff --git a/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx b/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx index 6c6c7da303..5d668374e0 100644 --- a/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx +++ b/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx @@ -334,6 +334,8 @@ function ActiveDrefTable(props: Props) { country_details, is_dref_imminent_v2, starting_language, + country, + event, } = originalDref; const is_published = status === DREF_STATUS_APPROVED; @@ -357,6 +359,8 @@ function ActiveDrefTable(props: Props) { ? userRegionCoordinatorMap?.[drefRegion] ?? false : false; + const countryId = isDefined(country) ? country : undefined; + return { id, drefId: originalDref.id, @@ -369,6 +373,8 @@ function ActiveDrefTable(props: Props) { hasPermissionToApprove: isRegionCoordinator || userMe?.is_superuser, onPublishSuccess: refetchActiveDref, startingLanguage: starting_language as Language, + countryId, + event, }; }, ), diff --git a/app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json b/app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json index cd697f8e1c..f8408b931a 100644 --- a/app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json +++ b/app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json @@ -26,6 +26,21 @@ "dropdownActionNewFinalReportLanguageSelectLanguageMessage": "Please select the language to create the DREF Final Report.", "dropdownActionImminentNewOpsUpdateConfirmationHeading": "Confirm addition of Operational Update", "dropdownActionNewFinalReportConfirmationHeading": "Confirm addition of Final Report", - "dropdownActionImminentNewOpsUpdateConfirmationMessage": "The DREF type will be changed to Response (from Imminent) for the Operational Update. Once created, you'll be able to change it to other types except Imminent. Are you sure you want to add an Operational Update?" + "dropdownActionImminentNewOpsUpdateConfirmationMessage": "The DREF type will be changed to Response (from Imminent) for the Operational Update. Once created, you'll be able to change it to other types except Imminent. Are you sure you want to add an Operational Update?", + "drefApplicationNoEventApprovalWarningMessage": "This DREF application isn't linked to an Emergency yet. Choose an option below to create a new one or link an existing Emergency", + "drefApplicationHasEventMessage": "You are about to approve this DREF Application with the following Emergency selected: {emergency}", + "drefApprovalSureMessage": "Are you sure you want to continue?", + "linkEmergencyCreateNewLabel": "Create a new Emergency", + "linkEmergencyCreateNewDescription": "A new emergency will be automatically created and linked using the details of the current DREF", + "linkEmergencySelectExistingLabel": "Select from existing Emergency", + "drefApprovalConfirmHeading": "Confirm Approval", + "drefApprovalLinkEmergencyPlaceholder": "Click here to link to an existing Emergency", + "drefAllocationForEmergencyAppeal": "Emergency Appeal", + "drefAllocationForDrefOperation": "DREF Operation", + "drefResponseTypeImminentCrisis": "Imminent Crisis", + "drefAllocatedFromAnticipatoryPillar": "Anticipatory Pillar", + "drefAllocatedFromResponsePillar": "Response Pillar", + "drefImplementationPeriodDays": "{count} days", + "drefImplementationPeriodMonths": "{count} months" } } diff --git a/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx b/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx index c918033733..5f0884e66c 100644 --- a/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx +++ b/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx @@ -15,6 +15,7 @@ import { import type { ButtonProps } from '@ifrc-go/ui'; import { Button, + Description, ListView, Message, Modal, @@ -27,19 +28,26 @@ import { useTranslation, } from '@ifrc-go/ui/hooks'; import { + resolveToComponent, resolveToString, + stringDescriptionSelector, + stringKeySelector, stringLabelSelector, } from '@ifrc-go/ui/utils'; import { isDefined, isNotDefined, + unique, } from '@togglecorp/fujs'; import DrefExportModal from '#components/domain/DrefExportModal'; import DrefShareModal from '#components/domain/DrefShareModal'; +import EventSearchSelectInput, { type EventItem as EventSearchItem } from '#components/domain/EventSearchSelectInput'; import DropdownMenuItem from '#components/DropdownMenuItem'; import Link from '#components/Link'; +import SelectOutput from '#components/SelectOutput'; import useAlert from '#hooks/useAlert'; +import useInputState from '#hooks/useInputState'; import useRouting from '#hooks/useRouting'; import { languageNameMap } from '#utils/common'; import { @@ -54,6 +62,7 @@ import { import { type GoApiBody, useLazyRequest, + useRequest, } from '#utils/restRequest'; import { exportDrefAllocation } from './drefAllocationExport'; @@ -61,11 +70,17 @@ import { exportDrefAllocation } from './drefAllocationExport'; import i18n from './i18n.json'; import styles from './styles.module.css'; +const keySelector = (d: EventSearchItem) => d.id; +const labelSelector = (d: EventSearchItem) => d.name ?? '???'; + type SelectLanguageOption = { key: Language, label: string, } +type DrefPublishRequestPostBody = GoApiBody<'/api/v2/dref/{id}/approve/', 'POST'>; +type OpsUpdateRequestBody = GoApiBody<'/api/v2/dref-op-update/', 'POST'>; + function selectLanguageKeySelector(option: SelectLanguageOption) { return option.key; } @@ -74,6 +89,8 @@ export interface Props { drefId: number; id: number; status: DrefStatus | null | undefined; + countryId?: number | undefined; + event?: number | null | undefined; applicationType: 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT'; canAddOpsUpdate: boolean; @@ -91,6 +108,7 @@ function DrefTableActions(props: Props) { id, drefId: drefIdFromProps, status, + countryId, applicationType, canAddOpsUpdate, canCreateFinalReport, @@ -99,16 +117,18 @@ function DrefTableActions(props: Props) { onPublishSuccess, drefType, startingLanguage, + event, } = props; + const [eventValue, setEventValue] = useInputState(event); + const [eventOptions, setEventOptions] = useState< + EventSearchItem[] | undefined | null + >([]); const [selectOpsLanguage, setSelectOpsLanguage] = useState(); - const [selectFinalLanguage, setSelectFinalLanguage] = useState(); const { navigate } = useRouting(); - const alert = useAlert(); - const strings = useTranslation(i18n); const selectLanguageOptions: SelectLanguageOption[] | undefined = useMemo(() => { @@ -124,11 +144,53 @@ function DrefTableActions(props: Props) { ]; }, [startingLanguage, strings.drefOpsUpdateStartingLanguageLabel]); + const linkEmergencyOptions = useMemo(() => ([ + { + key: 'create-new', + label: strings.linkEmergencyCreateNewLabel, + description: strings.linkEmergencyCreateNewDescription, + }, + { + key: 'select-existing', + label: strings.linkEmergencySelectExistingLabel, + description: null, + }, + ]), [ + strings.linkEmergencyCreateNewLabel, + strings.linkEmergencyCreateNewDescription, + strings.linkEmergencySelectExistingLabel, + ]); + + const [linkEmergencyValue, setLinkEmergencyValue] = useState(); + const [showExportModal, { setTrue: setShowExportModalTrue, setFalse: setShowExportModalFalse, }] = useBooleanState(false); + const [showDrefApprovalModal, { + setTrue: setShowDrefApprovalModalTrue, + setFalse: setShowDrefApprovalModalFalse, + }] = useBooleanState(false); + + const { + retrigger: eventTrigger, + } = useRequest({ + skip: isNotDefined(eventValue), + url: '/api/v2/event/mini/', + query: { + id: isDefined(eventValue) ? eventValue : undefined, + }, + onSuccess: (response) => { + setEventOptions( + (oldOptions) => unique( + [...(oldOptions ?? []), ...response.results], + (option) => option.id, + ), + ); + }, + }); + const { trigger: fetchDref, pending: fetchingDref, @@ -141,26 +203,36 @@ function DrefTableActions(props: Props) { ), onSuccess: (response) => { const exportData = { - // FIXME: use translations - allocationFor: response?.type_of_dref === DREF_TYPE_LOAN ? 'Emergency Appeal' : 'DREF Operation', + allocationFor: response?.type_of_dref === DREF_TYPE_LOAN + ? strings.drefAllocationForEmergencyAppeal + : strings.drefAllocationForDrefOperation, appealManager: response?.ifrc_appeal_manager_name, projectManager: response?.ifrc_project_manager_name, affectedCountry: response?.country_details?.name, name: response?.title, disasterType: response?.disaster_type_details?.name, - // FIXME: use translations - responseType: response?.type_of_dref === DREF_TYPE_IMMINENT ? 'Imminent Crisis' : response?.type_of_onset_display, + responseType: response?.type_of_dref === DREF_TYPE_IMMINENT + ? strings.drefResponseTypeImminentCrisis + : response?.type_of_onset_display, noOfPeopleTargeted: response?.num_assisted, nsRequestDate: response?.ns_request_date, disasterStartDate: response?.event_date, implementationPeriod: response?.type_of_dref === DREF_TYPE_IMMINENT - ? `${response.operation_timeframe_imminent} days` : `${response?.operation_timeframe} months`, + ? resolveToString( + strings.drefImplementationPeriodDays, + { count: response.operation_timeframe_imminent }, + ) + : resolveToString( + strings.drefImplementationPeriodMonths, + { count: response?.operation_timeframe }, + ), totalDREFAllocation: response?.amount_requested, previousAllocation: undefined, allocationRequested: response.type_of_dref === DREF_TYPE_IMMINENT ? response.total_cost : response?.amount_requested, - // FIXME: use translations - toBeAllocatedFrom: response?.type_of_dref === DREF_TYPE_IMMINENT ? 'Anticipatory Pillar' : 'Response Pillar', + toBeAllocatedFrom: response?.type_of_dref === DREF_TYPE_IMMINENT + ? strings.drefAllocatedFromAnticipatoryPillar + : strings.drefAllocatedFromResponsePillar, focalPointName: response?.regional_focal_point_name, }; exportDrefAllocation(exportData); @@ -179,29 +251,30 @@ function DrefTableActions(props: Props) { ), onSuccess: (response) => { const exportData = { - allocationFor: response?.type_of_dref === DREF_TYPE_LOAN ? 'Emergency Appeal' : 'DREF Operation', + allocationFor: response?.type_of_dref === DREF_TYPE_LOAN + ? strings.drefAllocationForEmergencyAppeal + : strings.drefAllocationForDrefOperation, appealManager: response?.ifrc_appeal_manager_name, projectManager: response?.ifrc_project_manager_name, affectedCountry: response?.country_details?.name, name: response?.title, disasterType: response?.disaster_type_details?.name, - responseType: - response?.type_of_dref === DREF_TYPE_IMMINENT - // FIXME: add translations - ? 'Imminent Crisis' - : response?.type_of_onset_display, + responseType: response?.type_of_dref === DREF_TYPE_IMMINENT + ? strings.drefResponseTypeImminentCrisis + : response?.type_of_onset_display, nsRequestDate: response?.ns_request_date, disasterStartDate: response?.event_date, - implementationPeriod: `${response?.total_operation_timeframe} months`, + implementationPeriod: resolveToString( + strings.drefImplementationPeriodMonths, + { count: response?.total_operation_timeframe }, + ), allocationRequested: response?.additional_allocation, previousAllocation: response?.dref_allocated_so_far ?? 0, totalDREFAllocation: response?.total_dref_allocation, noOfPeopleTargeted: response?.number_of_people_targeted, - toBeAllocatedFrom: - response?.type_of_dref === DREF_TYPE_IMMINENT - // FIXME: add translations - ? 'Anticipatory Pillar' - : 'Response Pillar', + toBeAllocatedFrom: response?.type_of_dref === DREF_TYPE_IMMINENT + ? strings.drefAllocatedFromAnticipatoryPillar + : strings.drefAllocatedFromResponsePillar, focalPointName: response?.regional_focal_point_name, }; exportDrefAllocation(exportData); @@ -216,12 +289,13 @@ function DrefTableActions(props: Props) { url: '/api/v2/dref/{id}/approve/', pathVariables: { id: String(id) }, // FIXME: typings should be fixed in the server - body: () => ({} as never), + body: (publishBody: DrefPublishRequestPostBody) => publishBody, onSuccess: () => { alert.show( strings.drefApprovalSuccessTitle, { variant: 'success' }, ); + setShowDrefApprovalModalFalse(); if (onPublishSuccess) { onPublishSuccess(); } @@ -239,6 +313,10 @@ function DrefTableActions(props: Props) { }, }); + const handlePublishDref = useCallback(() => { + publishDref({ event: eventValue }); + }, [publishDref, eventValue]); + const { trigger: publishOpsUpdate, pending: publishOpsUpdatePending, @@ -391,9 +469,6 @@ function DrefTableActions(props: Props) { }, }); - // FIXME: the type should be fixed on the server - type OpsUpdateRequestBody = GoApiBody<'/api/v2/dref-op-update/', 'POST'>; - const { trigger: createOpsUpdate, pending: createOpsUpdatePending, @@ -492,6 +567,11 @@ function DrefTableActions(props: Props) { setFalse: setShowFinalReportConfirmModalFalse, }] = useBooleanState(false); + const handleDrefApprovalOpen = useCallback(() => { + eventTrigger(); + setShowDrefApprovalModalTrue(); + }, [eventTrigger, setShowDrefApprovalModalTrue]); + const handleExportClick: NonNullable['onClick']> = useCallback( () => { setShowExportModalTrue(); @@ -508,19 +588,16 @@ function DrefTableActions(props: Props) { const handlePublishClick = useCallback( () => { - if (applicationType === 'DREF') { - publishDref(null); - } else if (applicationType === 'OPS_UPDATE') { + if (applicationType === 'OPS_UPDATE') { publishOpsUpdate(null); - } else if (applicationType === 'FINAL_REPORT') { + } + + if (applicationType === 'FINAL_REPORT') { publishFinalReport(null); - } else { - applicationType satisfies never; } }, [ applicationType, - publishDref, publishOpsUpdate, publishFinalReport, ], @@ -607,7 +684,7 @@ function DrefTableActions(props: Props) { {strings.dropdownActionFinalizeLabel} )} - {canApprove && ( + {canApprove && applicationType !== 'DREF' && ( )} + {canApprove && applicationType === 'DREF' && ( + } + onClick={handleDrefApprovalOpen} + disabled={disabled} + persist + > + {strings.dropdownActionApproveLabel} + + )} {canDownloadAllocation && ( )} + {showDrefApprovalModal && ( + + {strings.dropdownActionApproveLabel} + + )} + > + {isNotDefined(event) && ( + + + {strings.drefApplicationNoEventApprovalWarningMessage} + + + +
+ + )} + {(isDefined(event) && isDefined(eventOptions)) && ( + + + {resolveToComponent( + strings.drefApplicationHasEventMessage, + { + emergency: ( + + ), + }, + )} + + + {strings.drefApprovalSureMessage} + + + )} + + )} ); } diff --git a/app/src/views/AllSurgeAlerts/index.tsx b/app/src/views/AllSurgeAlerts/index.tsx index 346cba4dca..ea6c1a22fb 100644 --- a/app/src/views/AllSurgeAlerts/index.tsx +++ b/app/src/views/AllSurgeAlerts/index.tsx @@ -29,7 +29,7 @@ import { saveAs } from 'file-saver'; import Papa from 'papaparse'; import CountrySelectInput from '#components/domain/CountrySelectInput'; -import EventSearchSelectInput from '#components/domain/EventSearchSelectInput'; +import EventSearchSelectInput, { type EventItem } from '#components/domain/EventSearchSelectInput'; import ExportButton from '#components/domain/ExportButton'; import Page from '#components/Page'; import useAlert from '#hooks/useAlert'; @@ -49,9 +49,6 @@ import styles from './styles.module.css'; type SurgeResponse = GoApiResponse<'/api/v2/surge_alert/'>; type SurgeListItem = NonNullable[number]; -type GetEventResponse = GoApiResponse<'/api/v2/event/mini/'>; -type EventItem = Pick[number], 'id' | 'name' | 'dtype'>; - type TableKey = number; const nowTimestamp = new Date().getTime(); diff --git a/app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/i18n.json b/app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/i18n.json deleted file mode 100644 index e50e97a59d..0000000000 --- a/app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/i18n.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "namespace": "drefApplicationForm", - "strings": { - "drefFormCopyFRSuccessMessage": "Successfully copied some data from the selected field report", - "drefFormEventDetailsTitle": "Import data from existing field report", - "drefFormEventDescription": "If a field report related to this event/operation already exists, you can use it to pre-fill matching fields in this request. To do so, select the relevant field report from the drop-down list and click “Copy”.", - "drefFormSelectFieldReportPlaceholder": "Select field report", - "drefFormCopyButtonLabel": "Copy" - } -} diff --git a/app/src/views/DrefApplicationForm/Overview/EventInputSection/i18n.json b/app/src/views/DrefApplicationForm/Overview/EventInputSection/i18n.json new file mode 100644 index 0000000000..80e636eac4 --- /dev/null +++ b/app/src/views/DrefApplicationForm/Overview/EventInputSection/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "drefApplicationForm", + "strings": { + "drefFormCopyFRSuccessMessage": "Successfully copied some data from the selected emergency", + "drefFormEventDetailsTitle": "Link with an emergency", + "drefFormEventDescription": "Please check for, and link to an existing emergency if available. You can use it to pre-fill matching fields in this request. To do so, select the relevant emergency from the drop-down list and click “Copy”.", + "drefFormSelectFieldReportPlaceholder": "Select an emergency", + "drefFormCopyButtonLabel": "Copy", + "noEventDetailsWarningMessage": "The selected Emergency does not contain enough details to be copied over." + } +} diff --git a/app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/index.tsx b/app/src/views/DrefApplicationForm/Overview/EventInputSection/index.tsx similarity index 73% rename from app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/index.tsx rename to app/src/views/DrefApplicationForm/Overview/EventInputSection/index.tsx index f5759d79cb..9f4835580d 100644 --- a/app/src/views/DrefApplicationForm/Overview/CopyFieldReportSection/index.tsx +++ b/app/src/views/DrefApplicationForm/Overview/EventInputSection/index.tsx @@ -2,17 +2,21 @@ import { type Dispatch, type SetStateAction, useCallback, + useMemo, } from 'react'; import { Button, + Description, InlineLayout, InputSection, + ListView, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; import { isDefined, isFalsyString, isNotDefined, + listToMap, unique, } from '@togglecorp/fujs'; import { @@ -22,9 +26,8 @@ import { import sanitizeHtml from 'sanitize-html'; import { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; -import FieldReportSearchSelectInput, { type FieldReportItem as FieldReportSearchItem } from '#components/domain/FieldReportSearchSelectInput'; +import EventSearchSelectInput, { type EventItem as EventSearchItem } from '#components/domain/EventSearchSelectInput'; import useAlert from '#hooks/useAlert'; -import useInputState from '#hooks/useInputState'; import { useLazyRequest, useRequest, @@ -41,38 +44,52 @@ interface Props { setFieldValue: (...entries: EntriesAsList) => void; disabled?: boolean; setDistrictOptions: Dispatch>; - fieldReportOptions: FieldReportSearchItem[] | null | undefined; - setFieldReportOptions: Dispatch>; + eventOptions: EventSearchItem[] | null | undefined; + setEventOptions: Dispatch>; } -function CopyFieldReportSection(props: Props) { +function CopyEventSection(props: Props) { const { value, readOnly, setFieldValue, disabled, setDistrictOptions, - fieldReportOptions, - setFieldReportOptions, + eventOptions, + setEventOptions, } = props; const strings = useTranslation(i18n); const alert = useAlert(); - const [fieldReport, setFieldReport] = useInputState( - value?.field_report, - ); + const eventValue = value.event; + + const latestFieldReportId = useMemo(() => { + if (isNotDefined(eventValue) || isNotDefined(eventOptions)) { + return undefined; + } + const eventIdMap = listToMap( + eventOptions, + (item) => item.id, + ); + + return eventIdMap[eventValue]?.latest_field_report_id; + }, [eventOptions, eventValue]); + + const onEventChange = useCallback((val: number | undefined) => { + setFieldValue(val, 'event'); + }, [setFieldValue]); useRequest({ - skip: isNotDefined(value.field_report), - url: '/api/v2/field-report/{id}/', - pathVariables: { - id: Number(value.field_report), + skip: isNotDefined(eventValue), + url: '/api/v2/event/mini/', + query: { + id: isDefined(eventValue) ? eventValue : undefined, }, - onSuccess: (fr) => { - setFieldReportOptions( + onSuccess: (response) => { + setEventOptions( (oldOptions) => unique( - [...(oldOptions ?? []), fr], + [...(oldOptions ?? []), ...response.results], (option) => option.id, ), ); @@ -84,8 +101,8 @@ function CopyFieldReportSection(props: Props) { trigger: triggerDetailRequest, } = useLazyRequest({ url: '/api/v2/field-report/{id}/', - pathVariables: isDefined(fieldReport) - ? { id: fieldReport } + pathVariables: isDefined(latestFieldReportId) + ? { id: latestFieldReportId } : undefined, onSuccess: (rawFieldReportResponse) => { const fieldReportResponse = removeNull(rawFieldReportResponse); @@ -102,6 +119,7 @@ function CopyFieldReportSection(props: Props) { const un_or_other_actor = value.un_or_other_actor ?? fieldReportResponse.actions_others; const country = value.country ?? fieldReportResponse.countries[0]; + const national_society = value.national_society ?? country; const district = (value.district && value.district.length > 0) ? value.district @@ -211,7 +229,8 @@ function CopyFieldReportSection(props: Props) { setFieldValue(media_contact_email, 'media_contact_email'); setFieldValue(media_contact_phone_number, 'media_contact_phone_number'); setFieldValue(media_contact_title, 'media_contact_title'); - setFieldValue(fieldReportResponse.id, 'field_report'); + setFieldValue(fieldReportResponse.event, 'event'); + setFieldValue(national_society, 'national_society'); setFieldValue(country, 'country'); setFieldValue(district, 'district'); setFieldValue(num_affected, 'num_affected'); @@ -240,35 +259,45 @@ function CopyFieldReportSection(props: Props) { title={strings.drefFormEventDetailsTitle} description={strings.drefFormEventDescription} > - + + {strings.drefFormCopyButtonLabel} + + )} + > + + + {isDefined(eventValue) && isNotDefined(latestFieldReportId) && ( + - {strings.drefFormCopyButtonLabel} - + {strings.noEventDetailsWarningMessage} + )} - > - - + ); } -export default CopyFieldReportSection; +export default CopyEventSection; diff --git a/app/src/views/DrefApplicationForm/Overview/index.tsx b/app/src/views/DrefApplicationForm/Overview/index.tsx index 067b9f20b9..626913e90f 100644 --- a/app/src/views/DrefApplicationForm/Overview/index.tsx +++ b/app/src/views/DrefApplicationForm/Overview/index.tsx @@ -47,7 +47,7 @@ import CountrySelectInput from '#components/domain/CountrySelectInput'; import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; import DrefShareModal from '#components/domain/DrefShareModal'; -import { type FieldReportItem as FieldReportSearchItem } from '#components/domain/FieldReportSearchSelectInput'; +import { type EventItem as EventSearchItem } from '#components/domain/EventSearchSelectInput'; import GoSingleFileInput from '#components/domain/GoSingleFileInput'; import ImageWithCaptionInput from '#components/domain/ImageWithCaptionInput'; import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput'; @@ -80,7 +80,7 @@ import { TYPE_LOAN, } from '../common'; import { type PartialDref } from '../schema'; -import CopyFieldReportSection from './CopyFieldReportSection'; +import EventInputSection from './EventInputSection'; import i18n from './i18n.json'; @@ -113,8 +113,8 @@ interface Props { districtOptions: DistrictItem[] | null | undefined; setDistrictOptions: Dispatch>; - fieldReportOptions: FieldReportSearchItem[] | null | undefined; - setFieldReportOptions: Dispatch>; + eventOptions: EventSearchItem[] | null | undefined; + setEventOptions: Dispatch>; } const userKeySelector = (item: User) => item.id; @@ -131,8 +131,8 @@ function Overview(props: Props) { disabled, districtOptions, setDistrictOptions, - fieldReportOptions, - setFieldReportOptions, + eventOptions, + setEventOptions, } = props; const strings = useTranslation(i18n); @@ -317,17 +317,15 @@ function Overview(props: Props) { readOnly={readOnly} /> - {value?.type_of_dref !== TYPE_LOAN && ( - - )} + ([]); - const [fieldReportOptions, setFieldReportOptions] = useState< - FieldReportSearchItem[] | undefined | null + const [eventOptions, setEventOptions] = useState< + EventSearchItem[] | undefined | null >([]); const { @@ -810,8 +810,8 @@ export function Component() { disabled={disabled} districtOptions={districtOptions} setDistrictOptions={setDistrictOptions} - fieldReportOptions={fieldReportOptions} - setFieldReportOptions={setFieldReportOptions} + eventOptions={eventOptions} + setEventOptions={setEventOptions} /> diff --git a/app/src/views/DrefApplicationForm/schema.ts b/app/src/views/DrefApplicationForm/schema.ts index 84d39cd664..dcb5582bf5 100644 --- a/app/src/views/DrefApplicationForm/schema.ts +++ b/app/src/views/DrefApplicationForm/schema.ts @@ -164,7 +164,7 @@ const schema: DrefFormSchema = { required: true, requiredValidation: requiredStringCondition, }, - field_report: {}, // This value is set from CopyFieldReportSection + event: {}, // This value is set from CopyEventSection // EVENT DETAILS num_affected: { validations: [positiveIntegerCondition] }, estimated_number_of_affected_male: { validations: [positiveIntegerCondition] }, diff --git a/packages/ui/src/components/RadioInput/Radio/index.tsx b/packages/ui/src/components/RadioInput/Radio/index.tsx index 8371c270de..6a100516cc 100644 --- a/packages/ui/src/components/RadioInput/Radio/index.tsx +++ b/packages/ui/src/components/RadioInput/Radio/index.tsx @@ -82,7 +82,10 @@ function Radio(props: Props) { - + {description} diff --git a/packages/ui/src/utils/selectors.ts b/packages/ui/src/utils/selectors.ts index 6797bf9c6d..c5974b2859 100644 --- a/packages/ui/src/utils/selectors.ts +++ b/packages/ui/src/utils/selectors.ts @@ -37,3 +37,7 @@ export function numericKeySelector(option: { key: number }) { export function numericCountSelector(option: { count: number }) { return option.count; } + +export function stringDescriptionSelector(option: { description: string | undefined | null }) { + return option.description; +}