From 9e8f2896bae22ae81dc0e7f7a4c14589897785a9 Mon Sep 17 00:00:00 2001 From: barshathakuri Date: Fri, 17 Apr 2026 14:17:28 +0545 Subject: [PATCH] feature(emergency-pages): Add field repor --- app/src/App/routes/index.tsx | 28 ++ .../FieldReportStats/i18n.json | 4 +- .../FieldReportStats/index.tsx | 0 .../EmergencyOverviewTab}/i18n.json | 5 +- .../components/EmergencyOverviewTab/index.tsx | 357 +++++++++++++++ app/src/views/ActionsSummary/i18n.json | 10 + app/src/views/ActionsSummary/index.tsx | 203 +++++++++ .../RiskAnalysis/PastEventsChart/i18n.json | 11 + .../RiskAnalysis/PastEventsChart/index.tsx | 236 ++++++++++ .../PastEventsChart/styles.module.css | 5 + .../RiskBarChart/CombinedChart/i18n.json | 11 + .../RiskBarChart/CombinedChart/index.tsx | 335 ++++++++++++++ .../CombinedChart/styles.module.css | 3 + .../FoodInsecurity/FiChartPoint/i18n.json | 8 + .../FoodInsecurity/FiChartPoint/index.tsx | 99 ++++ .../RiskBarChart/FoodInsecurity/index.tsx | 224 +++++++++ .../FoodInsecurity/styles.module.css | 29 ++ .../RiskBarChart/WildfireChart/i18n.json | 10 + .../RiskBarChart/WildfireChart/index.tsx | 252 +++++++++++ .../WildfireChart/styles.module.css | 25 ++ .../RiskAnalysis/RiskBarChart/i18n.json | 15 + .../RiskAnalysis/RiskBarChart/index.tsx | 381 ++++++++++++++++ .../RiskBarChart/styles.module.css | 20 + .../RiskAnalysis/SeasonalCalender/i18n.json | 11 + .../RiskAnalysis/SeasonalCalender/index.tsx | 249 ++++++++++ .../SeasonalCalender/styles.module.css | 33 ++ .../views/Background/RiskAnalysis/index.tsx | 43 ++ app/src/views/Background/index.tsx | 27 ++ app/src/views/Emergency/i18n.json | 4 +- app/src/views/Emergency/index.tsx | 14 + .../FieldReportKeyFigure/i18n.json | 11 + .../FieldReportKeyFigure/index.tsx | 70 +++ .../FieldReportEmergency/index.tsx | 37 ++ app/src/views/EmergencyDetails/index.tsx | 425 +----------------- 34 files changed, 2768 insertions(+), 427 deletions(-) rename app/src/{views/EmergencyDetails => components/EmergencyOverviewTab}/FieldReportStats/i18n.json (95%) rename app/src/{views/EmergencyDetails => components/EmergencyOverviewTab}/FieldReportStats/index.tsx (100%) rename app/src/{views/EmergencyDetails => components/EmergencyOverviewTab}/i18n.json (91%) create mode 100644 app/src/components/EmergencyOverviewTab/index.tsx create mode 100644 app/src/views/ActionsSummary/i18n.json create mode 100644 app/src/views/ActionsSummary/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/PastEventsChart/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/PastEventsChart/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/PastEventsChart/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/RiskBarChart/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/SeasonalCalender/i18n.json create mode 100644 app/src/views/Background/RiskAnalysis/SeasonalCalender/index.tsx create mode 100644 app/src/views/Background/RiskAnalysis/SeasonalCalender/styles.module.css create mode 100644 app/src/views/Background/RiskAnalysis/index.tsx create mode 100644 app/src/views/Background/index.tsx create mode 100644 app/src/views/EmergencyDetails/FieldReportEmergency/FieldReportKeyFigure/i18n.json create mode 100644 app/src/views/EmergencyDetails/FieldReportEmergency/FieldReportKeyFigure/index.tsx create mode 100644 app/src/views/EmergencyDetails/FieldReportEmergency/index.tsx diff --git a/app/src/App/routes/index.tsx b/app/src/App/routes/index.tsx index b25ad826a3..cbc1ab0b32 100644 --- a/app/src/App/routes/index.tsx +++ b/app/src/App/routes/index.tsx @@ -222,6 +222,32 @@ const emergencyDetails = customWrapRoute({ }, }); +const background = customWrapRoute({ + parent: emergenciesLayout, + path: 'background', + component: { + render: () => import('#views/Background'), + props: {}, + }, + context: { + title: 'Emergency Background', + visibility: 'anything', + }, +}); + +const actionsSummary = customWrapRoute({ + parent: emergenciesLayout, + path: 'actions-summary', + component: { + render: () => import('#views/ActionsSummary'), + props: {}, + }, + context: { + title: 'Emergency Actions Summary', + visibility: 'anything', + }, +}); + const emergencyReportsAndDocuments = customWrapRoute({ parent: emergenciesLayout, path: 'reports', @@ -1363,6 +1389,8 @@ const wrappedRoutes = { // Redirects preparednessOperationalLearning, obsoleteFieldReportDetails, + background, + actionsSummary, }; export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes)); diff --git a/app/src/views/EmergencyDetails/FieldReportStats/i18n.json b/app/src/components/EmergencyOverviewTab/FieldReportStats/i18n.json similarity index 95% rename from app/src/views/EmergencyDetails/FieldReportStats/i18n.json rename to app/src/components/EmergencyOverviewTab/FieldReportStats/i18n.json index cc3370a2bb..a222f83c19 100644 --- a/app/src/views/EmergencyDetails/FieldReportStats/i18n.json +++ b/app/src/components/EmergencyOverviewTab/FieldReportStats/i18n.json @@ -1,5 +1,5 @@ { - "namespace": "emergencyDetails", + "namespace": "common", "strings": { "potentiallyAffected": "Potentially Affected", "highestRisk": "Highest Risk", @@ -23,4 +23,4 @@ "missing": "Missing", "displaced": "Displaced" } -} +} \ No newline at end of file diff --git a/app/src/views/EmergencyDetails/FieldReportStats/index.tsx b/app/src/components/EmergencyOverviewTab/FieldReportStats/index.tsx similarity index 100% rename from app/src/views/EmergencyDetails/FieldReportStats/index.tsx rename to app/src/components/EmergencyOverviewTab/FieldReportStats/index.tsx diff --git a/app/src/views/EmergencyDetails/i18n.json b/app/src/components/EmergencyOverviewTab/i18n.json similarity index 91% rename from app/src/views/EmergencyDetails/i18n.json rename to app/src/components/EmergencyOverviewTab/i18n.json index 28b56d96be..74240de405 100644 --- a/app/src/views/EmergencyDetails/i18n.json +++ b/app/src/components/EmergencyOverviewTab/i18n.json @@ -1,5 +1,5 @@ { - "namespace": "emergencyDetails", + "namespace": "common", "strings": { "emergencyKeyFiguresTitle": "Key Figures", "emergencyOverviewTitle": "Emergency Overview", @@ -12,10 +12,9 @@ "assistanceRequestedByNS": "NS Requests International Assistance", "assistanceRequestedByGovernment": "Government Requests International Assistance", "situationalOverviewTitle": "Situational Overview", - "linksTitle": "Links", "emergencyMapTitle": "Affected Provinces", "severityLevelUpdateDateLabel": "Last update", "contactsTitle": "Contacts", "sourceLabel": "Source {source}" } -} +} \ No newline at end of file diff --git a/app/src/components/EmergencyOverviewTab/index.tsx b/app/src/components/EmergencyOverviewTab/index.tsx new file mode 100644 index 0000000000..851f1bc2c1 --- /dev/null +++ b/app/src/components/EmergencyOverviewTab/index.tsx @@ -0,0 +1,357 @@ +import { useMemo } from 'react'; +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, +} from '@togglecorp/fujs'; + +import SeverityIndicator from '#components/domain/SeverityIndicator'; +import Link from '#components/Link'; +import { type DisasterType } from '#hooks/domain/useDisasterType'; +import { type GoApiResponse } from '#utils/restRequest'; +import EmergencyMap from '#views/EmergencyDetails/EmergencyMap'; + +import FieldReportStats from './FieldReportStats'; + +import i18n from './i18n.json'; + +type EmergencyResponse = GoApiResponse<'/api/v2/event/{id}/'>; +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); +} + +interface Props { + response: EmergencyResponse | undefined; + disasterType?: DisasterType | undefined; + visibilityMap?: Record; + mdrCode?: string | null; + assistanceIsRequestedByNS?: boolean | null; + assistanceIsRequestedByCountry?: boolean | null; +} + +function EmergencyOverview(props: Props) { + const { + response, + disasterType, + visibilityMap, + mdrCode, + assistanceIsRequestedByNS, + assistanceIsRequestedByCountry, + } = props; + const strings = useTranslation(i18n); + + const hasKeyFigures = isDefined(response) + && response.key_figures.length !== 0; + + const hasFieldReports = isDefined(response) + && isDefined(response?.field_reports) + && response?.field_reports.length > 0; + const emergencyContacts = response?.contacts; + + // In new API may be we need to fix this logic? + const latestFieldReport = hasFieldReports + ? getFieldReport(response.field_reports, compareDate) : undefined; + + 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 && ( + + + {response?.key_figures.map((keyFigure) => ( + +
{keyFigure.deck}
+
+ {resolveToString( + strings.sourceLabel, + { source: keyFigure.source }, + )} +
+
+ )} + withShadow + /> + ))} + +
+ )} + + {isDefined(response) && ( + + + + {response.ifrc_severity_level_display} + + {response.ifrc_severity_level_update_date && ( + + )} + /> + )} + + )} + strongValue + /> + + + + + + + + + + )} + + {isDefined(response?.summary) && isTruthyString(response.summary) && ( + + + + )} + + + {response && !response.hide_field_report_map && ( + + + + )} + {hasFieldReports + && isDefined(latestFieldReport) + && !response.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} + + )} + + + ))} + + + ))} + + + )} + + ); +} + +export default EmergencyOverview; diff --git a/app/src/views/ActionsSummary/i18n.json b/app/src/views/ActionsSummary/i18n.json new file mode 100644 index 0000000000..019858ab0d --- /dev/null +++ b/app/src/views/ActionsSummary/i18n.json @@ -0,0 +1,10 @@ +{ + "namespace": "fieldReportActionsSummary", + "strings": { + "actionsTakenHeader": "Actions Taken", + "actionsTakenByOthersHeading": "Actions Taken by Others", + "actionsTakenHeading": "Actions Taken by {organization}", + "notesLabel": "Notes", + "descriptionLabel": "Description" + } +} \ No newline at end of file diff --git a/app/src/views/ActionsSummary/index.tsx b/app/src/views/ActionsSummary/index.tsx new file mode 100644 index 0000000000..850b916522 --- /dev/null +++ b/app/src/views/ActionsSummary/index.tsx @@ -0,0 +1,203 @@ +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { + Chip, + Container, + HtmlOutput, + ListView, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToComponent } from '@ifrc-go/ui/utils'; +import { + isFalsyString, + isNotDefined, + isTruthyString, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; + +import TabPage from '#components/TabPage'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { + type CategoryType, + DISASTER_TYPE_EPIDEMIC, + FIELD_REPORT_STATUS_EARLY_WARNING, + type ReportType, +} from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + // NOTE: Add new api to fetch fieldReport from emergency pages + const { fieldReportId } = useParams<{ fieldReportId: string }>(); + + const { + api_action_org, + } = useGlobalEnums(); + + const organizationMap = listToMap( + api_action_org, + (option) => option.key, + (option) => option.value, + ); + + // NOTE: Add new api to fetch fieldReport from emergency pages + const { + pending: fetchingFieldReport, + response: fieldReportResponse, + } = useRequest({ + skip: isNotDefined(fieldReportId), + url: '/api/v2/field-report/{id}/', + pathVariables: { + id: Number(fieldReportId), + }, + }); + + const actionsTaken = fieldReportResponse?.actions_taken; + const notesHealth = fieldReportResponse?.notes_health; + const notesNs = fieldReportResponse?.notes_ns; + const notesSocioeco = fieldReportResponse?.notes_socioeco; + const actionsOthers = fieldReportResponse?.actions_others; + + const reportType: ReportType = useMemo(() => { + if (fieldReportResponse?.status === FIELD_REPORT_STATUS_EARLY_WARNING) { + return 'EW'; + } + + if (fieldReportResponse?.is_covid_report) { + return 'COVID'; + } + + if (fieldReportResponse?.dtype === DISASTER_TYPE_EPIDEMIC) { + return 'EPI'; + } + + return 'EVT'; + }, [fieldReportResponse]); + + return ( + + + + {actionsTaken?.map((actionTaken) => { + if ( + actionTaken.actions_details.length <= 0 + && isFalsyString(actionTaken.summary) + ) { + return null; + } + + const actionsGroupedByCategory = listToGroupList( + actionTaken.actions_details, + (item) => item.category ?? '', + (item) => item, + ); + const categoryItems = mapToList( + actionsGroupedByCategory, + (item, key) => ({ + category: key as CategoryType, + actions: item, + }), + ); + + return ( + + + {categoryItems?.map((value) => ( + + + {value.actions.map((action) => ( + + + + ))} + {reportType === 'COVID' && value.category === 'Health' && ( + + )} + {reportType === 'COVID' && value.category === 'NS Institutional Strengthening' && ( + + )} + {reportType === 'COVID' && value.category === 'Socioeconomic Interventions' && ( + + )} + + + ))} + + + + ); + })} + {isTruthyString(actionsOthers) && ( + + + + )} + + + + ); +} + +Component.displayName = 'ActionsSummary'; diff --git a/app/src/views/Background/RiskAnalysis/PastEventsChart/i18n.json b/app/src/views/Background/RiskAnalysis/PastEventsChart/i18n.json new file mode 100644 index 0000000000..f97be3d285 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/PastEventsChart/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "pastEventsChartEvents": "Previous Similar Emergencies", + "pastEventsTargetedPopulation": "Targeted population", + "pastEventsAmountRequested": "Amount requested", + "pastEventsAmountFunded": "Amount funded", + "pastEventsLastFiveYearsLabel": "Last 5 years", + "pastEventsLastTenYearsLabel": "Last 10 years" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/PastEventsChart/index.tsx b/app/src/views/Background/RiskAnalysis/PastEventsChart/index.tsx new file mode 100644 index 0000000000..b280b8c157 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/PastEventsChart/index.tsx @@ -0,0 +1,236 @@ +import { + useMemo, + useState, +} from 'react'; +import { + ChartAxes, + ChartContainer, + ChartPoint, + Container, + DateOutput, + SelectInput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getPercentage, + stringLabelSelector, +} from '@ifrc-go/ui/utils'; +import { + _cs, + encodeDate, + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import useTemporalChartData from '#hooks/useTemporalChartData'; +import { DEFAULT_Y_AXIS_WIDTH_WITH_LABEL } from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type TimePeriodKey = 'last-five-years' | 'last-ten-years' | 'last-twenty-years'; +type EventMetricKey = 'targeted_population' | 'amount_funded' | 'amount_requested'; + +function timePeriodKeySelector({ key }: { key: TimePeriodKey }) { + return key; +} + +function eventMetricKeySelector({ key }: { key: EventMetricKey }) { + return key; +} + +interface Props { + className?: string; + countryId: string | undefined; +} + +function PastEventsChart(props: Props) { + const { + className, + countryId, + } = props; + + const strings = useTranslation(i18n); + const [selectedTimePeriodKey, setSelectedTimePeriodKey] = useState('last-five-years'); + const [selectedEventMetricKey, setSelectedEventMetricKey] = useState('targeted_population'); + + const timePeriodOptions = useMemo<{ + key: TimePeriodKey; + label: string; + startDate: Date; + endDate: Date; + }[]>( + () => { + const now = new Date(); + + return [ + { + key: 'last-five-years', + label: strings.pastEventsLastFiveYearsLabel, + startDate: new Date(now.getFullYear() - 5, 0, 1), + endDate: new Date(now.getFullYear(), 11, 31), + }, + { + key: 'last-ten-years', + label: strings.pastEventsLastTenYearsLabel, + startDate: new Date(now.getFullYear() - 10, 0, 1), + endDate: new Date(now.getFullYear(), 11, 31), + }, + ]; + }, + [strings], + ); + + const timePeriodMap = useMemo( + () => listToMap(timePeriodOptions, ({ key }) => key), + [timePeriodOptions], + ); + + const eventMetricOptions = useMemo<{ + key: EventMetricKey, + label: string; + }[]>( + () => [ + { + key: 'targeted_population', + label: strings.pastEventsTargetedPopulation, + }, + { + key: 'amount_funded', + label: strings.pastEventsAmountFunded, + }, + { + key: 'amount_requested', + label: strings.pastEventsAmountRequested, + }, + ], + [strings], + ); + + const eventMetricMap = useMemo( + () => listToMap(eventMetricOptions, ({ key }) => key), + [eventMetricOptions], + ); + + const selectedTimePeriod = isDefined(selectedTimePeriodKey) + ? timePeriodMap[selectedTimePeriodKey] + : undefined; + + const { + pending: historicalDisastersPending, + response: historicalDisastersResponse, + error: historicalDisastersError, + } = useRequest({ + skip: isNotDefined(countryId) || isNotDefined(selectedTimePeriod), + url: '/api/v2/country/{id}/historical-disaster/', + pathVariables: { id: countryId }, + query: isDefined(selectedTimePeriod) ? { + start_date_from: encodeDate(selectedTimePeriod.startDate), + start_date_to: encodeDate(selectedTimePeriod.endDate), + } : undefined, + }); + + const chartData = useTemporalChartData( + historicalDisastersResponse, + { + keySelector: (datum, i) => `${datum.date}-${i}`, + xValueSelector: (datum) => datum.date, + yValueSelector: (datum) => datum[selectedEventMetricKey], + yAxisWidth: DEFAULT_Y_AXIS_WIDTH_WITH_LABEL, + temporalResolution: 'year', + yValueStartsFromZero: true, + }, + ); + + const selectedEventMetric = isDefined(selectedEventMetricKey) + ? eventMetricMap[selectedEventMetricKey] + : undefined; + + return ( + + + + + )} + > + + + {chartData.chartPoints.map( + (chartPoint) => ( + + + + + + + + )} + /> + + ), + )} + + + ); +} + +export default PastEventsChart; diff --git a/app/src/views/Background/RiskAnalysis/PastEventsChart/styles.module.css b/app/src/views/Background/RiskAnalysis/PastEventsChart/styles.module.css new file mode 100644 index 0000000000..68b78b8ac8 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/PastEventsChart/styles.module.css @@ -0,0 +1,5 @@ +.past-events-chart { + .data-point { + color: var(--go-ui-color-primary-red); + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/i18n.json b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/i18n.json new file mode 100644 index 0000000000..ff8a8aee3b --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "riskBarChartVeryLowLabel": "Very low", + "riskBarChartLowLabel": "Low", + "riskBarChartMediumLabel": "Medium", + "riskBarChartHighLabel": "High", + "riskVeryHighLabel": "Very high", + "riskScoreLabel": "Risk score" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/index.tsx b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/index.tsx new file mode 100644 index 0000000000..0c0eed88b6 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/index.tsx @@ -0,0 +1,335 @@ +import { + Fragment, + useCallback, + useMemo, +} from 'react'; +import { + ChartAxes, + ChartContainer, + NumberOutput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { maxSafe } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToMap, + mapToMap, +} from '@togglecorp/fujs'; + +import { type components } from '#generated/riskTypes'; +import useTemporalChartData from '#hooks/useTemporalChartData'; +import { + CATEGORY_RISK_HIGH, + CATEGORY_RISK_LOW, + CATEGORY_RISK_MEDIUM, + CATEGORY_RISK_VERY_HIGH, + CATEGORY_RISK_VERY_LOW, +} from '#utils/constants'; +import { + getDataWithTruthyHazardType, + getFiRiskDataItem, + getWfRiskDataItem, + hazardTypeToColorMap, + monthNumberToNameMap, + type RiskMetricOption, + riskScoreToCategory, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; +type HazardType = components<'read'>['schemas']['CommonHazardTypeEnumKey']; + +const selectedMonths = { + 0: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + 8: true, + 9: true, + 10: true, + 11: true, +}; + +const BAR_GAP = 2; + +interface Props { + riskData: RiskData | undefined; + selectedRiskMetricDetail: RiskMetricOption; + selectedHazardType: HazardType | undefined; + hazardListForDisplay: { + hazard_type: HazardType; + hazard_type_display: string; + }[]; +} + +function CombinedChart(props: Props) { + const { + riskData, + selectedHazardType, + selectedRiskMetricDetail, + hazardListForDisplay, + } = props; + const strings = useTranslation(i18n); + + const riskCategoryToLabelMap: Record = useMemo( + () => ({ + [CATEGORY_RISK_VERY_LOW]: strings.riskBarChartVeryLowLabel, + [CATEGORY_RISK_LOW]: strings.riskBarChartLowLabel, + [CATEGORY_RISK_MEDIUM]: strings.riskBarChartMediumLabel, + [CATEGORY_RISK_HIGH]: strings.riskBarChartHighLabel, + [CATEGORY_RISK_VERY_HIGH]: strings.riskVeryHighLabel, + }), + [ + strings.riskBarChartVeryLowLabel, + strings.riskBarChartLowLabel, + strings.riskBarChartMediumLabel, + strings.riskBarChartHighLabel, + strings.riskVeryHighLabel, + ], + ); + + const fiRiskDataItem = useMemo( + () => getFiRiskDataItem(riskData?.ipc_displacement_data), + [riskData], + ); + const wfRiskDataItem = useMemo( + () => getWfRiskDataItem(riskData?.gwis), + [riskData], + ); + + const selectedRiskData = useMemo( + () => { + if (selectedRiskMetricDetail.key === 'displacement') { + return listToMap( + riskData?.idmc + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) ?? [], + (data) => data.hazard_type, + ); + } + + if (selectedRiskMetricDetail.key === 'riskScore') { + return { + ...listToMap( + riskData?.inform_seasonal + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) + ?.map((riskItem) => ({ + ...riskItem, + ...mapToMap( + monthNumberToNameMap, + (_, monthName) => monthName, + (monthName) => riskScoreToCategory( + riskItem?.[monthName], + riskItem?.hazard_type, + ), + ), + })) ?? [], + (data) => data.hazard_type, + ), + WF: { + ...wfRiskDataItem, + ...mapToMap( + monthNumberToNameMap, + (_, monthName) => monthName, + (monthName) => riskScoreToCategory( + wfRiskDataItem?.[monthName], + 'WF', + ), + ), + }, + }; + } + + const rasterDisplacementData = listToMap( + riskData?.raster_displacement_data + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) ?? [], + (datum) => datum.hazard_type, + ); + + if (isNotDefined(fiRiskDataItem)) { + return rasterDisplacementData; + } + + return { + ...rasterDisplacementData, + FI: fiRiskDataItem, + }; + }, + [riskData, selectedRiskMetricDetail, fiRiskDataItem, wfRiskDataItem], + ); + + const filteredRiskData = useMemo( + () => { + if (isNotDefined(selectedHazardType)) { + return selectedRiskData; + } + + const riskDataItem = selectedRiskData[selectedHazardType]; + + return { + [selectedHazardType]: riskDataItem, + }; + }, + [selectedRiskData, selectedHazardType], + ); + + const hazardData = useMemo( + () => { + const monthKeys = Object.keys(selectedMonths) as unknown as ( + keyof typeof selectedMonths + )[]; + + const hazardKeysFromSelectedRisk = Object.keys(filteredRiskData ?? {}) as HazardType[]; + const currentYear = new Date().getFullYear(); + + return ( + monthKeys.map( + (monthKey) => { + const month = monthNumberToNameMap[monthKey]!; + const value = listToMap( + hazardKeysFromSelectedRisk, + (hazardKey) => hazardKey, + (hazardKey) => (filteredRiskData?.[hazardKey]?.[month] ?? 0), + ); + + return { + value, + month, + date: new Date(currentYear, monthKey, 1), + }; + }, + ) + ); + }, + [filteredRiskData], + ); + + const yAxisTickLabelSelector = useCallback( + (value: number) => { + if (selectedRiskMetricDetail.key === 'riskScore') { + return riskCategoryToLabelMap[value]; + } + + return ( + + ); + }, + [riskCategoryToLabelMap, selectedRiskMetricDetail.key], + ); + + const chartData = useTemporalChartData( + hazardData, + { + keySelector: (datum) => datum.date.getTime(), + xValueSelector: (datum) => datum.date, + yValueSelector: (datum) => maxSafe(Object.values(datum.value)), + yAxisTickLabelSelector, + yearlyChart: true, + yValueStartsFromZero: true, + yAxisWidth: selectedRiskMetricDetail.key === 'riskScore' ? 80 : undefined, + yScale: selectedRiskMetricDetail.key === 'riskScore' ? 'linear' : 'cbrt', + yDomain: selectedRiskMetricDetail.key === 'riskScore' ? { min: 0, max: 5 } : undefined, + numYAxisTicks: 6, + }, + ); + + function getChartHeight(y: number) { + return isDefined(y) + ? Math.max( + chartData.dataAreaSize.height - y + chartData.dataAreaOffset.top, + 0, + ) : 0; + } + + const xAxisDiff = chartData.dataAreaSize.width / chartData.numXAxisTicks; + const barGap = Math.min(BAR_GAP, xAxisDiff / 30); + + return ( + + + {chartData.chartPoints.map( + (datum) => ( + + {hazardListForDisplay.map( + ({ hazard_type: hazard, hazard_type_display }, hazardIndex) => { + const value = datum.originalData.value[hazard]; + const y = chartData.yScaleFn(value); + const height = getChartHeight(y); + + const offsetX = barGap; + const numItems = hazardListForDisplay.length; + + const width = Math.max( + + (xAxisDiff / numItems) - offsetX * 2, + 0, + ); + // eslint-disable-next-line max-len + const x = (datum.x - xAxisDiff / 2) + offsetX + (width + barGap) * hazardIndex; + + return ( + + + {datum.originalData.date.toLocaleDateString('default', { month: 'long' })} + {selectedRiskMetricDetail.key === 'riskScore' ? ( + + ) : ( + + )} + + )} + /> + + ); + }, + )} + + ), + )} + + ); +} + +export default CombinedChart; diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/styles.module.css b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/styles.module.css new file mode 100644 index 0000000000..1d3027805f --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/CombinedChart/styles.module.css @@ -0,0 +1,3 @@ +.combined-chart { + height: 20rem; +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/i18n.json b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/i18n.json new file mode 100644 index 0000000000..e64340224c --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "foodInsecurityChartAverage": "Average for", + "foodInsecurityAnalysisDate": "Analysis date", + "foodInsecurityPeopleExposed": "People Exposed" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/index.tsx b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/index.tsx new file mode 100644 index 0000000000..aeb1e32e0c --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/FiChartPoint/index.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { + ChartPoint, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import i18n from './i18n.json'; + +const currentYear = new Date().getFullYear(); + +interface Props { + className?: string; + dataPoint: { + originalData: { + year?: number; + month: number; + analysis_date?: string; + total_displacement: number; + }, + key: number | string; + x: number; + y: number; + }; +} + +function FiChartPoint(props: Props) { + const { + dataPoint: { + x, + y, + originalData, + }, + className, + } = props; + + // FIXME: strings should be used only by the main component + const strings = useTranslation(i18n); + + const title = useMemo( + () => { + const { + year, + month, + } = originalData; + + if (isDefined(year)) { + return new Date(year, month - 1, 1).toLocaleString( + navigator.language, + { + year: 'numeric', + month: 'long', + }, + ); + } + + const formattedMonth = new Date(currentYear, month - 1, 1).toLocaleString( + navigator.language, + { month: 'long' }, + ); + + return `${strings.foodInsecurityChartAverage} ${formattedMonth}`; + }, + [originalData, strings.foodInsecurityChartAverage], + ); + + return ( + + + {isDefined(originalData.analysis_date) && ( + + )} + + + )} + /> + + ); +} + +export default FiChartPoint; diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/index.tsx b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/index.tsx new file mode 100644 index 0000000000..b6dceceee5 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/index.tsx @@ -0,0 +1,224 @@ +import { useMemo } from 'react'; +import { + ChartAxes, + ChartContainer, +} from '@ifrc-go/ui'; +import { + avgSafe, + getDiscretePathDataList, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import useTemporalChartData from '#hooks/useTemporalChartData'; +import { getPrioritizedIpcData } from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import FiChartPoint from './FiChartPoint'; + +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; + +const colors = [ + 'var(--go-ui-color-gray-30)', + 'var(--go-ui-color-gray-40)', + 'var(--go-ui-color-gray-50)', + 'var(--go-ui-color-gray-60)', + 'var(--go-ui-color-gray-70)', + 'var(--go-ui-color-gray-80)', + 'var(--go-ui-color-gray-90)', +]; + +interface Props { + ipcData: RiskData['ipc_displacement_data'] | undefined; + showHistoricalData?: boolean; + showProjection?: boolean; +} + +function FoodInsecurityChart(props: Props) { + const { + ipcData, + showHistoricalData, + showProjection, + } = props; + + const uniqueData = useMemo( + () => getPrioritizedIpcData(ipcData ?? []), + [ipcData], + ); + + const chartData = useTemporalChartData( + uniqueData, + { + keySelector: (datum) => datum.id, + xValueSelector: (datum) => new Date(datum.year, datum.month - 1, 1), + yValueSelector: (datum) => datum.total_displacement, + yValueStartsFromZero: true, + yearlyChart: true, + }, + ); + + const latestProjectionYear = useMemo( + () => { + const projectionData = uniqueData.filter( + (fiData) => fiData.estimation_type !== 'current', + ).map( + (fiData) => fiData.year, + ); + + return Math.max(...projectionData); + }, + [uniqueData], + ); + + const historicalPointsDataList = useMemo( + () => { + const yearGroupedDataPoints = listToGroupList( + chartData.chartPoints.filter( + (pathPoints) => pathPoints.originalData.year !== latestProjectionYear, + ), + (dataPoint) => dataPoint.originalData.year, + ); + + return mapToList( + yearGroupedDataPoints, + (list, key) => ({ + key, + list, + }), + ); + }, + [latestProjectionYear, chartData.chartPoints], + ); + + const averagePointsData = useMemo( + () => { + const monthGroupedDataPoints = listToGroupList( + chartData.chartPoints, + (dataPoint) => dataPoint.originalData.month, + ); + + return mapToList( + monthGroupedDataPoints, + (list, month) => { + const averageDisplacement = avgSafe( + list.map( + (fiData) => fiData.originalData.total_displacement, + ), + ); + + if (isNotDefined(averageDisplacement)) { + return undefined; + } + + return { + key: month, + x: list[0]!.x, + y: chartData.yScaleFn(averageDisplacement), + originalData: { + total_displacement: averageDisplacement, + month: Number(month), + }, + }; + }, + ).filter(isDefined); + }, + [chartData], + ); + + const predictionPointsData = useMemo( + () => ( + chartData.chartPoints.filter( + (pathPoints) => pathPoints.originalData.year === latestProjectionYear, + ) + ), + [chartData.chartPoints, latestProjectionYear], + ); + + return ( + + + {showHistoricalData && historicalPointsDataList.map( + (historicalPointsData, i) => ( + + {getDiscretePathDataList(historicalPointsData.list).map( + (discretePath) => ( + + ), + )} + {historicalPointsData.list.map( + (pointData) => ( + + ), + )} + + ), + )} + {showProjection && ( + + {getDiscretePathDataList(predictionPointsData).map( + (discretePath) => ( + + ), + )} + {predictionPointsData.map( + (pointData) => ( + + ), + )} + + )} + + {getDiscretePathDataList(averagePointsData).map( + (discretePath) => ( + + ), + )} + {averagePointsData.map( + (pointData) => ( + + ), + )} + + + ); +} + +export default FoodInsecurityChart; diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/styles.module.css b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/styles.module.css new file mode 100644 index 0000000000..71cb651e1f --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/FoodInsecurity/styles.module.css @@ -0,0 +1,29 @@ +.food-insecurity-chart { + height: 20rem; + + .point { + color: currentColor; + } + + .path { + fill: none; + stroke-width: 2; + stroke: currentColor; + } + + .historical-data { + opacity: 0.6; + + &:hover { + opacity: 1; + } + } + + .prediction { + color: var(--go-ui-color-primary-red); + } + + .average { + color: var(--go-ui-color-hazard-fi); + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/i18n.json b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/i18n.json new file mode 100644 index 0000000000..20f2e70428 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/i18n.json @@ -0,0 +1,10 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "average": "Average (2003 - {currentYear})", + "year": "Year {currentYear}", + "minMax": "Min-max (2003 - {currentYear})", + "minMaxValue": "{min} - {max}", + "monthlySeverityRating": "Monthly Severity Rating" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/index.tsx b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/index.tsx new file mode 100644 index 0000000000..b90a834968 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/index.tsx @@ -0,0 +1,252 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + ChartAxes, + ChartContainer, + ChartPoint, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + avgSafe, + formatNumber, + getDiscretePathDataList, + getPathData, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import { type paths } from '#generated/riskTypes'; +import useTemporalChartData from '#hooks/useTemporalChartData'; +import { + COLOR_PRIMARY_BLUE, + DEFAULT_Y_AXIS_WIDTH_WITH_LABEL, +} from '#utils/constants'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetCountryRisk = paths['/api/v1/country-seasonal/']['get']; +type CountryRiskResponse = GetCountryRisk['responses']['200']['content']['application/json']; +type RiskData = CountryRiskResponse[number]; + +interface ChartPoint { + x: number; + y: number; + label: string | undefined; +} + +interface Props { + gwisData: RiskData['gwis'] | undefined; +} + +const currentYear = new Date().getFullYear(); + +function WildfireChart(props: Props) { + const { gwisData } = props; + + const strings = useTranslation(i18n); + + const aggregatedList = useMemo( + () => { + const monthGroupedData = listToGroupList( + gwisData?.filter((dataItem) => dataItem.dsr_type === 'monthly') ?? [], + (gwisItem) => gwisItem.month, + ); + + return mapToList( + monthGroupedData, + (monthlyData, monthKey) => { + const average = avgSafe(monthlyData.map((dataItem) => dataItem.dsr)) ?? 0; + const min = avgSafe(monthlyData.map((dataItem) => dataItem.dsr_min)) ?? 0; + const max = avgSafe(monthlyData.map((dataItem) => dataItem.dsr_max)) ?? 0; + + const current = monthlyData.find( + (dataItem) => dataItem.year === currentYear, + )?.dsr; + + const month = Number(monthKey) - 1; + + return { + date: new Date(currentYear, month, 1), + month, + min, + max, + average, + current, + maxValue: Math.max(min, max, average, current ?? 0), + }; + }, + ); + }, + [gwisData], + ); + + const chartData = useTemporalChartData( + aggregatedList, + { + keySelector: (datum) => datum.month, + xValueSelector: (datum) => datum.date, + yValueSelector: (datum) => datum.maxValue, + yearlyChart: true, + yAxisWidth: DEFAULT_Y_AXIS_WIDTH_WITH_LABEL, + yValueStartsFromZero: true, + }, + ); + + const minPoints = chartData.chartPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: chartData.yScaleFn(dataPoint.originalData.min), + }), + ); + + const maxPoints = chartData.chartPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: chartData.yScaleFn(dataPoint.originalData.max), + }), + ); + + const minMaxPoints = [...minPoints, ...[...maxPoints].reverse()]; + + const currentYearPoints = chartData.chartPoints.map( + (dataPoint) => { + if (isNotDefined(dataPoint.originalData.current)) { + return undefined; + } + + return { + ...dataPoint, + y: chartData.yScaleFn(dataPoint.originalData.current), + }; + }, + ).filter(isDefined); + + const averagePoints = chartData.chartPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: chartData.yScaleFn(dataPoint.originalData.average), + }), + ); + + const tooltipSelector = useCallback( + (_: number | string, i: number) => { + const date = new Date(currentYear, i, 1); + const monthData = aggregatedList[i]!; + + return ( + + + + + + )} + /> + ); + }, + [strings, aggregatedList], + ); + + const [hoveredAxisIndex, setHoveredAxisIndex] = useState(); + + const handleHover = useCallback( + (_: number | string | undefined, index: number | undefined) => { + setHoveredAxisIndex(index); + }, + [], + ); + + return ( + + + + {getDiscretePathDataList(currentYearPoints).map( + (points) => ( + + ), + )} + {currentYearPoints.map( + (pointData, i) => ( + + ), + )} + + + + {averagePoints.map( + (pointData, index) => ( + + ), + )} + + + + ); +} + +export default WildfireChart; diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/styles.module.css b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/styles.module.css new file mode 100644 index 0000000000..009e13b2b2 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/WildfireChart/styles.module.css @@ -0,0 +1,25 @@ +.wildfire-chart { + height: 20rem; + + .min-max-path { + fill: var(--go-ui-color-gray-20); + } + + .path { + fill: none; + stroke: currentColor; + stroke-width: 2; + } + + .point { + color: currentColor; + } + + .average { + color: var(--go-ui-color-primary-blue); + } + + .current-year { + color: var(--go-ui-color-primary-red); + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/i18n.json b/app/src/views/Background/RiskAnalysis/RiskBarChart/i18n.json new file mode 100644 index 0000000000..b1786f315a --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/i18n.json @@ -0,0 +1,15 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "riskBarChartTitle": "Average Risks by Month", + "riskBarChartFilterHazardPlaceholder": "All Hazard types", + "riskBarChartRiskScoreLabel": "Risk Score", + "riskBarChartExposureLabel": "People Exposed", + "riskBarChartDisplacementLabel": "People at Risk of Displacement", + "currentYearTooltipLabel": "Year {currentYear}", + "averageTooltipLabel": "Average (2003-{currentYear})", + "minMaxLabel": "Min-Max (2003-{currentYear})", + "showHistoricalDataLabel": "Show historical data", + "showProjectionsLabel": "Show projections" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/index.tsx b/app/src/views/Background/RiskAnalysis/RiskBarChart/index.tsx new file mode 100644 index 0000000000..5a5b9659ad --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/index.tsx @@ -0,0 +1,381 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Checkbox, + Container, + LegendItem, + SelectInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + resolveToString, + stringLabelSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, + isNotDefined, + listToGroupList, + listToMap, + unique, +} from '@togglecorp/fujs'; + +import useInputState from '#hooks/useInputState'; +import { + COLOR_LIGHT_GREY, + COLOR_PRIMARY_BLUE, + COLOR_PRIMARY_RED, +} from '#utils/constants'; +import type { + HazardType, + RiskMetric, + RiskMetricOption, +} from '#utils/domain/risk'; +import { + applicableHazardsByRiskMetric, + getDataWithTruthyHazardType, + getFiRiskDataItem, + hasSomeDefinedValue, + hazardTypeKeySelector, + hazardTypeLabelSelector, + hazardTypeToColorMap, + riskMetricKeySelector, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import CombinedChart from './CombinedChart'; +import FoodInsecurityChart from './FoodInsecurity'; +import WildfireChart from './WildfireChart'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; + +const currentYear = new Date().getFullYear(); + +interface Props { + pending: boolean; + seasonalRiskData: RiskData | undefined; +} + +function RiskBarChart(props: Props) { + const { + seasonalRiskData, + pending, + } = props; + const strings = useTranslation(i18n); + + const [selectedRiskMetric, setSelectedRiskMetric] = useInputState('exposure'); + const [ + selectedHazardType, + setSelectedHazardType, + ] = useInputState(undefined); + const [showFiHistoricalData, setShowFiHistoricalData] = useInputState(false); + const [showFiProjection, setShowFiProjection] = useInputState(false); + + const handleRiskMetricChange = useCallback( + (riskMetric: RiskMetric) => { + setSelectedRiskMetric(riskMetric); + setSelectedHazardType(undefined); + }, + [setSelectedHazardType, setSelectedRiskMetric], + ); + + const riskMetricOptions: RiskMetricOption[] = useMemo( + () => ([ + { + key: 'exposure', + label: strings.riskBarChartExposureLabel, + applicableHazards: applicableHazardsByRiskMetric.exposure, + }, + { + key: 'displacement', + label: strings.riskBarChartDisplacementLabel, + applicableHazards: applicableHazardsByRiskMetric.displacement, + }, + { + key: 'riskScore', + label: strings.riskBarChartRiskScoreLabel, + applicableHazards: applicableHazardsByRiskMetric.riskScore, + }, + ]), + [ + strings.riskBarChartExposureLabel, + strings.riskBarChartDisplacementLabel, + strings.riskBarChartRiskScoreLabel, + ], + ); + + const selectedRiskMetricDetail = useMemo( + () => riskMetricOptions.find( + (option) => option.key === selectedRiskMetric, + ) ?? riskMetricOptions[0]!, + [selectedRiskMetric, riskMetricOptions], + ); + + const data = useMemo( + () => { + if (isNotDefined(seasonalRiskData)) { + return undefined; + } + + const { + idmc, + ipc_displacement_data, + raster_displacement_data, + gwis_seasonal, + inform_seasonal, + } = seasonalRiskData; + + const displacement = idmc?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ).filter(isDefined) ?? []; + + const groupedIpc = Object.values( + listToGroupList( + ipc_displacement_data ?? [], + (ipcDataItem) => ipcDataItem.country, + ), + ); + + const exposure = [ + ...raster_displacement_data?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...groupedIpc.map(getFiRiskDataItem), + ].filter(isDefined); + + const riskScore = unique( + [ + ...inform_seasonal?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...gwis_seasonal?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ].filter(isDefined), + (item) => `${item.country_details.iso3}-${item.hazard_type}`, + ); + + return { + displacement, + exposure, + riskScore, + }; + }, + [seasonalRiskData], + ); + + const availableHazards: { [key in HazardType]?: string } | undefined = useMemo( + () => { + if (isNotDefined(data)) { + return undefined; + } + + if (selectedRiskMetric === 'exposure') { + return { + ...listToMap( + data.exposure, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + if (selectedRiskMetric === 'displacement') { + return { + ...listToMap( + data.displacement, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + if (selectedRiskMetric === 'riskScore') { + return { + ...listToMap( + data.riskScore, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + return undefined; + }, + [data, selectedRiskMetric], + ); + + const hazardTypeOptions = useMemo( + () => ( + selectedRiskMetricDetail.applicableHazards.map( + (hazardType) => { + const hazard_type_display = availableHazards?.[hazardType]; + if (isFalsyString(hazard_type_display)) { + return undefined; + } + + return { + hazard_type: hazardType, + hazard_type_display, + }; + }, + ).filter(isDefined) + ), + [availableHazards, selectedRiskMetricDetail], + ); + + const hazardListForDisplay = useMemo( + () => { + if (isNotDefined(selectedHazardType)) { + return hazardTypeOptions; + } + + return hazardTypeOptions.filter( + (hazardType) => hazardType.hazard_type === selectedHazardType, + ); + }, + [selectedHazardType, hazardTypeOptions], + ); + + return ( + + + + {selectedHazardType === 'FI' && ( +
+ + +
+ )} + + )} + pending={pending} + > + {selectedHazardType === 'FI' && ( + + )} + {selectedHazardType === 'WF' && ( + + )} + {selectedHazardType !== 'FI' && selectedHazardType !== 'WF' && ( + + )} +
+ {hazardListForDisplay.map( + (hazard) => ( + + ), + )} + {selectedHazardType === 'WF' && ( + <> + + + + + )} +
+
+ ); +} + +export default RiskBarChart; diff --git a/app/src/views/Background/RiskAnalysis/RiskBarChart/styles.module.css b/app/src/views/Background/RiskAnalysis/RiskBarChart/styles.module.css new file mode 100644 index 0000000000..80257eb105 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/RiskBarChart/styles.module.css @@ -0,0 +1,20 @@ +.risk-bar-chart { + .fi-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--go-ui-spacing-md); + grid-column: span 2; + + @media screen and (max-width: 40rem) { + grid-column: unset; + } + } + + .legend { + display: flex; + gap: var(--go-ui-spacing-md); + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-sm) var(--go-ui-spacing-md); + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/SeasonalCalender/i18n.json b/app/src/views/Background/RiskAnalysis/SeasonalCalender/i18n.json new file mode 100644 index 0000000000..43acb81672 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/SeasonalCalender/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "fieldReportBackground", + "strings": { + "seasonalCalendarHeading": "Seasonal Calendar", + "seasonalCalendarTooltipEventLabel": "Event", + "seasonalCalendarTooltipEventTypeLabel": "Event type", + "seasonalCalendarTooltipMonthsLabel": "Months", + "seasonalCalendarTooltipSourceLabel": "Source", + "seasonalCalenderDataNotAvailable": "Data is not available!" + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/SeasonalCalender/index.tsx b/app/src/views/Background/RiskAnalysis/SeasonalCalender/index.tsx new file mode 100644 index 0000000000..e54e0760d5 --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/SeasonalCalender/index.tsx @@ -0,0 +1,249 @@ +import { useMemo } from 'react'; +import { + Container, + Message, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { splitList } from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; + +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +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', +}; + +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} + + + + + + + )} + /> +
+ )); +} + +interface Props { + countryId: string | undefined; +} + +function SeasonalCalender(props: Props) { + const { countryId } = props; + + const strings = useTranslation(i18n); + + const { + pending: databankResponsePending, + response: databankResponse, + } = useRequest({ + url: '/api/v2/country/{id}/databank/', + skip: isNotDefined(countryId), + pathVariables: isDefined(countryId) ? { + id: Number(countryId), + } : undefined, + }); + // 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 = useMemo( + () => ( + 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, index) => ({ + key: index, + 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), + ) + ), + [databankResponse, monthToOrderMap], + ); + + const eventTypeGroupedData = mapToList( + listToGroupList( + seasonalCalendarData, + ({ event_type }) => event_type[0]!, + ), + (list, key) => ({ + event_type: key, + events: list, + }), + ); + + return ( + + {isDefined(databankResponse) && ( +
+
+ {monthsWithOrder.map( + ({ month, order }) => ( +
+ {month.substring(0, 3)} +
+ ), + )} +
+ {(isNotDefined(eventTypeGroupedData) + || eventTypeGroupedData.length === 0 + ) && ( + + )} + {eventTypeGroupedData?.map( + ({ event_type, events }) => ( +
+ {events.map((event) => ( + + ))} +
+ ), + )} +
+ )} +
+ ); +} + +export default SeasonalCalender; diff --git a/app/src/views/Background/RiskAnalysis/SeasonalCalender/styles.module.css b/app/src/views/Background/RiskAnalysis/SeasonalCalender/styles.module.css new file mode 100644 index 0000000000..abdf1d5dec --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/SeasonalCalender/styles.module.css @@ -0,0 +1,33 @@ +.seasonal-calendar-content { + background-image: + linear-gradient(to right, var(--go-ui-color-separator) var(--go-ui-width-separator-thin), transparent var(--go-ui-width-separator-thin)), + linear-gradient(to bottom, var(--go-ui-color-separator) var(--go-ui-width-separator-thin), transparent var(--go-ui-width-separator-thin)); + background-size: calc(100% / 12); + + .event-list { + display: grid; + border-bottom: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + padding: var(--go-ui-spacing-md) 0; + grid-template-columns: repeat(12, 1fr); + gap: var(--go-ui-spacing-3xs); + + .month-name { + text-align: center; + font-size: var(--go-ui-font-size-sm); + + @media screen and (max-width: 30rem) { + transform: rotate(-30deg) translateX(-10%) translateY(20%); + padding: 0; + font-size: var(--go-ui-font-size-2xs); + } + } + + .event { + padding: var(--go-ui-spacing-2xs) var(--go-ui-spacing-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--go-ui-font-size-xs); + } + } +} \ No newline at end of file diff --git a/app/src/views/Background/RiskAnalysis/index.tsx b/app/src/views/Background/RiskAnalysis/index.tsx new file mode 100644 index 0000000000..8fe532f65d --- /dev/null +++ b/app/src/views/Background/RiskAnalysis/index.tsx @@ -0,0 +1,43 @@ +import { useRiskRequest } from '#utils/restRequest'; + +import PastEventsChart from './PastEventsChart'; +import RiskBarChart from './RiskBarChart'; +import SeasonalCalender from './SeasonalCalender'; + +interface Props { + countryId: string | undefined; + countryIso3?: string | undefined; +} + +function RiskAnalysis({ countryId, countryIso3 }: Props) { + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + url: '/api/v1/country-seasonal/', + query: { + iso3: countryIso3?.toLowerCase(), + }, + skip: !countryIso3, + }); + + const riskResponse = countryRiskResponse?.[0]; + + return ( + <> + + + + + ); +} + +export default RiskAnalysis; diff --git a/app/src/views/Background/index.tsx b/app/src/views/Background/index.tsx new file mode 100644 index 0000000000..ada5f2456e --- /dev/null +++ b/app/src/views/Background/index.tsx @@ -0,0 +1,27 @@ +import { useOutletContext } from 'react-router-dom'; +import { isDefined } from '@togglecorp/fujs'; + +import TabPage from '#components/TabPage'; +import { type CountryOutletContext } from '#utils/outletContext'; + +import RiskAnalysis from './RiskAnalysis'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryResponse } = useOutletContext(); + + const countryId = isDefined(countryResponse?.id) + ? String(countryResponse.id) + : undefined; + + return ( + + + + ); +} + +Component.displayName = 'Background'; diff --git a/app/src/views/Emergency/i18n.json b/app/src/views/Emergency/i18n.json index 0e4d37c8c1..67a3fa7979 100644 --- a/app/src/views/Emergency/i18n.json +++ b/app/src/views/Emergency/i18n.json @@ -14,6 +14,8 @@ "emergencies": "Emergencies", "emergencyEdit": "Edit Event", "emergencyFollow":"Follow", - "emergencyUnfollow":"Unfollow" + "emergencyUnfollow":"Unfollow", + "actionsSummary": "Actions Summary", + "background": "Background" } } diff --git a/app/src/views/Emergency/index.tsx b/app/src/views/Emergency/index.tsx index 8be8a851fa..103f132844 100644 --- a/app/src/views/Emergency/index.tsx +++ b/app/src/views/Emergency/index.tsx @@ -345,6 +345,20 @@ export function Component() { > {strings.emergencyTabReports} + {/* TODO: Add if there is fieldReport in emergencyResponse */} + + {strings.actionsSummary} + + {/* TODO: Add if there is fieldReport in emergencyResponse */} + + {strings.background} + {(emergencyResponse?.response_activity_count ?? 0) > 0 && ( ; +type FieldReportListItem = NonNullable[number]; + +interface Props { + fieldReportResponse: FieldReportListItem | undefined; +} + +function FieldReportKeyFigure(props: Props) { + const { fieldReportResponse } = props; + const strings = useTranslation(i18n); + + return ( + + + + + + + + + + + + ); +} + +export default FieldReportKeyFigure; diff --git a/app/src/views/EmergencyDetails/FieldReportEmergency/index.tsx b/app/src/views/EmergencyDetails/FieldReportEmergency/index.tsx new file mode 100644 index 0000000000..0d2f273f2f --- /dev/null +++ b/app/src/views/EmergencyDetails/FieldReportEmergency/index.tsx @@ -0,0 +1,37 @@ +import { isNotDefined } from '@togglecorp/fujs'; + +import EmergencyOverview from '#components/EmergencyOverviewTab'; +import TabPage from '#components/TabPage'; +import { useRequest } from '#utils/restRequest'; + +import FieldReportKeyFigure from './FieldReportKeyFigure'; + +function FieldReportEmergency() { + // TODO: Add field report from event API + const fieldReportId = '123'; + + const { + response: fieldReportResponse, + } = useRequest({ + skip: isNotDefined(fieldReportId), + url: '/api/v2/field-report/{id}/', + pathVariables: { + id: Number(fieldReportId), + }, + }); + + return ( + + {/* TODO: Fix type by adding new API in the component */} + + {/* TODO: Fix type by adding new API in the component */} + + + ); +} + +export default FieldReportEmergency; diff --git a/app/src/views/EmergencyDetails/index.tsx b/app/src/views/EmergencyDetails/index.tsx index 56eb8eb8b6..12ef0589c7 100644 --- a/app/src/views/EmergencyDetails/index.tsx +++ b/app/src/views/EmergencyDetails/index.tsx @@ -1,436 +1,19 @@ -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 EmergencyOverview from '#components/EmergencyOverviewTab'; 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} - - )} - - - - ))} - - - ))} - - - )} +
); }