diff --git a/app/package.json b/app/package.json index ea3ee7d2a9..fd752598d3 100644 --- a/app/package.json +++ b/app/package.json @@ -48,6 +48,7 @@ "@togglecorp/toggle-request": "^1.0.0-beta.3", "@turf/bbox": "^6.5.0", "@turf/buffer": "^6.5.0", + "diff": "^8.0.2", "exceljs": "^4.3.0", "file-saver": "^2.0.5", "html-to-image": "^1.11.11", diff --git a/app/src/App/routes/index.tsx b/app/src/App/routes/index.tsx index b25ad826a3..ec94e3d3b5 100644 --- a/app/src/App/routes/index.tsx +++ b/app/src/App/routes/index.tsx @@ -319,6 +319,50 @@ const emergencyAdditionalInfo = customWrapRoute({ }, }); +type DefaultDrefDetailChild = 'dref-detail'; +const drefProcessLayout = customWrapRoute({ + parent: rootLayout, + path: 'dref-process', + forwardPath: 'dref-detail' satisfies DefaultDrefDetailChild, + component: { + render: () => import('#views/DrefProcess'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'DREF Process', + visibility: 'anything', + }, +}); + +const drefDetail = customWrapRoute({ + parent: drefProcessLayout, + path: 'dref-detail' satisfies DefaultDrefDetailChild, + component: { + render: () => import('#views/DrefDetail'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Response and Imminent DREF', + visibility: 'anything', + }, +}); + +const eapDetail = customWrapRoute({ + parent: drefProcessLayout, + path: 'eap-detail', + component: { + render: () => import('#views/EarlyActionProtocols'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Early Action Protocols', + visibility: 'anything', + }, +}); + type DefaultPreparednessChild = 'global-summary'; const preparednessLayout = customWrapRoute({ parent: rootLayout, @@ -715,6 +759,49 @@ const accountMyFormsThreeW = customWrapRoute({ }, }); +const accountMyFormsEap = customWrapRoute({ + parent: accountMyFormsLayout, + path: 'eap-applications', + component: { + render: () => import('#views/AccountMyFormsEap'), + props: {}, + }, + context: { + title: 'Account - EAP Applications', + visibility: 'is-authenticated', + permissions: ({ isGuestUser }) => !isGuestUser, + }, +}); + +const fullEapForm = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/full', + component: { + render: () => import('#views/EapFullForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'EAP Full Forms', + visibility: 'is-authenticated', + permissions: ({ isGuestUser }) => !isGuestUser, + }, +}); + +const simplifiedEapForm = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/simplified', + component: { + render: () => import('#views/SimplifiedEapForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Simplified EAP Form', + visibility: 'is-authenticated', + }, +}); + const accountNotifications = customWrapRoute({ parent: accountLayout, path: 'notifications', @@ -1094,6 +1181,65 @@ const fieldReportDetails = customWrapRoute({ }, }); +type DefaultEapRegistrationChild = 'new'; +const eapRegistrationLayout = customWrapRoute({ + parent: rootLayout, + path: 'eap-registration', + forwardPath: 'new' satisfies DefaultEapRegistrationChild, + component: { + render: () => import('#views/EapRegistration'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'EAP Process', + visibility: 'is-authenticated', + }, +}); + +const newEapDevelopmentRegistration = customWrapRoute({ + parent: eapRegistrationLayout, + path: 'new' satisfies DefaultEapRegistrationChild, + component: { + render: () => import('#views/EapRegistration'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New EAP Development Registration', + visibility: 'is-authenticated', + permissions: ({ isGuestUser }) => !isGuestUser, + }, +}); + +const eapDevelopmentRegistrationForm = customWrapRoute({ + parent: eapRegistrationLayout, + path: ':eapId/', + component: { + render: () => import('#views/EapRegistration'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'View EAP', + visibility: 'is-authenticated', + }, +}); + +const simplifiedEapExport = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/export', + component: { + render: () => import('#views/SimplifiedEapExport'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Simplified EAP Export', + visibility: 'is-authenticated', + }, +}); + type DefaultPerProcessChild = 'new'; const perProcessLayout = customWrapRoute({ parent: rootLayout, @@ -1317,6 +1463,7 @@ const wrappedRoutes = { accountMyFormsPer, accountMyFormsDref, accountMyFormsThreeW, + accountMyFormsEap, resources, search, allThreeWProject, @@ -1353,6 +1500,9 @@ const wrappedRoutes = { termsAndConditions, operationalLearning, montandonLandingPage, + newEapDevelopmentRegistration, + fullEapForm, + simplifiedEapForm, ...regionRoutes, ...countryRoutes, ...surgeRoutes, @@ -1363,6 +1513,12 @@ const wrappedRoutes = { // Redirects preparednessOperationalLearning, obsoleteFieldReportDetails, + drefDetail, + eapDetail, + drefProcessLayout, + eapRegistrationLayout, + eapDevelopmentRegistrationForm, + simplifiedEapExport, }; export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes)); diff --git a/app/src/components/Navbar/i18n.json b/app/src/components/Navbar/i18n.json index 0132def3ce..e0ed399edb 100644 --- a/app/src/components/Navbar/i18n.json +++ b/app/src/components/Navbar/i18n.json @@ -40,6 +40,7 @@ "userMenuDrefProcessDescription":"Disaster Response Emergency Fund (DREF) is the quickest way of getting funding directly to local humanitarian actors. Use one of the links below to submit a DREF Application or an update.", "userMenuCreateDrefApplication":"Create DREF Application", "myDrefApplications": "My DREF Applications", + "earlyActionProtocols": "Early Action Protocols (EAP)", "userMenuSurge":"The section displays the summary of deployments within current and ongoing emergencies. Login to see available details", "userMenuSurgeGlobalOverview":"Surge Global Overview", "userMenuOperationalToolbox":"Operational Toolbox", diff --git a/app/src/components/Navbar/index.tsx b/app/src/components/Navbar/index.tsx index be067b2ef4..0786bdea76 100644 --- a/app/src/components/Navbar/index.tsx +++ b/app/src/components/Navbar/index.tsx @@ -370,12 +370,20 @@ function Navbar(props: Props) { {strings.myDrefApplications} + + {strings.earlyActionProtocols} + ['schemas']['ExportStatusEnum']; +type ExportBody = GoApiBody<'/api/v2/pdf-export/', 'POST'>; const EXPORT_STATUS_PENDING = 0 satisfies ExportStatusEnum; const EXPORT_STATUS_COMPLETED = 1 satisfies ExportStatusEnum; @@ -45,8 +49,10 @@ function PerExportModal(props: Props) { export_id: Number(perId), export_type: 'per' as const, per_country: Number(countryId), - is_pga: false, - }), + is_pga: undefined, + version: undefined, + diff: undefined, + } satisfies ExportBody), [perId, countryId], ); diff --git a/app/src/components/domain/Admin2Input/index.tsx b/app/src/components/domain/Admin2Input/index.tsx new file mode 100644 index 0000000000..a5be123493 --- /dev/null +++ b/app/src/components/domain/Admin2Input/index.tsx @@ -0,0 +1,337 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { CloseLineIcon } from '@ifrc-go/icons'; +import { + Button, + ButtonLayout, + Container, + InputError, + ListView, + Modal, +} from '@ifrc-go/ui'; +import { useBooleanState } from '@ifrc-go/ui/hooks'; +import { isNotDefined } from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import turfBbox from '@turf/bbox'; +import { + type FillLayer, + type LineLayer, + type MapboxGeoJSONFeature, + type SymbolLayer, +} from 'mapbox-gl'; + +import GoMapContainer from '#components/GoMapContainer'; +import useCountry from '#hooks/domain/useCountry'; +import { + COLOR_BLACK, + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, + COLOR_TEXT, + COLOR_TEXT_ON_DARK, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; + +import BaseMap from '../BaseMap'; + +interface Props { + name: NAME; + value: number[] | null | undefined; + onChange: (newValue: number[] | undefined, name: NAME) => void; + countryId: number; + error?: React.ReactNode; +} + +function Admin2Input(props: Props) { + const { + name, + value, + onChange, + countryId, + error, + } = props; + + const countryDetails = useCountry({ id: countryId }); + const iso3 = countryDetails?.iso3; + + const bounds = useMemo(() => { + if (!countryDetails) { + return undefined; + } + + return turfBbox(countryDetails.bbox); + }, [ + countryDetails, + ]); + + const adminOneLabelLayerOptions: Omit = useMemo(() => ({ + type: 'symbol', + paint: { + 'text-opacity': [ + 'match', + ['get', 'country_id'], + countryId, + 1, + 0, + ], + }, + layout: { + 'text-offset': [ + 0, 1, + ], + visibility: 'visible', + }, + }), [countryId]); + + const adminTwoLineLayerOptions: Omit | undefined = useMemo(() => { + if (!iso3) { + return undefined; + } + + return { + type: 'line', + 'source-layer': `go-admin2-${iso3}-staging`, + paint: { + 'line-color': COLOR_BLACK, + 'line-opacity': 1, + }, + layout: { + visibility: 'visible', + }, + }; + }, [iso3]); + + const adminTwoFillLayerOptions = useMemo((): Omit | undefined => { + if (!iso3) { + return undefined; + } + const defaultColor: NonNullable['fill-color'] = [ + 'case', + ['boolean', ['feature-state', 'hovered'], false], + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, + ]; + const options: Omit = { + type: 'fill', + 'source-layer': `go-admin2-${iso3}-staging`, + paint: { + 'fill-color': (!value || value.length <= 0) + ? defaultColor + : [ + 'match', + ['get', 'id'], + ...value.map((admin2Id) => [ + admin2Id, + COLOR_PRIMARY_RED, + ]).flat(), + defaultColor, + ], + 'fill-outline-color': COLOR_DARK_GREY, + 'fill-opacity': 1, + }, + layout: { + visibility: 'visible', + }, + }; + return options; + }, [iso3, value]); + + const adminTwoLabelLayerOptions = useMemo((): Omit | undefined => { + const textColor: NonNullable['text-color'] = ( + value && value.length > 0 + ? [ + 'match', + ['get', 'id'], + ...value.map((admin2Id) => [ + admin2Id, + COLOR_TEXT_ON_DARK, + ]).flat(), + COLOR_TEXT, + ] + : COLOR_TEXT + ); + + const options: Omit = { + type: 'symbol', + 'source-layer': `go-admin2-${iso3}-centroids`, + paint: { + 'text-color': textColor, + 'text-opacity': 1, + }, + layout: { + 'text-field': ['get', 'name'], + 'text-anchor': 'center', + 'text-size': 10, + }, + }; + return options; + }, [iso3, value]); + + const handleAdmin2Click = useCallback((clickedFeature: MapboxGeoJSONFeature) => { + const properties = clickedFeature?.properties as { + id: number; + admin1_id: number; + code: string; + admin1_name: string; + name?: string; + }; + if (isNotDefined(properties.id)) { + return false; + } + + const valueIndex = value?.findIndex((admin2Id) => admin2Id === properties.id) ?? -1; + + if (valueIndex === -1) { + onChange([...(value ?? []), properties.id], name); + } else { + onChange(value?.toSpliced(valueIndex, 1), name); + } + + return false; + }, [value, name, onChange]); + + const [ + showModal, + { + setTrue: setShowModalTrue, + setFalse: setShowModalFalse, + }, + ] = useBooleanState(false); + + const removeSelection = useCallback((admin2Id: number) => { + const index = value?.findIndex((selectedAdmin2Id) => selectedAdmin2Id === admin2Id) ?? -1; + + if (index !== -1) { + onChange(value?.toSpliced(index, 1), name); + } + }, [value, onChange, name]); + + return ( + + + Select areas + + )} + withCompactMessage + empty={!value || value.length === 0} + > + + {value?.map((admin2Id) => ( + + + + )} + > + {admin2Id} + + ))} + + + {error && ( + + {error} + + )} + {showModal && ( + + Done + + )} + > + + )} + > + + {bounds && ( + + )} + {/* eslint-disable-next-line max-len */} + {adminTwoFillLayerOptions && adminTwoLineLayerOptions && adminTwoLabelLayerOptions && ( + <> + + + + + + + + + )} + + + )} + + ); +} + +export default Admin2Input; diff --git a/app/src/components/domain/BaseMap/index.tsx b/app/src/components/domain/BaseMap/index.tsx index ec4a793584..125d6138fc 100644 --- a/app/src/components/domain/BaseMap/index.tsx +++ b/app/src/components/domain/BaseMap/index.tsx @@ -28,7 +28,7 @@ export type Props = Omit & { withDisclaimer?: boolean; } & Partial>; -function BaseMap(props: Props) { +function BaseMapWithoutErrorBoundary(props: Props) { const { baseLayers, mapStyle, @@ -101,7 +101,7 @@ function BaseMap(props: Props) { ); } -function BaseMapWithErrorBoundary(props: Props) { +function BaseMap(props: Props) { return ( )} > - @@ -118,4 +118,4 @@ function BaseMapWithErrorBoundary(props: Props) { ); } -export default BaseMapWithErrorBoundary; +export default BaseMap; diff --git a/app/src/components/domain/DrefExportModal/index.tsx b/app/src/components/domain/DrefExportModal/index.tsx index 0bcebcca03..837b67168f 100644 --- a/app/src/components/domain/DrefExportModal/index.tsx +++ b/app/src/components/domain/DrefExportModal/index.tsx @@ -24,6 +24,7 @@ import { type TypeOfDrefEnum, } from '#utils/constants'; import { + type GoApiBody, useLazyRequest, useRequest, } from '#utils/restRequest'; @@ -33,6 +34,7 @@ import styles from './styles.module.css'; type ExportTypeEnum = components<'read'>['schemas']['ExportTypeEnum']; type ExportStatusEnum = components<'read'>['schemas']['ExportStatusEnum']; +type ExportBody = GoApiBody<'/api/v2/pdf-export/', 'POST'>; const EXPORT_STATUS_PENDING = 0 satisfies ExportStatusEnum; const EXPORT_STATUS_COMPLETED = 1 satisfies ExportStatusEnum; @@ -83,9 +85,11 @@ function DrefExportModal(props: Props) { export_id: id, export_type: type, is_pga: includePga, - selector: '#pdf-preview-ready', + // selector: '#pdf-preview-ready', per_country: undefined, - }; + version: undefined, + diff: undefined, + } satisfies ExportBody; }, [ id, diff --git a/app/src/components/domain/EapExportModal/i18n.json b/app/src/components/domain/EapExportModal/i18n.json new file mode 100644 index 0000000000..19fee027c1 --- /dev/null +++ b/app/src/components/domain/EapExportModal/i18n.json @@ -0,0 +1,13 @@ +{ + "namespace": "eapExportModal", + "strings": { + "exportTitle": "Export EAP", + "preparingExport": "Preparing for export...", + "waitingExport": "Waiting for the export to complete...", + "exportFailed": "Export failed", + "exportSuccessfully": "Export completed successfully!", + "downloadLinkDescription": "Click on the download link below!", + "downloadLinkLabel": "Download PDF", + "failureToExportMessage":"Failed to export PDF." + } +} diff --git a/app/src/components/domain/EapExportModal/index.tsx b/app/src/components/domain/EapExportModal/index.tsx new file mode 100644 index 0000000000..669d7a6b65 --- /dev/null +++ b/app/src/components/domain/EapExportModal/index.tsx @@ -0,0 +1,197 @@ +import { + useEffect, + useMemo, + useState, +} from 'react'; +import { DownloadLineIcon } from '@ifrc-go/icons'; +import { + Message, + Modal, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type components } from '#generated/types'; +import useAlert from '#hooks/useAlert'; +import { EAP_TYPE_SIMPLIFIED } from '#utils/constants'; +import { + type GoApiBody, + useLazyRequest, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type EapType = components['schemas']['EapEapTypeEnumKey']; +type ExportStatusEnum = components<'read'>['schemas']['ExportStatusEnum']; + +type ExportBody = GoApiBody<'/api/v2/pdf-export/', 'POST'>; + +const EXPORT_STATUS_PENDING = 0 satisfies ExportStatusEnum; +const EXPORT_STATUS_COMPLETED = 1 satisfies ExportStatusEnum; +const EXPORT_STATUS_ERRORED = 2 satisfies ExportStatusEnum; + +interface Props { + eapId: number; + eapType: EapType; + version?: number; + onClose: () => void; + diff?: boolean; +} + +function EapExportModal(props: Props) { + const { + eapId, + eapType, + onClose, + version, + diff, + } = props; + + const strings = useTranslation(i18n); + const alert = useAlert(); + + const [exportId, setExportId] = useState(); + + const exportTriggerBody = useMemo( + () => ({ + export_id: eapId, + export_type: eapType === EAP_TYPE_SIMPLIFIED ? 'simplified' : 'full', + selector: '#pdf-preview-ready', + is_pga: undefined, + per_country: undefined, + version, + diff, + }), + [eapId, eapType, version, diff], + ); + + const { + pending: exportPending, + error: exportError, + trigger: triggerExport, + } = useLazyRequest({ + method: 'POST', + useCurrentLanguageForMutation: true, + url: '/api/v2/pdf-export/', + body: exportTriggerBody, + onSuccess: (response) => { + if (isDefined(response.id)) { + setExportId(response.id); + } + }, + onFailure: () => { + alert.show( + strings.failureToExportMessage, + { variant: 'danger' }, + ); + }, + }); + + useEffect(() => { + triggerExport(null); + }, [triggerExport]); + + const { + pending: exportStatusPending, + response: exportStatusResponse, + error: exportStatusError, + } = useRequest({ + skip: isNotDefined(exportId), + url: '/api/v2/pdf-export/{id}/', + // FIXME: typings should be fixed in the server + pathVariables: isDefined(exportId) ? ({ id: String(exportId) }) : undefined, + shouldPoll: (poll) => { + if (poll?.errored || poll?.value?.status !== EXPORT_STATUS_PENDING) { + return -1; + } + + return 5000; + }, + }); + + const exportStatus = useMemo(() => { + if (exportPending) { + return 'PREPARE'; + } + + if (exportStatusPending || exportStatusResponse?.status === EXPORT_STATUS_PENDING) { + return 'WAITING'; + } + + if (isDefined(exportStatusError) + || isDefined(exportError) + || (isDefined(exportStatusResponse) + && exportStatusResponse.status === EXPORT_STATUS_ERRORED) + ) { + return 'FAILED'; + } + + if (isDefined(exportStatusResponse) + && isDefined(exportStatusResponse.status === EXPORT_STATUS_COMPLETED) + && isDefined(exportStatusResponse.pdf_file) + ) { + return 'SUCCESS'; + } + + return 'NOT_STARTED'; + }, [ + exportPending, + exportStatusError, + exportError, + exportStatusPending, + exportStatusResponse, + ]); + + return ( + + {exportStatus === 'PREPARE' && ( + + )} + {exportStatus === 'WAITING' && ( + + )} + {exportStatus === 'FAILED' && ( + + )} + {exportStatus === 'SUCCESS' && ( + } + external + > + {strings.downloadLinkLabel} + + )} + /> + )} + + ); +} + +export default EapExportModal; diff --git a/app/src/components/domain/EapExportModal/styles.module.css b/app/src/components/domain/EapExportModal/styles.module.css new file mode 100644 index 0000000000..2de1f5fc87 --- /dev/null +++ b/app/src/components/domain/EapExportModal/styles.module.css @@ -0,0 +1,5 @@ +.dref-export-modal { + .icon { + font-size: var(--go-ui-height-icon-multiplier); + } +} diff --git a/app/src/components/domain/GoMultiFileInput/index.tsx b/app/src/components/domain/GoMultiFileInput/index.tsx index d4f8f74916..c15ffb3603 100644 --- a/app/src/components/domain/GoMultiFileInput/index.tsx +++ b/app/src/components/domain/GoMultiFileInput/index.tsx @@ -31,7 +31,7 @@ import { transformObjectError } from '#utils/restRequest/error'; import i18n from './i18n.json'; -export type SupportedPaths = '/api/v2/per-file/multiple/' | '/api/v2/dref-files/multiple/' | '/api/v2/flash-update-file/multiple/'; +export type SupportedPaths = '/api/v2/per-file/multiple/' | '/api/v2/dref-files/multiple/' | '/api/v2/flash-update-file/multiple/' | '/api/v2/eap-file/multiple/'; interface FileUploadResult { id: number; diff --git a/app/src/components/domain/GoSingleFileInput/index.tsx b/app/src/components/domain/GoSingleFileInput/index.tsx index dc885dfe7b..af10bd7883 100644 --- a/app/src/components/domain/GoSingleFileInput/index.tsx +++ b/app/src/components/domain/GoSingleFileInput/index.tsx @@ -23,7 +23,7 @@ import { transformObjectError } from '#utils/restRequest/error'; import i18n from './i18n.json'; -export type SupportedPaths = '/api/v2/per-file/' | '/api/v2/dref-files/' | '/api/v2/flash-update-file/' | '/api/v2/per-document-upload/'; +export type SupportedPaths = '/api/v2/per-file/' | '/api/v2/dref-files/' | '/api/v2/flash-update-file/' | '/api/v2/per-document-upload/' | '/api/v2/eap-file/'; type Props = Omit, 'value'> & { name: NAME; diff --git a/app/src/components/domain/ImageWithCaptionInput/i18n.json b/app/src/components/domain/ImageWithCaptionInput/i18n.json index 2ac3a6a3ec..f10ddae406 100644 --- a/app/src/components/domain/ImageWithCaptionInput/i18n.json +++ b/app/src/components/domain/ImageWithCaptionInput/i18n.json @@ -1,7 +1,8 @@ { "namespace": "imageWithCaptionInput", "strings": { - "imageWithCaptionEnterCaption": "Enter Caption", - "imageWithCaptionPreview": "preview" + "captionInputPlaceholder": "Enter Caption", + "previewFallbackText": "Preview not available", + "defaultLabel": "Select an image" } -} \ No newline at end of file +} diff --git a/app/src/components/domain/ImageWithCaptionInput/index.tsx b/app/src/components/domain/ImageWithCaptionInput/index.tsx index cf74833d98..ce5df74d19 100644 --- a/app/src/components/domain/ImageWithCaptionInput/index.tsx +++ b/app/src/components/domain/ImageWithCaptionInput/index.tsx @@ -21,22 +21,28 @@ import NonFieldError from '#components/NonFieldError'; import i18n from './i18n.json'; -type Value = { - id?: number | undefined; +type InputValue = { + id?: number; client_id: string; - caption?: string | undefined; + caption?: string | null; }; -interface Props { +type OutputValue = { + id?: number; + client_id: string; + caption?: string; +} + +interface Props { className?: string; - name: N; + name: NAME; url: SupportedPaths; - value: Value | null | undefined; - onChange: (value: SetValueArg | undefined, name: N) => void; - error: ObjectError | undefined; + value: InputValue | null | undefined; + onChange: (value: SetValueArg | undefined, name: NAME) => void; + error: ObjectError | undefined; fileIdToUrlMap: Record; setFileIdToUrlMap?: React.Dispatch>>; - label: React.ReactNode; + label?: React.ReactNode; before?: React.ReactNode; after?: React.ReactNode; disabled?: boolean; @@ -46,6 +52,8 @@ interface Props { // FIXME: Move this to components function ImageWithCaptionInput(props: Props) { + const strings = useTranslation(i18n); + const { className, readOnly, @@ -56,15 +64,13 @@ function ImageWithCaptionInput(props: Props) setFileIdToUrlMap, onChange, error: formError, - label, + label = strings.defaultLabel, before, after, disabled, useCurrentLanguageForMutation, } = props; - const strings = useTranslation(i18n); - const setFieldValue = useFormObject( name, onChange, @@ -113,13 +119,14 @@ function ImageWithCaptionInput(props: Props) // FIXME: Make Go single file input with preview description={isDefined(fileUrl) ? ( {strings.imageWithCaptionPreview} ) : undefined} clearable useCurrentLanguageForMutation={useCurrentLanguageForMutation} + error={error?.id} > {label} @@ -130,7 +137,7 @@ function ImageWithCaptionInput(props: Props) readOnly={readOnly} onChange={setFieldValue} error={error?.caption} - placeholder={strings.imageWithCaptionEnterCaption} + placeholder={strings.captionInputPlaceholder} disabled={disabled} /> )} diff --git a/app/src/components/domain/MultiImageWithCaptionInput/i18n.json b/app/src/components/domain/MultiImageWithCaptionInput/i18n.json index a9dca445a8..d454e9df57 100644 --- a/app/src/components/domain/MultiImageWithCaptionInput/i18n.json +++ b/app/src/components/domain/MultiImageWithCaptionInput/i18n.json @@ -1,8 +1,9 @@ { "namespace": "multiImageWithCaptionInput", "strings": { - "removeImagesButtonTitle": "Remove", - "imagePreviewAlt": "preview", - "enterCaptionPlaceholder": "Enter Caption" + "removeImageButtonTitle": "Remove", + "imagePreviewFallbackText": "Preview not available", + "defaultLabel": "Select images", + "captionInputPlaceholder": "Enter caption" } -} \ No newline at end of file +} diff --git a/app/src/components/domain/MultiImageWithCaptionInput/index.tsx b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx index c7e2be8461..2222db54b4 100644 --- a/app/src/components/domain/MultiImageWithCaptionInput/index.tsx +++ b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx @@ -29,22 +29,28 @@ import NonFieldError from '#components/NonFieldError'; import i18n from './i18n.json'; import styles from './styles.module.css'; -type Value = { +type InputValue = { + id?: number; client_id: string; + caption?: string | null; +}; + +type OutputValue = { id?: number; + client_id: string; caption?: string; -}; +} interface Props { className?: string; name: N; url: SupportedPaths; - value: Value[] | null | undefined; - onChange: (value: SetValueArg, name: N) => void; - error: ArrayError | undefined; + value: InputValue[] | null | undefined; + onChange: (value: SetValueArg, name: N) => void; + error: ArrayError | undefined; fileIdToUrlMap: Record; setFileIdToUrlMap?: React.Dispatch>>; - label: React.ReactNode; + label?: React.ReactNode; readOnly?: boolean; before?: React.ReactNode; after?: React.ReactNode; @@ -54,6 +60,8 @@ interface Props { // FIXME: Move this to components function MultiImageWithCaptionInput(props: Props) { + const strings = useTranslation(i18n); + const { className, name, @@ -63,7 +71,7 @@ function MultiImageWithCaptionInput(props: Prop setFileIdToUrlMap, onChange, error: formError, - label, + label = strings.defaultLabel, readOnly, before, after, @@ -71,8 +79,6 @@ function MultiImageWithCaptionInput(props: Prop useCurrentLanguageForMutation = false, } = props; - const strings = useTranslation(i18n); - const error = getErrorObject(formError); const { @@ -185,8 +191,8 @@ function MultiImageWithCaptionInput(props: Prop (props: Prop /> {strings.imagePreviewAlt} @@ -206,7 +212,7 @@ function MultiImageWithCaptionInput(props: Prop value={fileValue?.caption} onChange={handleCaptionChange} error={imageError?.caption} - placeholder={strings.enterCaptionPlaceholder} + placeholder={strings.captionInputPlaceholder} readOnly={readOnly} disabled={disabled} /> diff --git a/app/src/components/domain/OperationActivityInput/TimeSpanCheck/index.tsx b/app/src/components/domain/OperationActivityInput/TimeSpanCheck/index.tsx new file mode 100644 index 0000000000..d94c47b047 --- /dev/null +++ b/app/src/components/domain/OperationActivityInput/TimeSpanCheck/index.tsx @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { + type CheckboxProps, + InputError, +} from '@ifrc-go/ui'; +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +function TimeSpanCheck(props: CheckboxProps) { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + checkmarkClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + checkmarkContainerClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + inputClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + labelContainerClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + description, + + className: classNameFromProps, + disabled, + error, + indeterminate, + invertedLogic = false, + label, + name, + onChange, + readOnly, + tooltip, + value, + withBackground, + withDarkBackground, + ...otherProps + } = props; + + const handleChange = useCallback( + (e: React.FormEvent) => { + const v = e.currentTarget.checked; + onChange( + invertedLogic ? !v : v, + name, + ); + }, + [name, onChange, invertedLogic], + ); + + const checked = invertedLogic ? !value : value; + + const className = _cs( + styles.checkbox, + classNameFromProps, + !indeterminate && checked && styles.checked, + withBackground && styles.withBackground, + withDarkBackground && styles.withDarkBackground, + disabled && styles.disabledCheckbox, + readOnly && styles.readOnly, + ); + + return ( + + ); +} + +export default TimeSpanCheck; diff --git a/app/src/components/domain/OperationActivityInput/TimeSpanCheck/styles.module.css b/app/src/components/domain/OperationActivityInput/TimeSpanCheck/styles.module.css new file mode 100644 index 0000000000..00ca96a56f --- /dev/null +++ b/app/src/components/domain/OperationActivityInput/TimeSpanCheck/styles.module.css @@ -0,0 +1,29 @@ +.time-span-check { + display: flex; + align-items: center; + justify-content: center; + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-gray-50); + border-radius: var(--go-ui-border-radius-md); + cursor: pointer; + padding: 0 var(--go-ui-spacing-3xs); + min-width: 1.5rem; + height: 1.5rem; + text-align: center; + font-size: var(--go-ui-font-size-sm); + + &.checked { + border-color: var(--go-ui-color-primary-red); + background-color: var(--go-ui-color-primary-red); + color: var(--go-ui-color-text-on-dark); + } + + .input { + position: absolute; + opacity: 0; + margin: 0; + padding: 0; + width: 0; + height: 0; + pointer-events: none; + } +} diff --git a/app/src/components/domain/OperationActivityInput/i18n.json b/app/src/components/domain/OperationActivityInput/i18n.json new file mode 100644 index 0000000000..56bb302145 --- /dev/null +++ b/app/src/components/domain/OperationActivityInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "operationActivityInput", + "strings": { + "operationPriorityActionLabel": "Priority action", + "operationTimeFrameLabel": "Time Frame", + "operationTimeValueLabel": "Time Value" + } +} diff --git a/app/src/components/domain/OperationActivityInput/index.tsx b/app/src/components/domain/OperationActivityInput/index.tsx new file mode 100644 index 0000000000..408118ed56 --- /dev/null +++ b/app/src/components/domain/OperationActivityInput/index.tsx @@ -0,0 +1,168 @@ +import { useCallback } from 'react'; +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + Button, + Checklist, + InlineLayout, + ListView, + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { stringValueSelector } from '@ifrc-go/ui/utils'; +import { + type ArrayError, + getErrorObject, + getErrorString, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; + +import { type OperationActivityFormFields } from './schema'; +import TimeSpanCheck from './TimeSpanCheck'; + +import i18n from './i18n.json'; + +const defaultActivityValue: OperationActivityFormFields = { + client_id: '-1', +}; + +type TimeframeOption = components['schemas']['EapTimeframeEnum']; + +function timeframeKeySelector(option: TimeframeOption) { + return option.key; +} + +const timeValueKeySelector = ( + option: { key: number; value: string }, +) => option.key; + +interface Props { + value: OperationActivityFormFields; + error: ArrayError | undefined; + onChange: (value: SetValueArg, index: number) => void; + onRemove: (index: number) => void; + index: number; + disabled?: boolean; +} + +function OperationActivityInput(props: Props) { + const { + error: errorFromProps, + onChange, + value, + index, + onRemove, + disabled, + } = props; + + const strings = useTranslation(i18n); + + const { + eap_timeframe, + eap_years_timeframe_value, + eap_months_timeframe_value, + eap_days_timeframe_value, + eap_hours_timeframe_value, + } = useGlobalEnums(); + const onFieldChange = useFormObject(index, onChange, defaultActivityValue); + + const error = (value && value.client_id && errorFromProps) + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + const getTimeValueOptions = useCallback( + (timeframe?: number) => { + switch (timeframe) { + case 10: + return eap_years_timeframe_value ?? []; + case 20: + return eap_months_timeframe_value ?? []; + case 30: + return eap_days_timeframe_value ?? []; + case 40: + return eap_hours_timeframe_value ?? []; + default: + return []; + } + }, + [ + eap_years_timeframe_value, + eap_months_timeframe_value, + eap_days_timeframe_value, + eap_hours_timeframe_value, + ], + ); + + const timeValueOptions = getTimeValueOptions(value?.timeframe); + + const handleTimeframeChange = useCallback( + (newTimeframe: TimeframeOption['key'] | undefined) => { + onFieldChange(newTimeframe, 'timeframe'); + onFieldChange([], 'time_value'); + }, + [onFieldChange], + ); + + return ( + + + + )} + > + + + + + {value?.timeframe && ( + + )} + + + + ); +} + +export default OperationActivityInput; diff --git a/app/src/components/domain/OperationActivityInput/schema.ts b/app/src/components/domain/OperationActivityInput/schema.ts new file mode 100644 index 0000000000..c6042a3e3b --- /dev/null +++ b/app/src/components/domain/OperationActivityInput/schema.ts @@ -0,0 +1,27 @@ +import { + type ObjectSchema, + type PartialForm, + undefinedValue, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; + +type OperationActivity = components['schemas']['OperationActivity']; + +export type OperationActivityFormFields = PartialForm> & { + client_id: string; +} + +type OperationActivitySchema = ObjectSchema; + +const schema: OperationActivitySchema = { + fields: (): ReturnType => ({ + client_id: {}, + id: { defaultValue: undefinedValue }, + activity: {}, + time_value: {}, + timeframe: {}, + }), +}; + +export default schema; diff --git a/app/src/components/printable/PrintableContainer/index.tsx b/app/src/components/printable/PrintableContainer/index.tsx new file mode 100644 index 0000000000..c0d248eefa --- /dev/null +++ b/app/src/components/printable/PrintableContainer/index.tsx @@ -0,0 +1,49 @@ +import { + Heading, + type HeadingProps, +} from '@ifrc-go/ui/printable'; +import { getSpacingValue } from '@ifrc-go/ui/utils'; + +import styles from './styles.module.css'; + +interface Props { + heading?: React.ReactNode; + headingLevel?: HeadingProps['level']; + breakBefore?: boolean; + breakAfter?: boolean; + children?: React.ReactNode; +} + +function PrintableContainer(props: Props) { + const { + heading, + headingLevel = 3, + breakAfter, + breakBefore, + children, + } = props; + + const spacing = getSpacingValue('3xl', -headingLevel); + + return ( + <> + {breakBefore &&
} + {heading && ( + + {heading} + + )} + {children} +
+ {breakAfter &&
} + + ); +} + +export default PrintableContainer; diff --git a/app/src/components/printable/PrintableContainer/styles.module.css b/app/src/components/printable/PrintableContainer/styles.module.css new file mode 100644 index 0000000000..904bf35cd1 --- /dev/null +++ b/app/src/components/printable/PrintableContainer/styles.module.css @@ -0,0 +1,17 @@ +.page-break { + break-before: page; + + @media screen { + margin: var(--go-ui-spacing-lg) 0; + border: var(--go-ui-width-separator-thin) dashed var(--go-ui-color-separator); + } +} + +.heading:has(+ :empty), +.heading:has(+ .heading + :empty) { + display: none; +} + +.block-spacing:has(+ :empty) { + display: none; +} diff --git a/app/src/components/printable/PrintableDataDisplay/index.tsx b/app/src/components/printable/PrintableDataDisplay/index.tsx new file mode 100644 index 0000000000..7d4075356c --- /dev/null +++ b/app/src/components/printable/PrintableDataDisplay/index.tsx @@ -0,0 +1,164 @@ +import { useMemo } from 'react'; +import { + BooleanOutput, + type BooleanOutputProps, + DateOutput, + type DateOutputProps, + NumberOutput, + type NumberOutputProps, +} from '@ifrc-go/ui'; +import { useSpacingToken } from '@ifrc-go/ui/hooks'; +import { + DEFAULT_INVALID_TEXT, + DEFAULT_PRINT_DATE_FORMAT, + fullSpacings, + gapSpacings, + paddingSpacings, + type SpacingType, +} from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +interface BaseProps { + className?: string; + label?: React.ReactNode; + strongValue?: boolean; + strongLabel?: boolean; + withoutLabelColon?: boolean; + invalidText?: React.ReactNode; + variant?: 'block' | 'inline' | 'contents'; + withPadding?: boolean; + withBackground?: boolean; + spacing?: SpacingType; +} + +interface BooleanProps extends BooleanOutputProps { + valueType: 'boolean', +} + +interface NumberProps extends NumberOutputProps { + valueType: 'number', +} + +interface DateProps extends DateOutputProps { + valueType: 'date', +} + +interface TextProps { + valueType: 'text', + value: string | null | undefined; +} + +interface NodeProps { + valueType?: never; + value?: React.ReactNode; +} + +type Props = BaseProps & ( + NodeProps | TextProps | DateProps | NumberProps | BooleanProps +); + +function PrintableDataDisplay(props: Props) { + const { + className, + label, + strongLabel, + strongValue, + withoutLabelColon, + invalidText = DEFAULT_INVALID_TEXT, + variant = 'inline', + withPadding, + withBackground, + spacing, + ...otherProps + } = props; + + const valueComponent = useMemo(() => { + if (otherProps.valueType === 'number') { + return ( + + ); + } + + if (otherProps.valueType === 'date') { + return ( + + ); + } + + if (otherProps.valueType === 'boolean') { + return ( + + ); + } + + if (!(otherProps.value instanceof Date)) { + return otherProps.value || invalidText; + } + + return invalidText; + }, [otherProps, invalidText]); + + const spacingClassName = useSpacingToken({ + spacing, + offset: -3, + modes: withPadding ? fullSpacings : gapSpacings, + }); + + const innerPaddingClassName = useSpacingToken({ + spacing, + offset: -3, + modes: paddingSpacings, + }); + + return ( +
+
+ {label} +
+
+ {valueComponent} +
+
+ ); +} + +export default PrintableDataDisplay; diff --git a/app/src/components/printable/PrintableDataDisplay/styles.module.css b/app/src/components/printable/PrintableDataDisplay/styles.module.css new file mode 100644 index 0000000000..7add934f47 --- /dev/null +++ b/app/src/components/printable/PrintableDataDisplay/styles.module.css @@ -0,0 +1,44 @@ +.printable-data-display { + &.inline-variant { + display: flex; + flex-direction: row; + } + + &.block-variant { + display: flex; + flex-direction: column; + } + + &.contents-variant { + display: contents; + } + + &.with-background { + background-color: var(--go-ui-color-background); + + &.contents-variant { + .value, + .label { + background-color: var(--go-ui-color-background); + } + } + } + + .label { + &.with-colon::after { + content: ':'; + } + } + + .value { + &.text-type { + text-align: justify; + white-space: pre-wrap; + } + } + + .strong { + color: var(--go-ui-color-black); + font-weight: var(--go-ui-font-weight-semibold); + } +} diff --git a/app/src/components/printable/PrintableDescription/index.tsx b/app/src/components/printable/PrintableDescription/index.tsx new file mode 100644 index 0000000000..1843a9402a --- /dev/null +++ b/app/src/components/printable/PrintableDescription/index.tsx @@ -0,0 +1,88 @@ +import { + Fragment, + useMemo, +} from 'react'; +import { + _cs, + isNotDefined, +} from '@togglecorp/fujs'; +import { diffSentences } from 'diff'; + +import styles from './styles.module.css'; + +interface Props { + value?: string | null; + className?: string; + prevValue?: string | null; +} + +function PrintableDescription(props: Props) { + const { + className, + value, + prevValue, + } = props; + + const diff = useMemo(() => { + if (isNotDefined(value) || isNotDefined(prevValue)) { + return undefined; + } + + return diffSentences(prevValue, value); + }, [value, prevValue]); + + if (isNotDefined(diff)) { + return ( +
+ {value} +
+ ); + } + + return ( +
+ {diff.map((part, index) => { + const { added, removed, value: partValue } = part; + + if (added) { + return ( + + {partValue} + + ); + } + + if (removed) { + return ( + + {partValue} + + ); + } + + return ( + // eslint-disable-next-line react/no-array-index-key + + {partValue} + + ); + })} +
+ ); +} + +export default PrintableDescription; diff --git a/app/src/components/printable/PrintableDescription/styles.module.css b/app/src/components/printable/PrintableDescription/styles.module.css new file mode 100644 index 0000000000..a52f405f09 --- /dev/null +++ b/app/src/components/printable/PrintableDescription/styles.module.css @@ -0,0 +1,18 @@ +.printable-description { + text-align: justify; + white-space: pre-wrap; + overflow-wrap: break-word; + + &.with-diff-view { + .added { + background-color: color-mix(in srgb, var(--go-ui-color-green) 10%, transparent); + color: var(--go-ui-color-green); + } + + .removed { + background-color: color-mix(in srgb, var(--go-ui-color-red) 10%, transparent); + text-decoration: line-through; + color: var(--go-ui-color-red); + } + } +} diff --git a/app/src/components/printable/PrintablePage/index.tsx b/app/src/components/printable/PrintablePage/index.tsx new file mode 100644 index 0000000000..66c9583459 --- /dev/null +++ b/app/src/components/printable/PrintablePage/index.tsx @@ -0,0 +1,101 @@ +import React, { + useEffect, + useRef, + useState, +} from 'react'; +import { Heading } from '@ifrc-go/ui/printable'; +import { _cs } from '@togglecorp/fujs'; + +import ifrcLogo from '#assets/icons/ifrc-square.png'; + +import styles from './styles.module.css'; + +interface Props { + className?: string; + children: React.ReactNode; + heading: React.ReactNode; + description: React.ReactNode; + dataReady?: boolean; +} + +function PrintablePage(props: Props) { + const { + className, + children, + heading, + description, + dataReady = false, + } = props; + + const [previewReady, setPreviewReady] = useState(false); + + const mainRef = useRef(null); + + useEffect(() => { + if (!dataReady) { + return; + } + + const mainContainer = mainRef.current; + + async function waitForImages() { + if (!mainContainer) { + return; + } + + const images = mainContainer.querySelectorAll('img'); + + if (images.length === 0) { + setPreviewReady(true); + return; + } + + const promises = Array.from(images).map( + (image) => { + if (image.complete) { + return undefined; + } + + return new Promise((accept) => { + image.addEventListener('load', () => { + accept(true); + }); + }); + }, + ); + + await Promise.all(promises); + setPreviewReady(true); + } + + waitForImages(); + }, [dataReady]); + + return ( +
+
+ IFRC + + {heading} + +
+ {description} +
+
+ {children} + {previewReady &&
} +
+ ); +} + +export default PrintablePage; diff --git a/app/src/components/printable/PrintablePage/styles.module.css b/app/src/components/printable/PrintablePage/styles.module.css new file mode 100644 index 0000000000..06f063c82c --- /dev/null +++ b/app/src/components/printable/PrintablePage/styles.module.css @@ -0,0 +1,38 @@ +.printable-page { + font-family: 'Open Sans', sans-serif; + font-size: var(--go-ui-font-size-export); + + @media screen { + margin: var(--go-ui-spacing-xl) auto; + background-color: var(--go-ui-color-foreground); + padding: var(--go-ui-export-page-margin); + width: 210mm; + min-height: 297mm; + } + + .header-section { + display: grid; + grid-template-areas: + "logo heading" + "desc desc"; + margin-block-end: var(--go-ui-spacing-xl); + + .ifrc-logo { + width: 6rem; + height: 6rem; + grid-area: logo; + } + + .heading { + text-align: end; + grid-area: heading; + } + + .description { + grid-area: desc; + text-align: end; + color: var(--go-ui-color-primary-blue); + font-size: var(--go-ui-font-size-lg); + } + } +} diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 8d3ec82ce2..08630617dc 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -56,8 +56,8 @@ export const CATEGORY_RISK_VERY_HIGH = 5; // Colors export const COLOR_WHITE = '#ffffff'; -// export const COLOR_TEXT = '#313131'; -// export const COLOR_TEXT_ON_DARK = '#ffffff'; +export const COLOR_TEXT = '#313131'; +export const COLOR_TEXT_ON_DARK = '#ffffff'; export const COLOR_LIGHT_GREY = '#e0e0e0'; export const COLOR_DARK_GREY = '#a5a5a5'; export const COLOR_BLACK = '#000000'; @@ -196,6 +196,29 @@ export const multiMonthSelectDefaultValue = listToMap( () => false, ); +// FIXME these need to satisfy some enum export const ERU_READINESS_READY = 1; export const ERU_READINESS_CAN_CONTRIBUTE = 2; export const ERU_READINESS_NO_CAPACITY = 3; + +// FIXME these need to satisfy some enum +export const EAP_TYPE_SIMPLIFIED = 20; +export const EAP_TYPE_FULL = 10; + +// Timeframe + +// FIXME these need to satisfy some enum +export const TIMEFRAME_YEAR = 10; +// export const TIMEFRAME_MONTHS = 20; +// export const TIMEFRAME_DAYS = 30; +// export const TIMEFRAME_HOURS = 40; + +type EapStatus = components['schemas']['EapEapStatusEnumKey']; + +export const EAP_STATUS_UNDER_DEVELOPMENT = 10 satisfies EapStatus; +export const EAP_STATUS_UNDER_REVIEW = 20 satisfies EapStatus; +export const EAP_STATUS_NS_ADDRESSING_COMMENTS = 30 satisfies EapStatus; +export const EAP_STATUS_TECHNICALLY_VALIDATED = 40 satisfies EapStatus; +export const EAP_STATUS_APPROVED = 50 satisfies EapStatus; +export const EAP_STATUS_PFA_SIGNED = 60 satisfies EapStatus; +export const EAP_STATUS_ACTIVATED = 70 satisfies EapStatus; diff --git a/app/src/views/AccountMyFormsEap/EapStatus/index.tsx b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx new file mode 100644 index 0000000000..515dedf65d --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx @@ -0,0 +1,193 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { ArrowRightFillIcon } from '@ifrc-go/icons'; +import { + Button, + DropdownMenu, + Label, + ListView, + Modal, + RawFileInput, +} from '@ifrc-go/ui'; +import { + isDefined, + listToMap, +} from '@togglecorp/fujs'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useAlert from '#hooks/useAlert'; +import { + EAP_STATUS_ACTIVATED, + EAP_STATUS_APPROVED, + EAP_STATUS_NS_ADDRESSING_COMMENTS, + EAP_STATUS_PFA_SIGNED, + EAP_STATUS_TECHNICALLY_VALIDATED, + EAP_STATUS_UNDER_DEVELOPMENT, + EAP_STATUS_UNDER_REVIEW, +} from '#utils/constants'; +import { + type GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; + +type EapStatusBody = GoApiBody<'/api/v2/eap-registration/{id}/status/', 'POST'>; +type EapStatus = components['schemas']['EapEapStatusEnumKey']; + +const validStatusTransition: Record = { + [EAP_STATUS_UNDER_DEVELOPMENT]: [EAP_STATUS_UNDER_REVIEW], + [EAP_STATUS_UNDER_REVIEW]: [ + EAP_STATUS_NS_ADDRESSING_COMMENTS, + EAP_STATUS_TECHNICALLY_VALIDATED, + ], + [EAP_STATUS_NS_ADDRESSING_COMMENTS]: [ + EAP_STATUS_UNDER_REVIEW, + ], + [EAP_STATUS_TECHNICALLY_VALIDATED]: [ + EAP_STATUS_UNDER_REVIEW, + EAP_STATUS_APPROVED, + ], + [EAP_STATUS_APPROVED]: [EAP_STATUS_PFA_SIGNED], + [EAP_STATUS_PFA_SIGNED]: [EAP_STATUS_ACTIVATED], + [EAP_STATUS_ACTIVATED]: [], +}; + +export interface Props { + eapId: number; + status: EapStatus; + onStatusUpdate?: () => void; +} + +function EapStatus(props: Props) { + const { + eapId, + status, + onStatusUpdate, + } = props; + + const alert = useAlert(); + + const { eap_eap_status: eapStatusOptions } = useGlobalEnums(); + const [newStatus, setNewStatus] = useState(); + const [checklistFile, setChecklistFile] = useState(); + + const statusLabelMapping = listToMap( + eapStatusOptions, + ({ key }) => key, + ({ value }) => value, + ); + + const { trigger: triggerStatusUpdate } = useLazyRequest({ + method: 'POST', + url: '/api/v2/eap-registration/{id}/status/', + pathVariables: { + id: eapId, + }, + body: (fields: EapStatusBody) => fields, + onSuccess: () => { + setNewStatus(undefined); + if (onStatusUpdate) { + onStatusUpdate(); + } + + alert.show( + 'Status updated successfully!', + { variant: 'success' }, + ); + }, + formData: true, + onFailure: () => { + alert.show( + 'Failed to update the status!', + { variant: 'danger' }, + ); + }, + }); + + // FIXME: fix typings in the server + const requestBody = useMemo( + () => ({ + status: newStatus, + review_checklist_file: checklistFile, + } as EapStatusBody), + [newStatus, checklistFile], + ); + + const handleStatusUpdateCancel = useCallback(() => { + setNewStatus(undefined); + }, []); + + return ( + <> + + {eapStatusOptions?.map((option) => ( + + {option.value} + + ))} + + {isDefined(newStatus) && ( + + Confirm + + )} + > + +
+ Are you sure you want to update the status? +
+ +
+ {statusLabelMapping?.[status]} +
+ +
+ {statusLabelMapping?.[newStatus]} +
+
+ {newStatus === EAP_STATUS_NS_ADDRESSING_COMMENTS && ( + + + Select review checklist file + + + + )} +
+
+ )} + + ); +} + +export default EapStatus; diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json b/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json new file mode 100644 index 0000000000..1404c3e356 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json @@ -0,0 +1,9 @@ +{ + "namespace":"accountMyFormsEap", + "strings":{ + "eapStartFullLink": "Start Full EAP", + "eapStartSimplifiedLink": "Start sEAP", + "eapEditFullLink": "Edit Full EAP", + "eapEditSimplifiedLink": "Edit sEAP" + } +} diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx b/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx new file mode 100644 index 0000000000..c4bc42dc5f --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx @@ -0,0 +1,171 @@ +import { + useCallback, + useState, +} from 'react'; +import { + DocumentPdfLineIcon, + DownloadTwoLineIcon, +} from '@ifrc-go/icons'; +import { + Button, + ListView, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import EapExportModal from '#components/domain/EapExportModal'; +import Link from '#components/Link'; +import { + EAP_STATUS_NS_ADDRESSING_COMMENTS, + EAP_STATUS_UNDER_DEVELOPMENT, + EAP_TYPE_FULL, + EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; + +import { type EapExpandedListItem } from '../utils'; + +import i18n from './i18n.json'; + +export interface Props { + expandedListItem: EapExpandedListItem; +} + +function EapTableActions(props: Props) { + const { expandedListItem } = props; + + const { + type, + eap, + details, + } = expandedListItem; + + const [exportWithDiffView, setExportWithDiffView] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + + const strings = useTranslation(i18n); + + const setShowExportModalTrue = useCallback((withDiff?: boolean) => { + setExportWithDiffView(!!withDiff); + setShowExportModal(true); + }, []); + + const setShowExportModalFalse = useCallback(() => { + setExportWithDiffView(false); + setShowExportModal(false); + }, []); + + return ( + <> + + {type === 'development' && eap.eap_type === EAP_TYPE_SIMPLIFIED && ( + } + > + Preview export + + )} + {type === 'development' && details?.data.is_locked && isDefined(eap.review_checklist_file) && ( + } + > + Review Checklist + + )} + {type === 'development' && eap.eap_type === EAP_TYPE_SIMPLIFIED && ( + + )} + {type === 'development' + && eap.eap_type === EAP_TYPE_SIMPLIFIED + && isDefined(details?.data.version) + && details.data.version > 1 + && ( + + )} + {type === 'registration' && isNotDefined(eap.eap_type) && isNotDefined(details) && ( + + + {strings.eapStartSimplifiedLink} + + + {strings.eapStartFullLink} + + + )} + {type === 'development' + && !details?.data.is_locked + && eap.eap_type === EAP_TYPE_SIMPLIFIED + && (eap.status === EAP_STATUS_UNDER_DEVELOPMENT + || (eap.status === EAP_STATUS_NS_ADDRESSING_COMMENTS + && eap.latest_simplified_eap === details?.data.id)) + && ( + + {strings.eapEditSimplifiedLink} + + )} + {type === 'development' && !details?.data.is_locked && eap.eap_type === EAP_TYPE_FULL && ( + + {strings.eapEditFullLink} + + )} + + {showExportModal && isDefined(eap.eap_type) && ( + + )} + + ); +} + +export default EapTableActions; diff --git a/app/src/views/AccountMyFormsEap/Filters/i18n.json b/app/src/views/AccountMyFormsEap/Filters/i18n.json new file mode 100644 index 0000000000..9aab9233d2 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/Filters/i18n.json @@ -0,0 +1,6 @@ +{ + "namespace": "accountMyFormsEap", + "strings": { + "filterStatusPlaceholder": "Select Status" + } +} diff --git a/app/src/views/AccountMyFormsEap/Filters/index.tsx b/app/src/views/AccountMyFormsEap/Filters/index.tsx new file mode 100644 index 0000000000..8ebee10365 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/Filters/index.tsx @@ -0,0 +1,47 @@ +import { SelectInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { stringValueSelector } from '@ifrc-go/ui/utils'; +import { type EntriesAsList } from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; + +import i18n from './i18n.json'; + +type TypeOfEapStatus = components<'read'>['schemas']['EapEapStatusEnumKey']; +function typeOfEapStatusKeySelector({ key } : { key: TypeOfEapStatus }) { + return key; +} + +export interface FilterValue { + status?: TypeOfEapStatus | undefined; +} + +interface Props { + value: FilterValue; + onChange: (...args: EntriesAsList) => void; +} + +function Filters(props: Props) { + const { + value, + onChange, + } = props; + + const strings = useTranslation(i18n); + const { eap_eap_status: eapStatusTypeOptions } = useGlobalEnums(); + + return ( + + ); +} + +export default Filters; diff --git a/app/src/views/AccountMyFormsEap/i18n.json b/app/src/views/AccountMyFormsEap/i18n.json new file mode 100644 index 0000000000..4fcb4dcfd7 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "eapApplication", + "strings": { + "eapRegistrationLink": "Register Your EAP", + "eapApplicationsHeading": "EAP Application", + "eapLastUpdated": "Last Updated", + "eapName": "Name/Phase", + "eapType": "EAP Type", + "eapStatus": "Status" + } +} diff --git a/app/src/views/AccountMyFormsEap/index.tsx b/app/src/views/AccountMyFormsEap/index.tsx new file mode 100644 index 0000000000..98c4220421 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/index.tsx @@ -0,0 +1,349 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + Pager, + type RowOptions, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createEmptyColumn, + createExpandColumn, + createExpansionIndicatorColumn, + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { + EAP_STATUS_APPROVED, + EAP_STATUS_PFA_SIGNED, + EAP_STATUS_TECHNICALLY_VALIDATED, + EAP_TYPE_FULL, + EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import EapStatus, { type Props as EapStatusProps } from './EapStatus'; +import EapTableActions, { type Props as EapTableActionProps } from './EapTableActions'; +import Filters, { type FilterValue } from './Filters'; +import { + type EapExpandedItem, + type EapExpandedListItem, + type EapListItem, +} from './utils'; + +import i18n from './i18n.json'; + +type Key = EapListItem['id']; +const ITEM_PER_PAGE = 6; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { + filter, + offset, + limit, + rawFilter, + filtered, + setFilterField, + page, + setPage, + } = useFilterState({ + filter: {}, + pageSize: ITEM_PER_PAGE, + }); + + const { + response: eapListResponse, + pending: eapListPending, + retrigger: reloadEapList, + } = useRequest({ + url: '/api/v2/eap-registration/', + preserveResponse: true, + query: { + offset, + limit, + status: filter.status, + }, + }); + + const [expandedRow, setExpandedRow] = useState(); + const handleExpandClick = useCallback( + (row: EapListItem) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const aggregatedColumns = useMemo( + () => ([ + createExpansionIndicatorColumn(false), + createDateColumn( + 'created_at', + strings.eapLastUpdated, + (item) => item.modified_at, + ), + createStringColumn( + 'name', + strings.eapName, + (item) => { + const baseYear = new Date(item.created_at).getFullYear(); + + let addedYear = baseYear; + + if (item.eap_type === EAP_TYPE_FULL) { + addedYear = baseYear + 5; + } else if (item.eap_type === EAP_TYPE_SIMPLIFIED) { + addedYear = baseYear + 2; + } + + return `${item.country_details?.name}: + ${item.disaster_type_details?.name} + ${baseYear} - ${addedYear}`; + }, + ), + createStringColumn( + 'eap_type_display', + strings.eapType, + (item) => item.eap_type_display, + ), + createElementColumn( + 'status_display', + strings.eapStatus, + EapStatus, + (key, row) => ({ + eapId: key, + status: row.status, + onStatusUpdate: reloadEapList, + }), + ), + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + }), + ), + ]), + [ + strings.eapLastUpdated, + strings.eapName, + strings.eapType, + strings.eapStatus, + expandedRow, + handleExpandClick, + reloadEapList, + ], + ); + + const eapExpandedItems = useMemo(() => ( + listToMap( + eapListResponse?.results, + (eapListItem) => eapListItem.id, + (eapListItem) => { + const { + simplified_eap_details, + full_eap_details, + modified_at, + eap_type, + status, + } = eapListItem; + + const eapStarted = simplified_eap_details.length > 0 || full_eap_details.length > 0; + + const items = [ + { + label: 'EAP Development Registration', + lastUpdated: modified_at, + eap: eapListItem, + type: 'registration', + details: undefined, + disabled: false, + } satisfies EapExpandedListItem, + ...(eap_type === EAP_TYPE_SIMPLIFIED + ? simplified_eap_details.map((simplifiedEap) => ({ + label: `EAP Application v${simplifiedEap.version}`, + lastUpdated: simplifiedEap.modified_at, + eap: eapListItem, + type: 'development', + details: { + eapType: EAP_TYPE_SIMPLIFIED, + data: simplifiedEap, + }, + disabled: false, + } satisfies EapExpandedListItem)).toReversed() + : [] + ), + ...(eap_type === EAP_TYPE_FULL + ? full_eap_details.map((fullEap) => ({ + label: `EAP Application v${fullEap.version}`, + lastUpdated: fullEap.modified_at, + eap: eapListItem, + type: 'development', + details: { + eapType: EAP_TYPE_FULL, + data: fullEap, + }, + disabled: false, + } satisfies EapExpandedListItem)).toReversed() + : [] + ), + ((isNotDefined(eap_type) || !eapStarted) + ? ({ + label: 'EAP Application v1', + eap: eapListItem, + type: 'development', + details: undefined, + disabled: true, + } satisfies EapExpandedListItem) + : undefined + ), + { + label: 'Technically Validated', + eap: eapListItem, + type: 'validated', + details: undefined, + disabled: status < EAP_STATUS_TECHNICALLY_VALIDATED, + } satisfies EapExpandedListItem, + { + label: 'Approved', + eap: eapListItem, + type: 'approved', + details: undefined, + disabled: status < EAP_STATUS_APPROVED, + } satisfies EapExpandedListItem, + { + label: 'PFA Signed', + eap: eapListItem, + type: 'signed', + details: undefined, + disabled: status < EAP_STATUS_PFA_SIGNED, + } satisfies EapExpandedListItem, + ].filter(isDefined).toReversed(); + + return { + eap: eapListItem, + expandedItems: items, + } satisfies EapExpandedItem; + }, + ) + ), [eapListResponse]); + + const detailColumns = useMemo( + () => ([ + createExpansionIndicatorColumn( + true, + (row) => !!row.disabled, + ), + createDateColumn( + 'created_at', + strings.eapLastUpdated, + (row) => row.lastUpdated, + ), + createStringColumn( + 'title', + '', + (row) => row.label, + { withLightText: (item) => !!item.disabled }, + ), + createEmptyColumn(), + createElementColumn( + 'actions', + '', + EapTableActions, + (_, row) => ({ + expandedListItem: row, + }), + ), + createEmptyColumn(), + ]), + [strings.eapLastUpdated], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (datum.id !== expandedRow?.id) { + return row; + } + + const subRows = eapExpandedItems?.[expandedRow.id]; + + return ( + <> + {row} + expandedItem.label} + data={subRows?.expandedItems} + columns={detailColumns} + expandedContent + /> + + ); + }, + [ + expandedRow, + detailColumns, + eapExpandedItems, + ], + ); + + return ( + + )} + headerActions={( + + {strings.eapRegistrationLink} + + )} + footerActions={( + + )} + > + + + ); +} diff --git a/app/src/views/AccountMyFormsEap/utils.ts b/app/src/views/AccountMyFormsEap/utils.ts new file mode 100644 index 0000000000..4287a54b43 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/utils.ts @@ -0,0 +1,34 @@ +import { + type EAP_TYPE_FULL, + type EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; +import { type GoApiResponse } from '#utils/restRequest'; + +type EapResponse = GoApiResponse<'/api/v2/eap-registration/'>; +export type EapListItem = NonNullable[number]; + +interface SimplifiedEapDetails { + eapType: typeof EAP_TYPE_SIMPLIFIED; + data: EapListItem['simplified_eap_details'][number]; +} + +interface FullEapDetails { + eapType: typeof EAP_TYPE_FULL; + data: EapListItem['full_eap_details'][number]; +} + +export type EapExpandedListItem = { + label: string; + lastUpdated?: string; + eap: EapListItem; + type: 'registration' | 'development' | 'review' | 'revision' | 'validated' | 'approved' | 'signed'; + disabled?: boolean; + + // Only applicable for development type + details: SimplifiedEapDetails | FullEapDetails | undefined; +}; + +export type EapExpandedItem = { + eap: EapListItem; + expandedItems: EapExpandedListItem[]; +}; diff --git a/app/src/views/AccountMyFormsLayout/i18n.json b/app/src/views/AccountMyFormsLayout/i18n.json index 6b70856db7..c23cf7ce5b 100644 --- a/app/src/views/AccountMyFormsLayout/i18n.json +++ b/app/src/views/AccountMyFormsLayout/i18n.json @@ -4,6 +4,7 @@ "fieldReportTabTitle": "Field Report", "perTabTitle": "PER", "drefTabTitle": "DREF", - "threeWTabTitle": "3W" + "threeWTabTitle": "3W", + "eapApplications": "EAP Applications" } } \ No newline at end of file diff --git a/app/src/views/AccountMyFormsLayout/index.tsx b/app/src/views/AccountMyFormsLayout/index.tsx index 5364a38441..c7eaac05d2 100644 --- a/app/src/views/AccountMyFormsLayout/index.tsx +++ b/app/src/views/AccountMyFormsLayout/index.tsx @@ -35,6 +35,11 @@ export function Component() { > {strings.threeWTabTitle} + + {strings.eapApplications} + diff --git a/app/src/views/DrefApplicationForm/index.tsx b/app/src/views/DrefApplicationForm/index.tsx index 226d5a1c49..58b95680cd 100644 --- a/app/src/views/DrefApplicationForm/index.tsx +++ b/app/src/views/DrefApplicationForm/index.tsx @@ -308,6 +308,7 @@ export function Component() { const loadResponseToFormValue = useCallback((response: GetDrefResponse) => { handleDrefLoad(response); + const { planned_interventions, proposed_action, diff --git a/app/src/views/DrefDetail/i18n.json b/app/src/views/DrefDetail/i18n.json new file mode 100644 index 0000000000..47ce2fe4c4 --- /dev/null +++ b/app/src/views/DrefDetail/i18n.json @@ -0,0 +1,15 @@ +{ + "namespace": "drefDetail", + "strings": { + "drefIntroHeading": "DREF Intro", + "drefIntroDetailOne": "Every year, small and medium-sized disasters occur in silence. Without media attention or international visibility, they can struggle to attract funding—putting affected communities at risk of being completely neglected.", + "drefIntroDetailTwo": "To support these smaller disasters, or to provide initial funding before launching an Emergency Appeal, we rapidly channel funding to Red Cross and Red Crescent Societies through the DREF—enabling them to deliver fast and effective local humanitarian action.", + "drefProcessHeading": "Dref Process", + "drefProcessSubHeading": "We provide funding in two ways:", + "drefProcessListOne": "A loan facility: start-up funding for the IFRC and National Societies to respond to large-scale disasters, which will later be reimbursed by donor contributions to an Emergency Appeal.", + "drefProcessListTwo": "A grant facility: funding for National Society responses to small- and medium-sized disasters. This is used when no Emergency Appeal will be launched or when support from other actors is not foreseen.", + "drefProcessDetailOne": "The fund is demand-driven and locally-owned. It is open to all 191 National Societies that submit funding applications and plans of action reflecting locally identified priorities and needs.", + "drefProcessDetailTwo": "On average, the DREF supports more than 100 responses to small and medium-sized disasters every year.", + "drefDrefApplication": "DREF Application" + } +} diff --git a/app/src/views/DrefDetail/index.tsx b/app/src/views/DrefDetail/index.tsx new file mode 100644 index 0000000000..33869faf08 --- /dev/null +++ b/app/src/views/DrefDetail/index.tsx @@ -0,0 +1,58 @@ +import { + Container, + ListView, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import SurgeContentContainer from '#components/domain/SurgeContentContainer'; +import Link from '#components/Link'; + +import i18n from './i18n.json'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + return ( + + + +
{strings.drefIntroDetailOne}
+
{strings.drefIntroDetailTwo}
+
+ +
{strings.drefProcessSubHeading}
+
    +
  • + {strings.drefProcessListOne} +
  • +
  • + {strings.drefProcessListTwo} +
  • +
+
+ {strings.drefProcessDetailOne} +
+
+ {strings.drefProcessDetailTwo} +
+
+ + {strings.drefDrefApplication} + +
+
+ ); +} + +Component.displayName = 'DrefDetail'; diff --git a/app/src/views/DrefProcess/i18n.json b/app/src/views/DrefProcess/i18n.json new file mode 100644 index 0000000000..01904a6dda --- /dev/null +++ b/app/src/views/DrefProcess/i18n.json @@ -0,0 +1,9 @@ +{ + "namespace": "drefProcess", + "strings": { + "eapHeading": "Disaster Response Emergency Fund (DREF)", + "eapDescription": "The IFRC's Disaster Emergency Fund (DREF): rapid, reliable funding for life-saving action.", + "eapProcessDrefTab": "Response And Imminent Dref", + "eapProcessEapTab": "Early Action Protocols (EAP)" + } +} diff --git a/app/src/views/DrefProcess/index.tsx b/app/src/views/DrefProcess/index.tsx new file mode 100644 index 0000000000..2ed605891a --- /dev/null +++ b/app/src/views/DrefProcess/index.tsx @@ -0,0 +1,37 @@ +import { Outlet } from 'react-router-dom'; +import { NavigationTabList } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import NavigationTab from '#components/NavigationTab'; +import Page from '#components/Page'; + +import i18n from './i18n.json'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + return ( + + + + {strings.eapProcessDrefTab} + + + {strings.eapProcessEapTab} + + + + + ); +} + +Component.displayName = 'DrefProcess'; diff --git a/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json b/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json new file mode 100644 index 0000000000..fb51cf85e1 --- /dev/null +++ b/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "eapFullForm", + "strings": { + "eapSourceInformationNameLabel": "Name", + "eapSourceInformationLinkLabel": "Link", + "eapSourceInformationDeleteButton": "Delete Source Information" + } +} diff --git a/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx b/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx new file mode 100644 index 0000000000..159f2dfd4b --- /dev/null +++ b/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx @@ -0,0 +1,122 @@ +import { useCallback } from 'react'; +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + Button, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isNotDefined, + randomString, +} from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type PartialForm, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import { type components } from '#generated/types'; + +import i18n from './i18n.json'; + +type EAPSourceInformation = components['schemas']['EAPSourceInformation'] & { client_id: string }; + +export type SourceInformationFormFields = PartialForm; + +interface Props { + value: SourceInformationFormFields; + error: ArrayError | undefined; + onChange: ( + value: SetValueArg, + index: number + ) => void; + onRemove: (index: number) => void; + index: number; + disabled?: boolean; + readOnly?: boolean; +} + +function EAPSourceInformationInput(props: Props) { + const { + error: errorFromProps, + onChange, + value, + index, + onRemove, + disabled, + readOnly, + } = props; + + const strings = useTranslation(i18n); + + const onFieldChange = useFormObject(index, onChange, () => ({ + client_id: randomString(), + })); + + const error = value && value.client_id && errorFromProps + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + const handleSourceFieldChange = useCallback( + (newValue: string | undefined) => { + if ( + isNotDefined(newValue) + || newValue.startsWith('http://') + || newValue.startsWith('https://') + || newValue === 'h' + || newValue === 'ht' + || newValue === 'htt' + || newValue === 'http' + || newValue === 'http:' + || newValue === 'http:/' + || newValue === 'https' + || newValue === 'https:' + || newValue === 'https:/' + ) { + onFieldChange(newValue, 'source_link'); + return; + } + + onFieldChange(`https://${newValue}`, 'source_link'); + }, + [onFieldChange], + ); + + return ( + <> + + + + + + ); +} + +export default EAPSourceInformationInput; diff --git a/app/src/views/EapFullForm/EapActivationProcess/i18n.json b/app/src/views/EapFullForm/EapActivationProcess/i18n.json new file mode 100644 index 0000000000..b25e56db9a --- /dev/null +++ b/app/src/views/EapFullForm/EapActivationProcess/i18n.json @@ -0,0 +1,44 @@ +{ + "namespace": "eapFullForm", + "strings": { + "activationProcessHeading": "EAP Activation Process", + "activationProcessTooltip": "It is crucial to select early actions that have the most potential to reduce the identified risks(s) and are feasible to implement given the lead time of the forecast. It is important to describe briefly: Who was involved? What data was consulted? Was research conducted? Were communities involved? For more guidance see FbF Manual, Chapter 4.2 Select Early Actions.", + "activationProcessTitle": "Early action implementation process", + "activationProcessDescription1": "Include a matrix/flowchart for a quick overview of the early action implementation process.", + "activationProcessDescription2": "Early Describe the step-by-step process from Day 1 to Day X for the implementation of the selected early actions. Indicate the day when the Stop Mechanism would occur. Include all critical and support tasks that are necessary for each of the steps. Each task should indicate the position of the person responsible (including when cash-based actions are planned liaison with the financial service provider)... implementation process", + "activationProcessDescriptionLabel": "Description", + "activationProcessExplanatoryLabel": "Explanatory Note", + "activationProcessRequiredPointsLabel": "Required Points", + "activationImplementationExplanatoryNote": "As a crucial component of the EAP, once the trigger has been reached, everyone involved should be knowledgeable about what will be done, where, when and by whom. The described implementation process shows that each step of the activation has been thought through and considered and that implementation in the lead time available is possible. The set of tasks described in this section should cover all activities from the moment the trigger is reached (Day 1) to the completion of post-impact surveys (Day X).", + "activationImplementationRequiredPoint1": "Include a matrix/flowchart for a quick overview of the early action implementation process.", + "activationImplementationRequiredPoint2": "Describe the step-by-step process from Day 1 to Day X for the implementation of the selected early actions. Indicate the day when the Stop Mechanism would occur. Include all critical and support tasks that are necessary for each of the steps. Each task should indicate the position of the person responsible (including when cash-based actions are planned liaison with the financial service provider).", + "activationImplementationRequiredPoint3": "For each action, include at which level it will take place (HQ, branch, community).", + "activationImplementationRequiredPoint4": "Each NS should have a detailed version of this process, including communication flows, for each task and the name of the person responsible with their contact information. This document should be regularly updated.", + "activationProcessUploadLabel": "Upload", + "activationTriggerTitle": "Trigger activation system", + "activationTriggerDescription1": "Describe the automatic system used to monitor the forecasts, generate the intervention map and send the alert message when the trigger is reached.", + "activationTriggerDescription2": "If this automatic system does not yet exist, explain how forecasts will be monitored, intervention maps generated and how the relevant actors will be informed that the trigger has been reached.", + "activationTriggerDescription3": "Indicate who gives the signal to start the activation.", + "activationTriggerExplanatoryNote": "The activation process starts with the message that the trigger has been reached (on Day 1). Ideally, there is a system in place to automatically monitor the forecasts and send an automatic message of alert to relevant actors as soon as a trigger is reached. It is expected that this will be executed by the national meteorological office and/or national DRM authority. If this automatic system does not exist, a mechanism needs to be in place to monitor the forecasts and alert relevant actors as soon as a trigger is reached to initiate the early actions.", + "activationTriggerRequiredPoint1": "Describe the automatic system used to monitor the forecasts, generate the intervention map and send the alert message when the trigger is reached.", + "activationTriggerRequiredPoint2": "If this automatic system does not yet exist, explain how forecasts will be monitored, intervention maps generated and how the relevant actors will be informed that the trigger has been reached.", + "activationTriggerRequiredPoint3": "Indicate who gives the signal to start the activation.", + "activationPeopleTargetedTitle": "People Targeted", + "activationPeopleTargetedDescription": "Specify number of people targeted", + "activationSelectionPopulationTitle": "Selection of target population", + "activationSelectionDescription1": "Provide a short summary of the target population, (the number, location etc.)", + "activationSelectionDescription2": "Describe how the target population will be selected, with a special focus on feasibility in the short period of time between forecast and event", + "activationSelectionDescription3": "If the EAP is intending to use Social Protection systems or other government beneficiary databases, indicate how the potential number of targeted households be selected", + "activationSelectionExplanatoryNote": "FbF aims to protect the most vulnerable from the impact of extreme weather events. Based on the analysis on vulnerability and exposure (in section 3) and on the described mechanism for identifying intervention areas/communities (in section 4- Intervention area), it needs to be clear, how vulnerability criteria and impact forecasts will be applied to determine who will be targeted.", + "activationStopMechanismTitle": "Stop Mechanism", + "activationStopMechanismDescription1": "Indicate on which day of activation the stop mechanism is foreseen, and who is responsible to give the signal to stop.", + "activationStopMechanismDescription2": "Describe when the stop mechanism begins and whether in-kind/cash distribution would be stopped or not. For cash actions cancelled, how would this be coordinated with the financial service provider? For in-kind distribution, what would happen with the perishable items?", + "activationStopMechanismDescription3": "Explain how it would be communicated to communities and stakeholders that the activities are being stopped.", + "activationStopMechanismExplanatoryNote": "For forecast triggers with a lead time of more than three days, the EAP should include the description of a stop mechanism. This means that if a later forecast – prior to the start of activities (related to the early action(s)) shows that the event is no longer likely to occur, the activation of the EAP will be stopped to avoid generating further use of resources. For example, if the 6-day forecast on Day 1 indicates high risk of heavy rainfall and thereby triggers the activation and the new 6-day-forecast released on Day 3 shows that the risk has significantly lowered, the trigger level is no longer reached. If the start of distributions was planned for Day 4, activation should be stopped. Items that have been purchased based on the trigger being reached and are not distributed due to the stop mechanism should be stored in the warehouse for a future activation. For forecast triggers with a lead time of less than 3 days, the EAP should include the description of what the National Society would do if the forecast changes in strength or location within the last three days before the event.", + "activationAttachFilesTitle": "Attach Relevant Files", + "activationAttachFilesDescription": "Attach any additional maps, documentation, files, images, etc.", + "activationSourceOfInformationTitle": "Sources of Information", + "activationSourceOfInformationAddNewLabel": "Add New Source of Information", + "activationSourceOfInformationDescription": "Add the description of the sources one at a time. If the source has a link, add in the second field." + } +} diff --git a/app/src/views/EapFullForm/EapActivationProcess/index.tsx b/app/src/views/EapFullForm/EapActivationProcess/index.tsx new file mode 100644 index 0000000000..67338ab9a6 --- /dev/null +++ b/app/src/views/EapFullForm/EapActivationProcess/index.tsx @@ -0,0 +1,338 @@ +import { useCallback } from 'react'; +import { + Button, + Heading, + InfoPopup, + InputSection, + ListView, + NumberInput, + TextArea, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { randomString } from '@togglecorp/fujs'; +import { + type EntriesAsList, + type Error, + getErrorObject, + getErrorString, + useFormArray, +} from '@togglecorp/toggle-form'; + +import GoMultiFileInput from '#components/domain/GoMultiFileInput'; +import MultiImageWithCaptionInput from '#components/domain/MultiImageWithCaptionInput'; +import NonFieldError from '#components/NonFieldError'; +import TabPage from '#components/TabPage'; + +import EAPSourceInformationInput, { type SourceInformationFormFields } from '../EAPSourceInformationInput'; +import { type PartialEapFullFormType } from '../schema'; + +import i18n from './i18n.json'; + +interface Props { + value: PartialEapFullFormType; + setFieldValue: (...entries: EntriesAsList) => void; + error: Error | undefined; + disabled?: boolean; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch< + React.SetStateAction> + >; +} + +function EapActivationProcess(props: Props) { + const { + value, + setFieldValue, + error: formError, + disabled, + fileIdToUrlMap, + setFileIdToUrlMap, + } = props; + + const strings = useTranslation(i18n); + + const error = getErrorObject(formError); + + const { + setValue: onRiskSourceInformationChange, + removeValue: onRiskSourceInformationRemove, + } = useFormArray< + 'activation_process_source_of_information', + SourceInformationFormFields + >('activation_process_source_of_information', setFieldValue); + + const handleSourceInformationAdd = useCallback(() => { + const newSourceInformationItem: SourceInformationFormFields = { + client_id: randomString(), + }; + + setFieldValue( + (oldValue: SourceInformationFormFields[] | undefined) => [ + ...(oldValue ?? []), + newSourceInformationItem, + ], + 'activation_process_source_of_information' as const, + ); + }, [setFieldValue]); + + return ( + + + + {strings.activationProcessHeading} + + + + + +
  • {strings.activationImplementationRequiredPoint1}
  • +
  • {strings.activationImplementationRequiredPoint2}
  • +
  • {strings.activationImplementationRequiredPoint3}
  • +
  • {strings.activationImplementationRequiredPoint4}
  • + + )} + /> +
    + )} + description={( +
      +
    • {strings.activationProcessDescription1}
    • +
    • {strings.activationProcessDescription2}
    • +
    + )} + withAsteriskOnTitle + > +