diff --git a/app/package.json b/app/package.json
index a055bdf62e..60e600e30b 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 c4f42806ec..a4ea61df8f 100644
--- a/app/src/components/Navbar/index.tsx
+++ b/app/src/components/Navbar/index.tsx
@@ -377,12 +377,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) ? (
) : 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
/>
@@ -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 (
+
+
+ {label}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+ {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 93b6b00878..1365983bae 100644
--- a/app/src/utils/constants.ts
+++ b/app/src/utils/constants.ts
@@ -58,8 +58,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';
@@ -198,6 +198,7 @@ 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;
@@ -220,3 +221,25 @@ export const OTHER_TRAINING_FACILITIES = 9 satisfies LocalUnitTrainingFacilityTy
type LocalUnitAffiliationOptions = NonNullable['affiliation']>[number]>['id']
export const OTHER_AFFILIATION = 9 satisfies LocalUnitAffiliationOptions;
+
+// 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
+
+
+ {isDefined(checklistFile) && checklistFile.name}
+
+
+ )}
+
+
+ )}
+ >
+ );
+}
+
+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 && (
+ }
+ styleVariant="action"
+ // FIXME: use strings
+ >
+ Export
+
+ )}
+ {type === 'development'
+ && eap.eap_type === EAP_TYPE_SIMPLIFIED
+ && isDefined(details?.data.version)
+ && details.data.version > 1
+ && (
+ }
+ styleVariant="action"
+ // FIXME: use strings
+ >
+ Export with changes
+
+ )}
+ {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/index.tsx b/app/src/views/EapFullForm/index.tsx
new file mode 100644
index 0000000000..e6d0317fee
--- /dev/null
+++ b/app/src/views/EapFullForm/index.tsx
@@ -0,0 +1,12 @@
+import Page from '#components/Page';
+
+/** @knipignore */
+// eslint-disable-next-line import/prefer-default-export
+export function Component() {
+ return (
+
+ {/* TODO: Add EAP form */}
+ Full EAP Form
+
+ );
+}
diff --git a/app/src/views/EapRegistration/i18n.json b/app/src/views/EapRegistration/i18n.json
new file mode 100644
index 0000000000..9a8304afc0
--- /dev/null
+++ b/app/src/views/EapRegistration/i18n.json
@@ -0,0 +1,51 @@
+{
+ "namespace": "eapRegistration",
+ "strings": {
+ "eapRegistrationHeading": "EAP Development Registration",
+ "eapRegistrationDescription": "Use the following page to submit your National Society's interest in following the Early Action Protocol (EAP) application process. Once you submit this form, the EAP team members will contact you to start the procedure. You can find more information on the process and different ways to apply {link}",
+ "eapRegistrationLink": "on this page.",
+ "eapApplicationDetails": "Application Details",
+ "eapNationalSociety": "National Society (NS)",
+ "eapNationalSocietyDescription": "Select National Society that is planning to apply for the EAP",
+ "eapCountry": "Country",
+ "eapCountryDescription": "The country will be pre-populated based on the NS selection, but can be adapted as needed.",
+ "eapDisasterType": "Disaster Type",
+ "eapDisasterTypeDescription": "Select the disaster type for which the EAP is needed.",
+ "eapType": "EAP Type",
+ "eapTypeDescription": "Select the EAP type. Find details of both under this link.",
+ "eapSubmission": "Expected Time of Submission",
+ "eapSubmissionDescription": "Include the proposed time of submission, accounting for the time it will take to deliver the application.",
+ "eapPartnersInvolved": "Partners Involved",
+ "eapPartnersInvolvedDescription": "Select from the list the partners involved in this process. Add as many as needed or select not applicable if no partners involved.",
+ "eapContacts": "Contacts",
+ "eapNSContact": "National Society Contact",
+ "eapNSContactDescription": "National Society contact responsible for the EAP process",
+ "eapNSName": "Name",
+ "eapNSTitle": "Title",
+ "eapNSEmail": "Email",
+ "eapNSPhoneNumber": "Phone Number",
+ "eapIFRCContact": "IFRC Contact",
+ "eapIFRCContactDescription": "The most senior staff in the National Society responsible and knowledgable about the disaster event.",
+ "eapIFRCName": "Name",
+ "eapIFRCTitle": "Title",
+ "eapIFRCEmail": "Email",
+ "eapIFRCPhoneNumber": "Phone Number",
+ "eapFocalPoint": "DREF Focal Point",
+ "eapFocalPointDescription": "The DREF contact person form IFRC",
+ "eapFocalPointName": "Name",
+ "eapFocalPointTitle": "Title",
+ "eapFocalPointEmail": "Email",
+ "eapFocalPointPhoneNumber": "Phone Number",
+ "eapSubmitButton": "Submit",
+ "eapBackButton": "Back",
+ "eapCancelButton": "Cancel",
+ "eapRegistrationFailure": "Sorry could not register new EAP right now!",
+ "eapRegistrationSuccess": "Successfully created a new EAP!",
+ "eapNotSure": "Not Sure",
+ "eapDevelopmentRegistrationHeading": "EAP Development Registration",
+ "eapDevelopmentRegistrationDescription": "Thank you for notifying us about the start of your EAP process. We look forward to your completed application.",
+ "eapFailedToLoad": "Failed to Load",
+ "eapRegistrationUpdateMessage": "EAP Registration updated Successfully.",
+ "eapRegistrationFailureMessage": "Failed to update EAP Registration"
+ }
+}
diff --git a/app/src/views/EapRegistration/index.tsx b/app/src/views/EapRegistration/index.tsx
new file mode 100644
index 0000000000..698e6a4c90
--- /dev/null
+++ b/app/src/views/EapRegistration/index.tsx
@@ -0,0 +1,555 @@
+import {
+ type ElementRef,
+ useCallback,
+ useRef,
+} from 'react';
+import {
+ useLocation,
+ useParams,
+} from 'react-router-dom';
+import {
+ ConfirmButton,
+ Container,
+ DateInput,
+ InputSection,
+ ListView,
+ Radio,
+ RadioInput,
+ TextInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ resolveToComponent,
+ stringValueSelector,
+} from '@ifrc-go/ui/utils';
+import {
+ isDefined,
+ isNotDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
+import {
+ createSubmitHandler,
+ getErrorObject,
+ getErrorString,
+ useForm,
+} from '@togglecorp/toggle-form';
+
+import CountrySelectInput from '#components/domain/CountrySelectInput';
+import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput';
+import FormFailedToLoadMessage from '#components/domain/FormFailedToLoadMessage';
+import NationalSocietyMultiSelectInput from '#components/domain/NationalSocietyMultiSelectInput';
+import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput';
+import Link from '#components/Link';
+import Page from '#components/Page';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import useAlert from '#hooks/useAlert';
+import useRouting from '#hooks/useRouting';
+import {
+ type GoApiBody,
+ type GoApiResponse,
+ useLazyRequest,
+ useRequest,
+} from '#utils/restRequest';
+import { transformObjectError } from '#utils/restRequest/error';
+
+import {
+ defaultFormValue,
+ formSchema,
+} from './schema';
+
+import i18n from './i18n.json';
+
+type EapRegisterRequestBody = GoApiBody<'/api/v2/eap-registration/', 'POST'>;
+type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>;
+type EapTypeOption = NonNullable[number];
+
+function eapTypeKeySelector(option: EapTypeOption) {
+ return option.key;
+}
+
+/** @knipignore */
+// eslint-disable-next-line import/prefer-default-export
+export function Component() {
+ const strings = useTranslation(i18n);
+ const alert = useAlert();
+ const { navigate } = useRouting();
+ const { eapId: eapIdFromParams } = useParams<{ eapId: string }>();
+
+ const { state } = useLocation();
+ const eapId = eapIdFromParams ?? state?.eapId as string | undefined;
+ const isReadOnly = state?.mode === 'view';
+
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ setError,
+ setValue,
+ validate,
+ } = useForm(formSchema, { value: defaultFormValue });
+
+ const {
+ eap_eap_type: eapFormOptions,
+ } = useGlobalEnums();
+
+ const error = getErrorObject(formError);
+ const formContentRef = useRef>(null);
+
+ const {
+ pending: fetchingEap,
+ error: eapError,
+ } = useRequest({
+ skip: isNotDefined(eapId),
+ url: '/api/v2/eap-registration/{id}/',
+ pathVariables: isTruthyString(eapId) ? {
+ id: Number(eapId),
+ } : undefined,
+ onSuccess: (response) => {
+ const {
+ ...formValues
+ } = response;
+ setValue(formValues);
+ },
+ });
+
+ const {
+ pending: eapRegistrationPending,
+ trigger: eapRegister,
+ } = useLazyRequest({
+ method: 'POST',
+ url: '/api/v2/eap-registration/',
+ body: (body: EapRegisterRequestBody) => body,
+ onSuccess: () => {
+ const message = strings.eapRegistrationSuccess;
+ alert.show(
+ message,
+ { variant: 'success' },
+ );
+ navigate('accountMyFormsEap');
+ },
+ onFailure: (err) => {
+ const {
+ value: {
+ formErrors,
+ },
+ } = err;
+
+ setError(transformObjectError(formErrors, () => undefined));
+
+ alert.show(
+ strings.eapRegistrationFailure,
+ { variant: 'danger' },
+ );
+ },
+ });
+
+ const {
+ pending: updateEapRegistrationPending,
+ trigger: updateEapRegistration,
+ } = useLazyRequest({
+ url: '/api/v2/eap-registration/{id}/',
+ method: 'PATCH',
+ pathVariables: {
+ id: Number(eapId),
+ },
+ body: (formFields: EapRegisterRequestBody) => formFields,
+ onSuccess: (response) => {
+ alert.show(
+ strings.eapRegistrationUpdateMessage,
+ { variant: 'success' },
+ );
+ navigate(
+ 'accountMyFormsEap',
+ { params: { eapId: response.id } },
+ );
+ },
+ onFailure: (err) => {
+ const {
+ value: {
+ formErrors,
+ messageForNotification,
+ },
+ } = err;
+
+ setError(transformObjectError(
+ formErrors,
+ () => undefined,
+ ));
+
+ alert.show(
+ strings.eapRegistrationFailureMessage,
+ {
+ variant: 'danger',
+ description: messageForNotification,
+ },
+ );
+ },
+ });
+
+ const handleCountryChange = useCallback(
+ (val: number | undefined, name: 'country') => {
+ setFieldValue(val, name);
+ },
+ [setFieldValue],
+ );
+
+ const handleEapTypeClick = useCallback(() => {
+ if (isReadOnly) {
+ return;
+ }
+ setFieldValue(null, 'eap_type');
+ }, [isReadOnly, setFieldValue]);
+
+ const handleSubmissionTimeClick = useCallback(() => {
+ if (isReadOnly) {
+ return;
+ }
+ setFieldValue(null, 'expected_submission_time');
+ }, [isReadOnly, setFieldValue]);
+
+ const eapRegistration = useCallback(() => {
+ const handler = createSubmitHandler(
+ validate,
+ setError,
+ (formValues) => {
+ if (isNotDefined(eapId)) {
+ eapRegister(formValues as EapRegisterRequestBody);
+ } else {
+ updateEapRegistration({
+ ...formValues,
+ id: eapId,
+ } as EapRegisterRequestBody);
+ }
+ },
+ );
+ handler();
+ }, [
+ setError,
+ validate,
+ eapRegister,
+ updateEapRegistration,
+ eapId,
+ ]);
+
+ const handleFormError = useCallback(() => {
+ setTimeout(() => formContentRef.current?.scrollIntoView(), 200);
+ }, []);
+
+ const handleEapRegistration = useCallback(() => {
+ const handler = createSubmitHandler(
+ validate,
+ setError,
+ eapRegistration,
+ handleFormError,
+ );
+ handler();
+ }, [
+ handleFormError,
+ eapRegistration,
+ validate,
+ setError,
+ ]);
+
+ const disabled = eapRegistrationPending || fetchingEap || updateEapRegistrationPending;
+
+ const handleNationalSocietyInputChange = useCallback((newValue: number | undefined) => {
+ setFieldValue(newValue, 'national_society');
+ setFieldValue(newValue, 'country');
+ }, [setFieldValue]);
+
+ if (isDefined(eapError)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {strings.eapRegistrationLink}
+
+ ),
+ },
+ )}
+ actions={(
+
+ {eapId ? strings.eapBackButton : strings.eapCancelButton}
+
+ )}
+ elementRef={formContentRef}
+ withBackgroundColorInMainSection
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.eapNotSure}
+
+
+
+
+
+
+ {strings.eapNotSure}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.eapSubmitButton}
+
+
+
+ );
+}
diff --git a/app/src/views/EapRegistration/schema.ts b/app/src/views/EapRegistration/schema.ts
new file mode 100644
index 0000000000..17dca9c0b7
--- /dev/null
+++ b/app/src/views/EapRegistration/schema.ts
@@ -0,0 +1,56 @@
+import {
+ emailCondition,
+ type ObjectSchema,
+ type PartialForm,
+} from '@togglecorp/toggle-form';
+
+import { type GoApiBody } from '#utils/restRequest';
+
+type EapRegisterRequestBody = GoApiBody<'/api/v2/eap-registration/', 'POST'>;
+
+export const defaultFormValue: FormFields = {
+ eap_type: undefined,
+ expected_submission_time: undefined,
+};
+
+type FormFields = PartialForm;
+
+type FormSchema = ObjectSchema;
+type FormSchemaFields = ReturnType
+
+export const formSchema: FormSchema = {
+ fields: (): FormSchemaFields => ({
+ national_society: {
+ required: true,
+ },
+ country: {
+ required: true,
+ },
+ disaster_type: {
+ required: true,
+ },
+ eap_type: {},
+ expected_submission_time: {},
+ partners: {
+ required: true,
+ },
+ national_society_contact_name: {},
+ national_society_contact_title: {},
+ national_society_contact_email: {
+ validations: [emailCondition],
+ },
+ national_society_contact_phone_number: {},
+ ifrc_contact_name: {},
+ ifrc_contact_title: {},
+ ifrc_contact_email: {
+ validations: [emailCondition],
+ },
+ ifrc_contact_phone_number: {},
+ dref_focal_point_name: {},
+ dref_focal_point_title: {},
+ dref_focal_point_email: {
+ validations: [emailCondition],
+ },
+ dref_focal_point_phone_number: {},
+ }),
+};
diff --git a/app/src/views/EarlyActionProtocols/i18n.json b/app/src/views/EarlyActionProtocols/i18n.json
new file mode 100644
index 0000000000..9472e2952a
--- /dev/null
+++ b/app/src/views/EarlyActionProtocols/i18n.json
@@ -0,0 +1,14 @@
+{
+ "namespace": "earlyActionProtocols",
+ "strings": {
+ "eapRegistrationLink": "Register your EAP",
+ "eapContent": "What is an EAP?",
+ "eapContentHeading": "Early Action Protocols (EAPs) are a core mechanism of the IFRC's Forecast-based Financing (FbF) approach, designed to ensure that humanitarian action happens before a disaster strikes, rather than only responding afterwards.",
+ "eapContentSubHeadingOne": "An EAP is a pre-agreed plan developed by a Nation Society together with partners, which outlines:",
+ "eapDescriptionOne": "The triggers (based on scientific forecasts and risk analysis) that indicate when a hazard is likely to impact communities.",
+ "eapDescriptionTwo": "The early actions to be implemented once those triggers are met - practical, life-saving measures that reduce the impacts of the forecasted disaster.",
+ "eapDescriptionThree": "The roles, responsibilities, and budget required to carry out these actions quickly and effectively.",
+ "eapContentSubHeadingTwo": "Why are the EAPs Important?",
+ "eapContentSubHeadingThree": "What is the EAP Application Process?"
+ }
+}
diff --git a/app/src/views/EarlyActionProtocols/index.tsx b/app/src/views/EarlyActionProtocols/index.tsx
new file mode 100644
index 0000000000..98ac8ff321
--- /dev/null
+++ b/app/src/views/EarlyActionProtocols/index.tsx
@@ -0,0 +1,71 @@
+import {
+ Container,
+ ExpandableContainer,
+ ListView,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+
+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.eapRegistrationLink}
+
+ )}
+ >
+
+
+
+ {strings.eapContentHeading}
+
+
+ {strings.eapContentSubHeadingOne}
+
+
+ {strings.eapDescriptionOne}
+
+
+ {strings.eapDescriptionTwo}
+
+
+ {strings.eapDescriptionThree}
+
+
+
+
+ {/* TODO: Add remaining content */}
+
+ {/* TODO: Add real content and replace with strings */}
+ EAP content sub heading description two
+
+
+ {/* TODO: Add real content and replace with strings */}
+ EAP content sub heading description three
+
+
+
+ );
+}
+
+Component.displayName = 'EarlyActionProtocols';
diff --git a/app/src/views/PerPrioritizationForm/ComponentInput/index.tsx b/app/src/views/PerPrioritizationForm/ComponentInput/index.tsx
index 3a496a9b05..2a2835e370 100644
--- a/app/src/views/PerPrioritizationForm/ComponentInput/index.tsx
+++ b/app/src/views/PerPrioritizationForm/ComponentInput/index.tsx
@@ -208,9 +208,8 @@ function ComponentInput(props: Props) {
{answerStats.map((answerStat) => (
();
+ const [searchParams] = useSearchParams();
+
+ const version = searchParams.get('version') ?? undefined;
+ const showDiff = searchParams.get('diff') ?? undefined;
+
+ const strings = useTranslation(i18n);
+
+ const {
+ pending: eapRegistrationPending,
+ response: eapRegistrationResponse,
+ } = useRequest({
+ skip: isFalsyString(eapId),
+ url: '/api/v2/eap-registration/{id}/',
+ pathVariables: isTruthyString(eapId) ? {
+ id: Number(eapId),
+ } : undefined,
+ });
+
+ const selectedSimplifiedEap = eapRegistrationResponse?.simplified_eap_details?.find(
+ (simplifiedEap) => String(simplifiedEap.version) === String(version),
+ );
+
+ const latestSimplifiedEapVersion = eapRegistrationResponse?.latest_simplified_eap;
+ const latestSimplifiedEap = eapRegistrationResponse?.simplified_eap_details?.find(
+ (simplifiedEap) => simplifiedEap.version === latestSimplifiedEapVersion,
+ );
+
+ const currentSimplifiedEap = selectedSimplifiedEap ?? latestSimplifiedEap;
+ const currentSimplifiedEapId = currentSimplifiedEap?.id;
+
+ const prevSimplifiedEapVersion = isDefined(currentSimplifiedEap?.version)
+ && currentSimplifiedEap.version > 1
+ ? currentSimplifiedEap.version - 1
+ : undefined;
+ const prevSimplifiedEap = eapRegistrationResponse?.simplified_eap_details.find(
+ (simplifiedEap) => simplifiedEap.version === prevSimplifiedEapVersion,
+ );
+
+ const {
+ pending: simplifiedEapPending,
+ response: simplifiedEapResponse,
+ } = useRequest({
+ skip: isNotDefined(currentSimplifiedEapId),
+ url: '/api/v2/simplified-eap/{id}/',
+ pathVariables: isDefined(currentSimplifiedEapId) ? {
+ id: Number(currentSimplifiedEapId),
+ } : undefined,
+ });
+
+ const {
+ pending: prevSimplifiedEapPending,
+ response: prevSimplifiedEapResponse,
+ } = useRequest({
+ skip: isNotDefined(prevSimplifiedEap) || showDiff?.toLowerCase() !== 'true',
+ url: '/api/v2/simplified-eap/{id}/',
+ pathVariables: isDefined(prevSimplifiedEap) ? {
+ id: Number(prevSimplifiedEap.id),
+ } : undefined,
+ });
+
+ const { eap_sector, eap_approach } = useGlobalEnums();
+
+ const eapSectorTitleMap = listToMap(
+ eap_sector,
+ ({ key }) => key,
+ ({ value }) => value,
+ );
+
+ const eapApproachTitleMap = listToMap(
+ eap_approach,
+ ({ key }) => key,
+ ({ value }) => value,
+ );
+
+ const {
+ disaster_type_details,
+ country_details,
+ } = eapRegistrationResponse ?? {};
+
+ const {
+ cover_image_file,
+ admin2_details,
+
+ total_budget,
+ readiness_budget,
+ pre_positioning_budget,
+ early_action_budget,
+ people_targeted,
+ seap_timeframe,
+ seap_lead_time,
+ operational_timeframe,
+
+ prioritized_hazard_and_impact,
+ risks_selected_protocols,
+
+ overall_objective_intervention,
+ potential_geographical_high_risk_areas,
+ assisted_through_operation,
+ trigger_statement,
+ trigger_threshold_justification,
+ next_step_towards_full_eap,
+ planned_operations,
+ enable_approaches,
+
+ early_action_capability,
+ rcrc_movement_involvement,
+
+ national_society_contact_name,
+ national_society_contact_email,
+ national_society_contact_title,
+ national_society_contact_phone_number,
+ } = simplifiedEapResponse ?? {};
+
+ const {
+ prioritized_hazard_and_impact: prev_prioritized_hazard_and_impact,
+ risks_selected_protocols: prev_risks_selected_protocols,
+ overall_objective_intervention: prev_overall_objective_intervention,
+ potential_geographical_high_risk_areas: prev_potential_geographical_high_risk_areas,
+ assisted_through_operation: prev_assisted_through_operation,
+ trigger_statement: prev_trigger_statement,
+ trigger_threshold_justification: prev_trigger_threshold_justification,
+ next_step_towards_full_eap: prev_next_step_towards_full_eap,
+ early_action_capability: prev_early_action_capability,
+ rcrc_movement_involvement: prev_rcrc_movement_involvement,
+ } = prevSimplifiedEapResponse ?? {};
+
+ const eapTitle = [
+ country_details?.name,
+ admin2_details?.map(({ name }) => name).join(', '),
+ disaster_type_details?.name,
+ ].filter(isTruthyString).join(' | ');
+
+ const previewReady = !eapRegistrationPending
+ && !simplifiedEapPending
+ && !prevSimplifiedEapPending;
+
+ return (
+
+ {strings.pageTitleSimplifiedText}
+
+ {strings.pageTitleEapText}
+ >
+ )}
+ description={eapTitle ?? '--'}
+ dataReady={previewReady}
+ >
+ {isDefined(cover_image_file?.file) && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {planned_operations?.map((operation) => (
+
+
+
+
+
+
+
+
+ {strings.indicatorTitleLabel}
+
+
+ {strings.indicatorTargetLabel}
+
+ {operation.indicators.map((indicator) => (
+
+ ))}
+
+
+
+
+ {operation.readiness_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+
+ {operation.prepositioning_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+
+ {operation.early_action_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+ ))}
+
+
+ {enable_approaches?.map((approach) => (
+
+
+
+
+
+
+
+ {strings.indicatorTitleLabel}
+
+
+ {strings.indicatorTargetLabel}
+
+ {approach.indicators.map((indicator) => (
+
+ ))}
+
+
+
+
+ {approach.readiness_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+
+ {approach.prepositioning_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+
+ {approach.early_action_activities.map((activity) => (
+
+ {activity.activity}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/*
+
+
+
+ */}
+
+
+
+ {strings.contactInformationDescription}
+
+
+
+
+
+
+
+ );
+}
+
+Component.displayName = 'SimplifiedEapExport';
diff --git a/app/src/views/SimplifiedEapExport/styles.module.css b/app/src/views/SimplifiedEapExport/styles.module.css
new file mode 100644
index 0000000000..c9628d6851
--- /dev/null
+++ b/app/src/views/SimplifiedEapExport/styles.module.css
@@ -0,0 +1,11 @@
+.meta-items {
+ display: grid;
+ grid-gap: var(--go-ui-width-separator-md);
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+}
+
+.indicator-items {
+ display: grid;
+ grid-gap: var(--go-ui-width-separator-md);
+ grid-template-columns: 2fr 1fr;
+}
diff --git a/app/src/views/SimplifiedEapForm/ContactInputsSection/index.tsx b/app/src/views/SimplifiedEapForm/ContactInputsSection/index.tsx
new file mode 100644
index 0000000000..4f49a02336
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/ContactInputsSection/index.tsx
@@ -0,0 +1,86 @@
+import {
+ InputSection,
+ TextInput,
+} from '@ifrc-go/ui';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+} from '@togglecorp/toggle-form';
+
+import {
+ type PartialSimplifiedEapType,
+ type ValidContactFieldPrefixes,
+} from '../schema';
+
+interface Props {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ namePrefix: ValidContactFieldPrefixes;
+ value: PartialSimplifiedEapType;
+ setFieldValue: (...entries: EntriesAsList) => void;
+ error: Error | undefined;
+ disabled?: boolean;
+}
+
+function ContactInputsSection(props: Props) {
+ const {
+ title: sectionTitle,
+ description,
+ namePrefix,
+ value,
+ setFieldValue,
+ error: formError,
+ disabled,
+ } = props;
+
+ const error = getErrorObject(formError);
+
+ const name = `${namePrefix}_name` satisfies keyof PartialSimplifiedEapType;
+ const title = `${namePrefix}_title` satisfies keyof PartialSimplifiedEapType;
+ const email = `${namePrefix}_email` satisfies keyof PartialSimplifiedEapType;
+ const phoneNumber = `${namePrefix}_phone_number` satisfies keyof PartialSimplifiedEapType;
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default ContactInputsSection;
diff --git a/app/src/views/SimplifiedEapForm/DeliveryAndBudget/i18n.json b/app/src/views/SimplifiedEapForm/DeliveryAndBudget/i18n.json
new file mode 100644
index 0000000000..7117ff40f4
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/DeliveryAndBudget/i18n.json
@@ -0,0 +1,21 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "deliverHeading": "Conditions to deliver the Early Action",
+ "deliverEarlyActions": "Experience and/or capacity to implement the early actions",
+ "deliverEarlyActionsDescription": "Assumptions or minimum conditions needed to deliver on the early actions (including issues to be resolved.) Explain how the National Society will be able to delive on the early actions, what experiences and/or capacities they have related to be intervention. Are there issues to be addressed in order for the Naional Society to deliver on these actions? How will these issues be resolved?",
+ "deliverDescription": "Description",
+ "deliverInvolved": "RCRC Movement partners, Governmental/other agencies consulted/involved",
+ "deliverInvolvedDescription": "Explain who was part of the development of this plan, how were they involved and if they have any role on the implementation of the actions. Add any relevant information of the National Society's role on the National Disaster Response System.",
+ "budgetHeading": "Budget",
+ "deliverTotalBudget": "Total budget",
+ "deliverTotalBudgetDescription": "Add the expected budget amount",
+ "deliverBudgetLabel": "Budget",
+ "deliverReadinessLabel": "Readiness",
+ "deliverPrepositioning": "Prepositioning",
+ "deliverBudgetDetails": "Budget details",
+ "deliverBudgetDetailsDescription": "Add here the page from the budget template called EAP for publication.",
+ "earlyAction": "Early Action",
+ "upload": "Upload"
+ }
+}
diff --git a/app/src/views/SimplifiedEapForm/DeliveryAndBudget/index.tsx b/app/src/views/SimplifiedEapForm/DeliveryAndBudget/index.tsx
new file mode 100644
index 0000000000..7e8eb9ed1a
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/DeliveryAndBudget/index.tsx
@@ -0,0 +1,155 @@
+import {
+ Container,
+ InputSection,
+ ListView,
+ NumberInput,
+ TextArea,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+ getErrorString,
+} from '@togglecorp/toggle-form';
+
+import GoSingleFileInput from '#components/domain/GoSingleFileInput';
+import TabPage from '#components/TabPage';
+
+import { type PartialSimplifiedEapType } from '../schema';
+
+import i18n from './i18n.json';
+
+interface Props {
+ value: PartialSimplifiedEapType;
+ setFieldValue: (...entries: EntriesAsList) => void;
+ error: Error | undefined;
+ disabled?: boolean;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+}
+
+function DeliveryAndBudget(props: Props) {
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ disabled,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const error = getErrorObject(formError);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.upload}
+
+
+
+
+
+ );
+}
+
+export default DeliveryAndBudget;
diff --git a/app/src/views/SimplifiedEapForm/EarlyAction/i18n.json b/app/src/views/SimplifiedEapForm/EarlyAction/i18n.json
new file mode 100644
index 0000000000..f2bd8dbe50
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EarlyAction/i18n.json
@@ -0,0 +1,28 @@
+{
+ "namespace": "form",
+ "strings": {
+ "actionHeading": "Early Action Intervention",
+ "intervention": "Overall objective of the intervention",
+ "interventionDescription": "Provide an objective statement that describes the main goal of the intervention.",
+ "geographicalRiskArea": "Potential geographical high-risk areas that the simplified EAP would target",
+ "geographicalRiskAreaDescription": "Which high risk regions have been selected for this intervention and why?",
+ "actionDescription": "Description",
+ "actionValue": "Value",
+ "actionPeopleTargeted": "People targeted",
+ "actionPeopleTargetedDescription": "Add the number of people targeted for the event.",
+ "actionOperation": "Assisted through the operation",
+ "actionOperationDescription": "List who will be targeted by the early actions in this simplified EAP (these should be groups of people who are most exposed combined with those moose vulnerable to the impacts of the hazard)",
+ "actionCriteria": "Explain your selection criteria for who will be targeted.",
+ "actionsStatement": "Trigger(s) statement",
+ "actionsStatementDescription": "State clear and precise criteria that will have to be met for the simplified EAP to be activated. If multiple triggers are used, indicate which trigger is linked to which early action.",
+ "actionsLeadTime": "sEAP Lead Time",
+ "actionsLeadTimeDescription": "This is the time between the trigger being met and the impact of the hazard, when the early actions are undertaken.",
+ "actionsOperational": "Operational Timeframe",
+ "actionsOperationalDescription": "The operational timeframe starts from the trigger date and includes the time it takes to implement the early action activities plus the time it takes to finalize the operation, including time to settle the finances, facilities the lessons learned workshop and prepare the final report.",
+ "justification": "Trigger threshold justification",
+ "justificationDescription": "Explain how the triggers(s) were set and provide information showing that the level chosen has caused humanitarian impact in the past.",
+ "fullEap": "Next step towards full EAP",
+ "fullEapDescription": "For National Societies that intend to develop a full EAP, outline the next steps you will be taking to continue developing this simplified EAP into a full EAP.",
+ "operationTimeFrame": "Time Frame"
+ }
+}
\ No newline at end of file
diff --git a/app/src/views/SimplifiedEapForm/EarlyAction/index.tsx b/app/src/views/SimplifiedEapForm/EarlyAction/index.tsx
new file mode 100644
index 0000000000..f38ac699a6
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EarlyAction/index.tsx
@@ -0,0 +1,248 @@
+import {
+ Container,
+ InputSection,
+ ListView,
+ NumberInput,
+ SelectInput,
+ TextArea,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { stringValueSelector } from '@ifrc-go/ui/utils';
+import { isDefined } from '@togglecorp/fujs';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+ getErrorString,
+} from '@togglecorp/toggle-form';
+
+import Admin2Input from '#components/domain/Admin2Input';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+import { TIMEFRAME_YEAR } from '#utils/constants';
+import { type GoApiResponse } from '#utils/restRequest';
+
+import { type PartialSimplifiedEapType } from '../schema';
+
+import i18n from './i18n.json';
+
+type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>;
+
+type TimeframeOption = NonNullable[number];
+
+function timeframeKeySelector(option: TimeframeOption) {
+ return option.key;
+}
+
+interface Props {
+ value: NonNullable;
+ setFieldValue: (...entries: EntriesAsList) => void;
+ error: Error | undefined;
+ disabled?: boolean;
+ eapRegistrationDetail?: GoApiResponse<'/api/v2/eap-registration/{id}/'>;
+}
+
+function EarlyAction(props: Props) {
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ disabled,
+ eapRegistrationDetail,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const error = getErrorObject(formError);
+
+ const {
+ eap_timeframe,
+ } = useGlobalEnums();
+
+ const eapTimeframeOption = eap_timeframe?.filter(
+ (item) => item.key !== TIMEFRAME_YEAR,
+ );
+
+ return (
+
+
+
+
+
+
+
+ {isDefined(eapRegistrationDetail?.country) && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default EarlyAction;
diff --git a/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/i18n.json b/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/i18n.json
new file mode 100644
index 0000000000..bbf24b60e2
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/i18n.json
@@ -0,0 +1,13 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "approachBudget": "Budget",
+ "approachApCode": "AP Code",
+ "approachRemoveButton": "Remove",
+ "approachAddActivityButton": "Add Activity",
+ "approachReadinessActivities": "Readiness Activities",
+ "approachPrepositioningActivities": "Pre-positioning Activities",
+ "approachEarlyActionActivities": "Early Action Activities",
+ "approachNoActivitiesMessage": "No activities added yet"
+ }
+}
\ No newline at end of file
diff --git a/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/index.tsx b/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/index.tsx
new file mode 100644
index 0000000000..dfc63cdc36
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EnablingApproaches/ApproachesInput/index.tsx
@@ -0,0 +1,386 @@
+import { useCallback } from 'react';
+import {
+ AddLineIcon,
+ DeleteBinTwoLineIcon,
+} from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ ExpandableContainer,
+ ListView,
+ NumberInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isNotDefined,
+ randomString,
+} from '@togglecorp/fujs';
+import {
+ type ArrayError,
+ getErrorObject,
+ type SetValueArg,
+ useFormArray,
+ useFormObject,
+} from '@togglecorp/toggle-form';
+
+import OperationActivityInput from '#components/domain/OperationActivityInput';
+import NonFieldError from '#components/NonFieldError';
+
+import IndicatorInput from '../../IndicatorInput';
+import { type PartialSimplifiedEapType } from '../../schema';
+
+import i18n from './i18n.json';
+
+type EnableApproachesFormFields = NonNullable[number];
+type EarlyActionFormFields = NonNullable[number];
+type PrepositioningFormFields = NonNullable[number];
+type ReadinessFormFields = NonNullable[number];
+type IndicatorFormFields = NonNullable[number];
+
+const defaultApproachValue: EnableApproachesFormFields = {
+ approach: 10,
+};
+
+interface Props {
+ value: EnableApproachesFormFields;
+ error: ArrayError | undefined;
+ onChange: (value: SetValueArg, index: number) => void;
+ onRemove: (index: number) => void;
+ index: number;
+ disabled?: boolean;
+ approachTitle?: React.ReactNode;
+}
+
+function OperationsBySectorInput(props: Props) {
+ const {
+ error: errorFromProps,
+ onChange,
+ value,
+ index,
+ onRemove,
+ disabled,
+ approachTitle,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const onFieldChange = useFormObject(index, onChange, defaultApproachValue);
+
+ const error = (value && value.approach && errorFromProps)
+ ? getErrorObject(errorFromProps?.[value.approach])
+ : undefined;
+
+ const {
+ setValue: onEarlyActionChange,
+ removeValue: onEarlyActionRemove,
+ } = useFormArray<'early_action_activities', EarlyActionFormFields>(
+ 'early_action_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onPrepositioningChange,
+ removeValue: onPrepositioningRemove,
+ } = useFormArray<'prepositioning_activities', PrepositioningFormFields>(
+ 'prepositioning_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onReadinessChange,
+ removeValue: onReadinessRemove,
+ } = useFormArray<'readiness_activities', ReadinessFormFields>(
+ 'readiness_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onIndicatorChange,
+ removeValue: onIndicatorRemove,
+ } = useFormArray<'indicators', IndicatorFormFields>(
+ 'indicators' as const,
+ onFieldChange,
+ );
+
+ const handleEarlyActionAddButtonClick = useCallback(
+ () => {
+ const newActionItem: EarlyActionFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: EarlyActionFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'early_action_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handlePrepositioningAddButtonClick = useCallback(
+ () => {
+ const newActionItem: PrepositioningFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: PrepositioningFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'prepositioning_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handleReadinessAddButtonClick = useCallback(
+ () => {
+ const newActionItem: ReadinessFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: ReadinessFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'readiness_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handleIndicatorAddButtonClick = useCallback(
+ () => {
+ const newIndicator: IndicatorFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: IndicatorFormFields[] | undefined) => (
+ [...(oldValue ?? []), newIndicator]
+ ),
+ 'indicators' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ return (
+
+
+
+ )}
+ withPadding
+ withBackground
+ initiallyExpanded
+ >
+
+
+
+
+
+
+ }
+ // FIXME: use strings
+ >
+ Add Indicator
+
+ )}
+ empty={isNotDefined(value.indicators) || value.indicators.length === 0}
+ // FIXME: use strings
+ emptyMessage="No indicators yet"
+ >
+
+ {value.indicators?.map((indicator, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.approachAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.readiness_activities)
+ || value.readiness_activities.length === 0}
+ emptyMessage={strings.approachNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.readiness_activities?.map((activity, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.approachAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.prepositioning_activities)
+ || value.prepositioning_activities.length === 0}
+ emptyMessage={strings.approachNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.prepositioning_activities?.map((activity, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.approachAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.early_action_activities)
+ || value.early_action_activities.length === 0}
+ emptyMessage={strings.approachNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.early_action_activities?.map((activity, i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default OperationsBySectorInput;
diff --git a/app/src/views/SimplifiedEapForm/EnablingApproaches/i18n.json b/app/src/views/SimplifiedEapForm/EnablingApproaches/i18n.json
new file mode 100644
index 0000000000..b2fc7004f4
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EnablingApproaches/i18n.json
@@ -0,0 +1,7 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "enablingApproachesTitle": "Enabling Approaches",
+ "enablingApproachesDescription": "Select approaches which are used in this Early Action Protocol."
+ }
+}
\ No newline at end of file
diff --git a/app/src/views/SimplifiedEapForm/EnablingApproaches/index.tsx b/app/src/views/SimplifiedEapForm/EnablingApproaches/index.tsx
new file mode 100644
index 0000000000..322c482580
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/EnablingApproaches/index.tsx
@@ -0,0 +1,137 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ Checklist,
+ Container,
+ InputSection,
+ ListView,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { stringValueSelector } from '@ifrc-go/ui/utils';
+import { listToMap } from '@togglecorp/fujs';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+ useFormArray,
+} from '@togglecorp/toggle-form';
+
+import NonFieldError from '#components/NonFieldError';
+import { type components } from '#generated/types';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+
+import { type PartialSimplifiedEapType } from '../schema';
+import ApproachesInput from './ApproachesInput';
+
+import i18n from './i18n.json';
+
+type EapApproach = components['schemas']['EapApproachEnumKey'];
+type EapApproachOption = components['schemas']['EapApproachEnum'];
+
+type EnablingApproachesFormFields = NonNullable[number];
+
+interface Props {
+ value: PartialSimplifiedEapType;
+ error: Error | undefined;
+ disabled?: boolean;
+ setFieldValue: (...entries: EntriesAsList) => void;
+}
+
+function approachesKeySelector(option: EapApproachOption) {
+ return option.key;
+}
+function EnablingApproaches(props: Props) {
+ const {
+ value,
+ error: formError,
+ disabled,
+ setFieldValue,
+ } = props;
+
+ const error = getErrorObject(formError);
+ const strings = useTranslation(i18n);
+
+ const { eap_approach: eapApproachOptions } = useGlobalEnums();
+
+ const eapApproachLabelMapping = useMemo(() => (
+ listToMap(
+ eapApproachOptions,
+ ({ key }) => key,
+ ({ value: label }) => label,
+ )
+ ), [eapApproachOptions]);
+
+ const {
+ setValue: onApproachChange,
+ removeValue: onApproachRemove,
+ } = useFormArray<'enable_approaches', EnablingApproachesFormFields>(
+ 'enable_approaches',
+ setFieldValue,
+ );
+
+ const handleApproachChecklistChange = useCallback((approaches: EapApproach[] | undefined) => {
+ setFieldValue((previousValue: EnablingApproachesFormFields[] | undefined) => {
+ const previousValueMapping = listToMap(
+ previousValue,
+ ({ approach }) => approach,
+ );
+
+ return approaches?.map((approach) => {
+ const prevapproachValue = previousValueMapping?.[approach];
+
+ if (prevapproachValue) {
+ return prevapproachValue;
+ }
+
+ return {
+ approach,
+ } satisfies EnablingApproachesFormFields;
+ });
+ }, 'enable_approaches');
+ }, [setFieldValue]);
+
+ const selectedApproaches = value?.enable_approaches?.map(({ approach }) => approach);
+
+ return (
+
+
+
+
+
+
+ {value?.enable_approaches?.map((approach, index) => (
+
+ ))}
+
+
+ );
+}
+
+export default EnablingApproaches;
diff --git a/app/src/views/SimplifiedEapForm/IndicatorInput/index.tsx b/app/src/views/SimplifiedEapForm/IndicatorInput/index.tsx
new file mode 100644
index 0000000000..a0b49bb9db
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/IndicatorInput/index.tsx
@@ -0,0 +1,98 @@
+import { DeleteBinTwoLineIcon } from '@ifrc-go/icons';
+import {
+ Button,
+ InlineLayout,
+ ListView,
+ NumberInput,
+ TextInput,
+} from '@ifrc-go/ui';
+import {
+ type ArrayError,
+ getErrorObject,
+ type PartialForm,
+ type SetValueArg,
+ useFormObject,
+} from '@togglecorp/toggle-form';
+
+import { type components } from '#generated/types';
+
+type Indicator = components['schemas']['Indicator'] & { client_id: string };
+type IndicatorFormFields = PartialForm
+
+const defaultIndicatorValue: IndicatorFormFields = {
+ client_id: '-1',
+};
+
+interface Props {
+ value: IndicatorFormFields;
+ error: ArrayError | undefined;
+ onChange: (value: SetValueArg, index: number) => void;
+ onRemove: (index: number) => void;
+ index: number;
+ disabled?: boolean;
+ readOnly?: boolean;
+}
+
+function IndicatorInput(props: Props) {
+ const {
+ error: errorFromProps,
+ readOnly,
+ onChange,
+ value,
+ index,
+ onRemove,
+ disabled,
+ } = props;
+
+ const onFieldChange = useFormObject(index, onChange, defaultIndicatorValue);
+
+ const error = (value && value.client_id && errorFromProps)
+ ? getErrorObject(errorFromProps?.[value.client_id])
+ : undefined;
+
+ return (
+
+
+
+ )}
+ spacing="sm"
+ >
+
+
+
+
+
+ );
+}
+
+export default IndicatorInput;
diff --git a/app/src/views/SimplifiedEapForm/Overview/i18n.json b/app/src/views/SimplifiedEapForm/Overview/i18n.json
new file mode 100644
index 0000000000..2b71f756ef
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/Overview/i18n.json
@@ -0,0 +1,32 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "detailsHeading": "Details",
+ "nationalSociety": "National Society (NS)",
+ "nationalSocietyDescription": "Select National Society that is planning to apply for the EAP",
+ "country": "Country",
+ "countryDescription": "The country will be pre-populated based on the NS selection, but can be adapted as needed.",
+ "disasterType": "Disaster Type",
+ "disasterTypeDescription": "Select the disaster type for which the EAP is needed.",
+ "uploadCoverImage": "Cover Photo",
+ "uploadCoverImageDescription": "Upload a image for the cover page of the publicly published DREF application.",
+ "timeframe": "sEAP Timeframe",
+ "timeframeDescription": "A simplified EAP has a timeframe of two years (unless the early actions are activiated)",
+ "contacts": "Contacts",
+ "nationalHeader": "National",
+ "delegationHeader": "Delegation",
+ "regionalHeader": "Regional and global",
+ "nSContact": "National Society Contact",
+ "nSContactDescription": "National Society contact responsible for the EAP process",
+ "partnerNS": "Partner NS",
+ "partnerNSDescription": "partner National Society contact",
+ "focalPoint": "IFRC Delegation AA Focal Point",
+ "delegation": "IFRC Head of Delegation",
+ "drefFocalPoint": "DREF Focal Point",
+ "drefFocalPointDescription": "The DREF contact person fro IFRC",
+ "regionalFocalPoint": "IFRC Regional AA Focal Point",
+ "regionalManager": "IFRC Regional Ops Manager",
+ "regionalHead": "IFRC Regional Head of DCC",
+ "regionalCoordinator": "IFRC Global Ops Coordinator"
+ }
+}
diff --git a/app/src/views/SimplifiedEapForm/Overview/index.tsx b/app/src/views/SimplifiedEapForm/Overview/index.tsx
new file mode 100644
index 0000000000..b4ce61e0a5
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/Overview/index.tsx
@@ -0,0 +1,259 @@
+import {
+ Container,
+ InputSection,
+ ListView,
+ NumberInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+ type PartialForm,
+} from '@togglecorp/toggle-form';
+
+import CountrySelectInput from '#components/domain/CountrySelectInput';
+import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput';
+import ImageWithCaptionInput from '#components/domain/ImageWithCaptionInput';
+import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput';
+import TabPage from '#components/TabPage';
+import { type GoApiBody } from '#utils/restRequest';
+
+import ContactInputsSection from '../ContactInputsSection';
+import { type PartialSimplifiedEapType } from '../schema';
+
+import i18n from './i18n.json';
+
+type EapRegisterRequestBody = GoApiBody<'/api/v2/eap-registration/', 'POST'>;
+type FormFields = PartialForm;
+
+interface Props {
+ value: PartialSimplifiedEapType;
+ setFieldValue: (...entries: EntriesAsList) => void;
+ error: Error | undefined;
+ disabled?: boolean;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+ eapRegistrationDetail?: FormFields;
+}
+
+function Overview(props: Props) {
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ disabled,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ eapRegistrationDetail,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const error = getErrorObject(formError);
+
+ const noop = () => {};
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Overview;
diff --git a/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/i18n.json b/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/i18n.json
new file mode 100644
index 0000000000..6ffc1a9a75
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/i18n.json
@@ -0,0 +1,14 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "operationPeopleTargeted": "People Targeted",
+ "operationBudget": "Budget",
+ "operationApCode": "AP Code",
+ "operationRemoveButton": "Remove",
+ "operationAddActivityButton": "Add activity",
+ "operationReadinessActivities": "Readiness Activities",
+ "operationPrepositioningActivities": "Pre-positioning Activities",
+ "operationEarlyActionActivities": "Early Action Activities",
+ "operationNoActivitiesMessage": "No activities added yet"
+ }
+}
diff --git a/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/index.tsx b/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/index.tsx
new file mode 100644
index 0000000000..626edc7580
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/PlannedOperations/OperationsInput/index.tsx
@@ -0,0 +1,391 @@
+import { useCallback } from 'react';
+import {
+ AddLineIcon,
+ DeleteBinTwoLineIcon,
+} from '@ifrc-go/icons';
+import {
+ Button,
+ Container,
+ ExpandableContainer,
+ ListView,
+ NumberInput,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ isNotDefined,
+ randomString,
+} from '@togglecorp/fujs';
+import {
+ type ArrayError,
+ getErrorObject,
+ type SetValueArg,
+ useFormArray,
+ useFormObject,
+} from '@togglecorp/toggle-form';
+
+import OperationActivityInput from '#components/domain/OperationActivityInput';
+import NonFieldError from '#components/NonFieldError';
+
+import IndicatorInput from '../../IndicatorInput';
+import { type PartialSimplifiedEapType } from '../../schema';
+
+import i18n from './i18n.json';
+
+type PlannedOperationFormFields = NonNullable[number];
+type EarlyActionFormFields = NonNullable[number];
+type PrepositioningFormFields = NonNullable[number];
+type ReadinessFormFields = NonNullable[number];
+type IndicatorFormFields = NonNullable[number];
+
+const defaultOperationValue: PlannedOperationFormFields = {
+ sector: 101,
+};
+
+interface Props {
+ value: PlannedOperationFormFields;
+ error: ArrayError | undefined;
+ onChange: (value: SetValueArg, index: number) => void;
+ onRemove: (index: number) => void;
+ index: number;
+ disabled?: boolean;
+ operationTitle?: React.ReactNode;
+}
+
+function OperationsBySectorInput(props: Props) {
+ const {
+ error: errorFromProps,
+ onChange,
+ value,
+ index,
+ onRemove,
+ disabled,
+ operationTitle,
+ } = props;
+
+ const strings = useTranslation(i18n);
+ const onFieldChange = useFormObject(index, onChange, defaultOperationValue);
+
+ const error = (value && value.sector && errorFromProps)
+ ? getErrorObject(errorFromProps?.[value.sector])
+ : undefined;
+
+ const {
+ setValue: onEarlyActionChange,
+ removeValue: onEarlyActionRemove,
+ } = useFormArray<'early_action_activities', EarlyActionFormFields>(
+ 'early_action_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onPrepositioningChange,
+ removeValue: onPrepositioningRemove,
+ } = useFormArray<'prepositioning_activities', PrepositioningFormFields>(
+ 'prepositioning_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onReadinessChange,
+ removeValue: onReadinessRemove,
+ } = useFormArray<'readiness_activities', ReadinessFormFields>(
+ 'readiness_activities' as const,
+ onFieldChange,
+ );
+ const {
+ setValue: onIndicatorChange,
+ removeValue: onIndicatorRemove,
+ } = useFormArray<'indicators', IndicatorFormFields>(
+ 'indicators' as const,
+ onFieldChange,
+ );
+
+ const handleEarlyActionAddButtonClick = useCallback(
+ () => {
+ const newActionItem: EarlyActionFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: EarlyActionFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'early_action_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handlePrepositioningAddButtonClick = useCallback(
+ () => {
+ const newActionItem: PrepositioningFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: PrepositioningFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'prepositioning_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handleReadinessAddButtonClick = useCallback(
+ () => {
+ const newActionItem: ReadinessFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: ReadinessFormFields[] | undefined) => (
+ [...(oldValue ?? []), newActionItem]
+ ),
+ 'readiness_activities' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ const handleIndicatorAddButtonClick = useCallback(
+ () => {
+ const newIndicator: IndicatorFormFields = {
+ client_id: randomString(),
+ };
+
+ onFieldChange(
+ (oldValue: IndicatorFormFields[] | undefined) => (
+ [...(oldValue ?? []), newIndicator]
+ ),
+ 'indicators' as const,
+ );
+ },
+ [onFieldChange],
+ );
+
+ return (
+
+
+
+ )}
+ withPadding
+ withBackground
+ initiallyExpanded
+ withHeaderBorder
+ // FIXME: add non field error and error indicator
+ >
+
+
+
+
+
+
+
+ }
+ // FIXME: use strings
+ >
+ Add Indicator
+
+ )}
+ empty={isNotDefined(value.indicators) || value.indicators.length === 0}
+ // FIXME: use strings
+ emptyMessage="No indicators yet"
+ >
+
+ {value.indicators?.map((indicator, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.operationAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.readiness_activities)
+ || value.readiness_activities.length === 0}
+ emptyMessage={strings.operationNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.readiness_activities?.map((activity, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.operationAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.prepositioning_activities)
+ || value.prepositioning_activities.length === 0}
+ emptyMessage={strings.operationNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.prepositioning_activities?.map((activity, i) => (
+
+ ))}
+
+
+ }
+ >
+ {strings.operationAddActivityButton}
+
+ )}
+ withCompactMessage
+ empty={isNotDefined(value.early_action_activities)
+ || value.early_action_activities.length === 0}
+ emptyMessage={strings.operationNoActivitiesMessage}
+ headerDescription={(
+
+ )}
+ >
+
+ {value?.early_action_activities?.map((activity, i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default OperationsBySectorInput;
diff --git a/app/src/views/SimplifiedEapForm/PlannedOperations/i18n.json b/app/src/views/SimplifiedEapForm/PlannedOperations/i18n.json
new file mode 100644
index 0000000000..fe89c1f8e0
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/PlannedOperations/i18n.json
@@ -0,0 +1,7 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "plannedOperationsTitle": "Planned Operations",
+ "plannedOperationsDescription": "Select sectors which are used in this Early Action Protocol."
+ }
+}
\ No newline at end of file
diff --git a/app/src/views/SimplifiedEapForm/PlannedOperations/index.tsx b/app/src/views/SimplifiedEapForm/PlannedOperations/index.tsx
new file mode 100644
index 0000000000..436bdd6d2a
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/PlannedOperations/index.tsx
@@ -0,0 +1,140 @@
+import {
+ useCallback,
+ useMemo,
+} from 'react';
+import {
+ Checklist,
+ Container,
+ InputSection,
+ ListView,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { stringValueSelector } from '@ifrc-go/ui/utils';
+import { listToMap } from '@togglecorp/fujs';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+ useFormArray,
+} from '@togglecorp/toggle-form';
+
+import NonFieldError from '#components/NonFieldError';
+import TabPage from '#components/TabPage';
+import { type components } from '#generated/types';
+import useGlobalEnums from '#hooks/domain/useGlobalEnums';
+
+import { type PartialSimplifiedEapType } from '../schema';
+import OperationInput from './OperationsInput';
+
+import i18n from './i18n.json';
+
+type EapSector = components['schemas']['EapSectorEnumKey'];
+type EapSectorOption = components['schemas']['EapSectorEnum'];
+
+type PlannedOperationFormFields = NonNullable[number];
+function sectorKeySelector(option: EapSectorOption) {
+ return option.key;
+}
+
+interface Props {
+ value: PartialSimplifiedEapType;
+ error: Error | undefined;
+ disabled?: boolean;
+ setFieldValue: (...entries: EntriesAsList) => void;
+}
+
+function PlannedOperations(props: Props) {
+ const {
+ value,
+ error: formError,
+ disabled,
+ setFieldValue,
+ } = props;
+
+ const error = getErrorObject(formError);
+ const strings = useTranslation(i18n);
+ const { eap_sector: eapSectorOptions } = useGlobalEnums();
+
+ const eapSectorLabelMapping = useMemo(() => (
+ listToMap(
+ eapSectorOptions,
+ ({ key }) => key,
+ ({ value: label }) => label,
+ )
+ ), [eapSectorOptions]);
+
+ const {
+ setValue: onOperationChange,
+ removeValue: onOperationRemove,
+ } = useFormArray<'planned_operations', PlannedOperationFormFields>(
+ 'planned_operations',
+ setFieldValue,
+ );
+
+ const handleOperationChecklistChange = useCallback((sectors: EapSector[] | undefined) => {
+ setFieldValue((previousValue: PlannedOperationFormFields[] | undefined) => {
+ const previousValueMapping = listToMap(
+ previousValue,
+ ({ sector }) => sector,
+ );
+
+ return sectors?.map((sector) => {
+ const prevSectorValue = previousValueMapping?.[sector];
+
+ if (prevSectorValue) {
+ return prevSectorValue;
+ }
+
+ return {
+ sector,
+ } satisfies PlannedOperationFormFields;
+ });
+ }, 'planned_operations');
+ }, [setFieldValue]);
+
+ const selectedSectors = value?.planned_operations?.map(({ sector }) => sector);
+
+ return (
+
+
+
+
+
+
+
+
+ {value?.planned_operations?.map((operation, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default PlannedOperations;
diff --git a/app/src/views/SimplifiedEapForm/RiskAnalysis/i18n.json b/app/src/views/SimplifiedEapForm/RiskAnalysis/i18n.json
new file mode 100644
index 0000000000..ae276f6add
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/RiskAnalysis/i18n.json
@@ -0,0 +1,14 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "simplifiedEapRiskHeading": "Risk Analysis",
+ "simplifiedEapRiskDescription": "Which hazard needs to be addressed by aniticipatory actions in the country as a priority? Provide an explanation on the reasons that the selected hazard was chosen for this simplified Eap, why it is a major problem in the country and the humanitarian impacts it has caused in the past. Describe the extent to which this hazard has produced and/or will produce negative impacts on lives, livelihoods, well-being and other development aspects.",
+ "simplifiedHistoricalImpact": "Prioritized hazard and its historical Impact",
+ "simplifiedFormDescriptionLabel": "Description",
+ "simplifiedFormRiskProtocol": "Risk selected for the protocol",
+ "simplifiedFormRiskProtocolDescription": "Recognizing that the simplified EAP will not be able to addredd all potential risks and based on the analysis of past impacts, what are the main risks that the National Society has decided to focus on for this plan.",
+ "simplifiedFormEarlyActionSelection": "Early Action Selection",
+ "simplifiedFormSelectedEarlyAction": "Selected early actions",
+ "simplifiedFormSelectedEarlyActionDescription": "Describe the selected early actions and explain how they will address the risks and lead to the intended outcome. Explain how earlyactions were selecred and how they will mitigate the prioritized risk and being about the intended outcome for the most vulnerable, at-risk individuals and communities."
+ }
+}
\ No newline at end of file
diff --git a/app/src/views/SimplifiedEapForm/RiskAnalysis/index.tsx b/app/src/views/SimplifiedEapForm/RiskAnalysis/index.tsx
new file mode 100644
index 0000000000..58e1c7ab94
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/RiskAnalysis/index.tsx
@@ -0,0 +1,135 @@
+import {
+ Container,
+ InputSection,
+ ListView,
+ TextArea,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import {
+ type EntriesAsList,
+ type Error,
+ getErrorObject,
+} from '@togglecorp/toggle-form';
+
+import MultiImageWithCaptionInput from '#components/domain/MultiImageWithCaptionInput';
+import TabPage from '#components/TabPage';
+
+import { type PartialSimplifiedEapType } from '../schema';
+
+import i18n from './i18n.json';
+
+interface Props {
+ value: PartialSimplifiedEapType;
+ setFieldValue: (...entries: EntriesAsList) => void;
+ error: Error | undefined;
+ disabled?: boolean;
+ fileIdToUrlMap: Record;
+ setFileIdToUrlMap?: React.Dispatch>>;
+}
+
+function RiskAnalysis(props: Props) {
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ disabled,
+ fileIdToUrlMap,
+ setFileIdToUrlMap,
+ } = props;
+
+ const error = getErrorObject(formError);
+ const strings = useTranslation(i18n);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default RiskAnalysis;
diff --git a/app/src/views/SimplifiedEapForm/common.tsx b/app/src/views/SimplifiedEapForm/common.tsx
new file mode 100644
index 0000000000..b1acd48662
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/common.tsx
@@ -0,0 +1,124 @@
+import { isNotDefined } from '@togglecorp/fujs';
+import {
+ analyzeErrors,
+ type Error,
+ getErrorObject,
+} from '@togglecorp/toggle-form';
+
+import { type PartialSimplifiedEapType } from './schema';
+
+export type TabKeys = 'overview' | 'riskAnalysis' | 'earlyAction' | 'plannedOperations' | 'enablingApproaches' | 'deliveryAndBudget';
+
+const overviewTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'cover_image_file',
+ 'seap_timeframe',
+ 'national_society_contact_name',
+ 'national_society_contact_title',
+ 'national_society_contact_email',
+ 'national_society_contact_phone_number',
+ 'partner_ns_name',
+ 'partner_ns_title',
+ 'partner_ns_email',
+ 'partner_ns_phone_number',
+ 'dref_focal_point_name',
+ 'dref_focal_point_email',
+ 'dref_focal_point_phone_number',
+ 'ifrc_delegation_focal_point_name',
+ 'ifrc_delegation_focal_point_title',
+ 'ifrc_delegation_focal_point_email',
+ 'ifrc_delegation_focal_point_phone_number',
+ 'dref_focal_point_name',
+ 'dref_focal_point_title',
+ 'dref_focal_point_email',
+ 'dref_focal_point_phone_number',
+ 'ifrc_regional_focal_point_name',
+ 'ifrc_regional_focal_point_title',
+ 'ifrc_regional_focal_point_email',
+ 'ifrc_regional_focal_point_phone_number',
+ 'ifrc_regional_ops_manager_name',
+ 'ifrc_regional_ops_manager_title',
+ 'ifrc_regional_ops_manager_email',
+ 'ifrc_regional_ops_manager_phone_number',
+ 'ifrc_regional_head_dcc_name',
+ 'ifrc_regional_head_dcc_title',
+ 'ifrc_regional_head_dcc_email',
+ 'ifrc_regional_head_dcc_phone_number',
+ 'ifrc_global_ops_coordinator_name',
+ 'ifrc_global_ops_coordinator_title',
+ 'ifrc_global_ops_coordinator_email',
+ 'ifrc_global_ops_coordinator_phone_number',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const riskAnalysisTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'prioritized_hazard_and_impact',
+ 'hazard_impact_images',
+ 'risks_selected_protocols',
+ 'risk_selected_protocols_images',
+ 'selected_early_actions',
+ 'selected_early_actions_images',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const earlyActionTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'overall_objective_intervention',
+ 'potential_geographical_high_risk_areas',
+ 'admin2',
+ 'people_targeted',
+ 'assisted_through_operation',
+ 'selection_criteria',
+ 'trigger_statement',
+ 'seap_lead_time',
+ 'seap_lead_timeframe_unit',
+ 'operational_timeframe',
+ 'operational_timeframe_unit',
+ 'trigger_threshold_justification',
+ 'next_step_towards_full_eap',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const plannedOperationsTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'planned_operations',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const enablingApproachesTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'enable_approaches',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const deliveryAndBudgetTabFields: (keyof PartialSimplifiedEapType)[] = [
+ 'early_action_capability',
+ 'rcrc_movement_involvement',
+ 'total_budget',
+ 'readiness_budget',
+ 'pre_positioning_budget',
+ 'early_action_budget',
+ 'budget_file',
+] satisfies (keyof PartialSimplifiedEapType)[];
+
+const tabToFieldsMap: Record = {
+ overview: overviewTabFields,
+ riskAnalysis: riskAnalysisTabFields,
+ earlyAction: earlyActionTabFields,
+ plannedOperations: plannedOperationsTabFields,
+ enablingApproaches: enablingApproachesTabFields,
+ deliveryAndBudget: deliveryAndBudgetTabFields,
+};
+
+export function checkTabErrors(
+ error: Error | undefined,
+ tabKey: TabKeys,
+) {
+ if (isNotDefined(analyzeErrors(error))) {
+ return false;
+ }
+
+ const fields = tabToFieldsMap[tabKey];
+ const fieldErrors = getErrorObject(error);
+
+ const hasErrorOnAnyField = fields.some(
+ (field) => {
+ const fieldError = fieldErrors?.[field];
+ const isErrored = analyzeErrors(fieldError);
+ return isErrored;
+ },
+ );
+
+ return hasErrorOnAnyField;
+}
diff --git a/app/src/views/SimplifiedEapForm/i18n.json b/app/src/views/SimplifiedEapForm/i18n.json
new file mode 100644
index 0000000000..a895aa311e
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/i18n.json
@@ -0,0 +1,20 @@
+{
+ "namespace": "simplifiedEapForm",
+ "strings": {
+ "simplifiedEapHeading": "Simplified EAP form",
+ "simplifiedEapDescription": "Use the following form to submit the details of your Simplified Early Action Protocols (sEAP) submission",
+ "simplifiedEapOverview": "Overview",
+ "simplifiedEapRiskAnalysis": "Risk Analysis & Early Action Selection",
+ "simplifiedEapEarlyAction": "Early Action Intervention",
+ "simplifiedPlannedOperations": "Planned Operations",
+ "simplifiedEnablingApproaches": "Enabling Approaches",
+ "simplifiedDeliverAndBudget": "Conditions to deliver & budgets",
+ "simplifiedCancelButton": "Cancel",
+ "simplifiedBackButton": "Back",
+ "simplifiedNextButton": "Next",
+ "simplifiedSaveButton": "Save",
+ "eapSimplifiedFailure": "Sorry could not create a new Simplified EAP right now!",
+ "eapSimplifiedSuccess": "Successfully created a new Simplified EAP!",
+ "eapSimplifiedUpdateMessage": "Simplified EAP Form updated Successfully."
+ }
+}
diff --git a/app/src/views/SimplifiedEapForm/index.tsx b/app/src/views/SimplifiedEapForm/index.tsx
new file mode 100644
index 0000000000..a217f57e32
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/index.tsx
@@ -0,0 +1,586 @@
+import {
+ type ElementRef,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useParams } from 'react-router-dom';
+import {
+ Button,
+ ListView,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+} from '@ifrc-go/ui';
+import { useTranslation } from '@ifrc-go/ui/hooks';
+import { injectClientId } from '@ifrc-go/ui/utils';
+import {
+ compareNumber,
+ isDefined,
+ isNotDefined,
+ isTruthyString,
+ listToMap,
+} from '@togglecorp/fujs';
+import {
+ createSubmitHandler,
+ removeNull,
+ useForm,
+} from '@togglecorp/toggle-form';
+
+import Link from '#components/Link';
+import Page from '#components/Page';
+import useAlert from '#hooks/useAlert';
+import useRouting from '#hooks/useRouting';
+import {
+ type GoApiBody,
+ type GoApiResponse,
+ useLazyRequest,
+ useRequest,
+} from '#utils/restRequest';
+import {
+ matchArray,
+ NUM,
+ type ResponseObjectError,
+ transformObjectError,
+} from '#utils/restRequest/error';
+
+import {
+ checkTabErrors,
+ type TabKeys,
+} from './common';
+import DeliveryAndBudget from './DeliveryAndBudget';
+import EarlyAction from './EarlyAction';
+import EnablingApproaches from './EnablingApproaches';
+import Overview from './Overview';
+import PlannedOperations from './PlannedOperations';
+import RiskAnalysis from './RiskAnalysis';
+import {
+ formSchema,
+ type PartialSimplifiedEapType,
+} from './schema';
+
+import i18n from './i18n.json';
+import styles from './styles.module.css';
+
+type EapSimplifiedRequestBody = GoApiBody<'/api/v2/simplified-eap/', 'POST'>;
+type GetSimplifiedResponse = GoApiResponse<'/api/v2/simplified-eap/{id}/'>;
+
+function getNextStep(current: TabKeys, direction: 1 | -1) {
+ const tabKeyList: TabKeys[] = [
+ 'overview',
+ 'riskAnalysis',
+ 'earlyAction',
+ 'plannedOperations',
+ 'enablingApproaches',
+ 'deliveryAndBudget',
+ ];
+
+ const currentIndex = tabKeyList.findIndex((key) => key === current);
+
+ return tabKeyList[currentIndex + direction];
+}
+
+/** @knipignore */
+// eslint-disable-next-line import/prefer-default-export
+export function Component() {
+ const strings = useTranslation(i18n);
+ const { navigate } = useRouting();
+
+ const {
+ value,
+ setFieldValue,
+ error: formError,
+ setError,
+ validate,
+ setValue,
+ } = useForm(formSchema, { value: {} });
+
+ const alert = useAlert();
+ const [fileIdToUrlMap, setFileIdToUrlMap] = useState>({});
+ const { eapId } = useParams<{ eapId: string }>();
+
+ const updateFileUrlMapping = useCallback((response: GetSimplifiedResponse) => {
+ setFileIdToUrlMap((prevMap) => {
+ const {
+ cover_image_file,
+
+ hazard_impact_images,
+ risk_selected_protocols_images,
+ selected_early_actions_images,
+
+ budget_file_details,
+ } = response;
+
+ return {
+ ...prevMap,
+ ...listToMap(
+ [
+ cover_image_file,
+ ...hazard_impact_images ?? [],
+ ...risk_selected_protocols_images ?? [],
+ ...selected_early_actions_images ?? [],
+ budget_file_details,
+ ].map(
+ (eapFile) => {
+ if (isNotDefined(eapFile)) {
+ return undefined;
+ }
+
+ const {
+ id,
+ file,
+ } = eapFile;
+
+ if (isNotDefined(id) || isNotDefined(file)) {
+ return undefined;
+ }
+
+ return {
+ id,
+ file,
+ };
+ },
+ ).filter(isDefined),
+ (file) => file.id,
+ (file) => file.file,
+ ),
+ };
+ });
+ }, []);
+
+ const loadResponseToFormValue = useCallback((response: GetSimplifiedResponse) => {
+ updateFileUrlMapping(response);
+
+ const {
+ planned_operations,
+ enable_approaches,
+ cover_image_file,
+ hazard_impact_images,
+ selected_early_actions_images,
+ risk_selected_protocols_images,
+ ...otherValues
+ } = removeNull(response);
+
+ setValue({
+ ...otherValues,
+
+ cover_image_file: isDefined(cover_image_file)
+ ? injectClientId(cover_image_file)
+ : undefined,
+
+ hazard_impact_images: hazard_impact_images?.map(injectClientId),
+ selected_early_actions_images: selected_early_actions_images?.map(injectClientId),
+ risk_selected_protocols_images: risk_selected_protocols_images?.map(injectClientId),
+
+ planned_operations: planned_operations?.map((intervention) => ({
+ ...intervention,
+ indicators: intervention.indicators?.map(injectClientId),
+ early_action_activities: intervention.early_action_activities?.map(injectClientId),
+ readiness_activities: intervention.readiness_activities?.map(injectClientId),
+ prepositioning_activities: intervention.prepositioning_activities
+ ?.map(injectClientId),
+ })),
+ enable_approaches: enable_approaches?.map((approach) => ({
+ ...approach,
+ indicators: approach.indicators?.map(injectClientId),
+ early_action_activities: approach.early_action_activities?.map(injectClientId),
+ readiness_activities: approach.readiness_activities?.map(injectClientId),
+ prepositioning_activities: approach.prepositioning_activities?.map(injectClientId),
+ })),
+ });
+ }, [updateFileUrlMapping, setValue]);
+
+ const processServerErrors = useCallback((errors: ResponseObjectError) => {
+ setError(transformObjectError(
+ errors,
+ (locations) => {
+ let match = matchArray(locations, ['cover_image_file', NUM]);
+ if (isDefined(match)) {
+ return value?.cover_image_file?.client_id;
+ }
+
+ match = matchArray(locations, ['hazard_impact_images', NUM]);
+ if (isDefined(match)) {
+ const [index] = match;
+ return value?.hazard_impact_images?.[index!]?.client_id;
+ }
+
+ match = matchArray(locations, ['risk_selected_protocols_images', NUM]);
+ if (isDefined(match)) {
+ const [index] = match;
+ return value?.risk_selected_protocols_images?.[index!]?.client_id;
+ }
+
+ match = matchArray(locations, ['selected_early_actions_images', NUM]);
+ if (isDefined(match)) {
+ const [index] = match;
+ return value?.selected_early_actions_images?.[index!]?.client_id;
+ }
+
+ match = matchArray(locations, ['planned_operations', NUM, 'early_action_activities', NUM]);
+ if (isDefined(match)) {
+ const [poIndex, index] = match;
+ return value?.planned_operations?.[poIndex!]
+ ?.early_action_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['planned_operations', NUM, 'readiness_activities', NUM]);
+ if (isDefined(match)) {
+ const [poIndex, index] = match;
+ return value?.planned_operations?.[poIndex!]
+ ?.readiness_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['planned_operations', NUM, 'prepositioning_activities', NUM]);
+ if (isDefined(match)) {
+ const [poIndex, index] = match;
+ return value?.planned_operations?.[poIndex!]
+ ?.prepositioning_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['planned_operations', NUM]);
+ if (isDefined(match)) {
+ const [poIndex] = match;
+ return value?.planned_operations?.[poIndex!]?.sector;
+ }
+ match = matchArray(locations, ['enable_approaches', NUM, 'early_action_activities', NUM]);
+ if (isDefined(match)) {
+ const [eaIndex, index] = match;
+ return value?.enable_approaches?.[eaIndex!]
+ ?.early_action_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['enable_approaches', NUM, 'readiness_activities', NUM]);
+ if (isDefined(match)) {
+ const [eaIndex, index] = match;
+ return value?.enable_approaches?.[eaIndex!]
+ ?.readiness_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['enable_approaches', NUM, 'prepositioning_activities', NUM]);
+ if (isDefined(match)) {
+ const [eaIndex, index] = match;
+ return value?.enable_approaches?.[eaIndex!]
+ ?.prepositioning_activities?.[index!]?.client_id;
+ }
+ match = matchArray(locations, ['enable_approaches', NUM]);
+ if (isDefined(match)) {
+ const [eaIndex] = match;
+ return value?.enable_approaches?.[eaIndex!]?.approach;
+ }
+
+ return undefined;
+ },
+ ));
+ }, [value, setError]);
+
+ const {
+ pending: fetchingEap,
+ response: eapDetailResponse,
+ } = useRequest({
+ skip: isNotDefined(eapId),
+ url: '/api/v2/eap-registration/{id}/',
+ pathVariables: isTruthyString(eapId) ? {
+ id: Number(eapId),
+ } : undefined,
+ });
+
+ // FIXME: get the latest simplified properly
+ const latestSimplifiedEapId = eapDetailResponse?.simplified_eap_details?.toSorted(
+ (a, b) => compareNumber(a.version, b.version, -1),
+ )?.[0]?.id;
+
+ // FIXME: handle errors
+ useRequest({
+ skip: isNotDefined(latestSimplifiedEapId),
+ url: '/api/v2/simplified-eap/{id}/',
+ pathVariables: isDefined(latestSimplifiedEapId) ? ({
+ id: latestSimplifiedEapId,
+ }) : undefined,
+ onSuccess: (simplifiedEapResponse) => loadResponseToFormValue(simplifiedEapResponse),
+ });
+
+ const {
+ pending: eapSimplifiedPending,
+ trigger: createSimplifiedEap,
+ } = useLazyRequest({
+ method: 'POST',
+ url: '/api/v2/simplified-eap/',
+ body: (body: EapSimplifiedRequestBody) => body,
+ onSuccess: () => {
+ const message = strings.eapSimplifiedSuccess;
+ alert.show(
+ message,
+ { variant: 'success' },
+ );
+ navigate('accountMyFormsEap');
+ },
+ onFailure: (err) => {
+ const {
+ value: {
+ formErrors,
+ messageForNotification,
+ },
+ } = err;
+
+ processServerErrors(formErrors);
+
+ alert.show(
+ strings.eapSimplifiedFailure,
+ {
+ variant: 'danger',
+ description: messageForNotification,
+ },
+ );
+ },
+ });
+
+ const {
+ pending: updateSimplifiedFormPending,
+ trigger: updateSimplifiedEap,
+ } = useLazyRequest({
+ url: '/api/v2/simplified-eap/{id}/',
+ method: 'PATCH',
+ pathVariables: {
+ id: Number(latestSimplifiedEapId),
+ },
+ body: (formFields: EapSimplifiedRequestBody) => formFields,
+ onSuccess: (response) => {
+ alert.show(
+ strings.eapSimplifiedUpdateMessage,
+ { variant: 'success' },
+ );
+
+ // FIXME: only navigate to accounts page for the submit action
+ navigate(
+ 'accountMyFormsEap',
+ { params: { eapId: response.id } },
+ );
+ },
+ onFailure: (err) => {
+ const {
+ value: {
+ formErrors,
+ messageForNotification,
+ },
+ } = err;
+
+ processServerErrors(formErrors);
+
+ alert.show(
+ strings.eapSimplifiedFailure,
+ {
+ variant: 'danger',
+ description: messageForNotification,
+ },
+ );
+ },
+ });
+
+ const disabled = eapSimplifiedPending || fetchingEap || updateSimplifiedFormPending;
+
+ const handleValidationSuccess = useCallback((validatedFormValue: PartialSimplifiedEapType) => {
+ if (isNotDefined(latestSimplifiedEapId)) {
+ createSimplifiedEap({
+ ...validatedFormValue as EapSimplifiedRequestBody,
+ eap_registration: Number(eapId),
+ });
+ } else {
+ updateSimplifiedEap({
+ ...validatedFormValue,
+ id: latestSimplifiedEapId,
+ } as EapSimplifiedRequestBody);
+ }
+ }, [
+ eapId,
+ createSimplifiedEap,
+ updateSimplifiedEap,
+ latestSimplifiedEapId,
+ ]);
+
+ const [activeTab, setActiveTab] = useState('overview');
+ const formContentRef = useRef>(null);
+
+ const handleFormError = useCallback(() => {
+ setTimeout(() => formContentRef.current?.scrollIntoView(), 200);
+ }, []);
+
+ const handleSave = useMemo(() => (
+ createSubmitHandler(
+ validate,
+ setError,
+ handleValidationSuccess,
+ handleFormError,
+ )
+ ), [
+ handleFormError,
+ handleValidationSuccess,
+ validate,
+ setError,
+ ]);
+
+ const nextStep = getNextStep(activeTab, 1);
+ const prevStep = getNextStep(activeTab, -1);
+
+ const handleTabChange = useCallback((newTab: TabKeys) => {
+ formContentRef.current?.scrollIntoView();
+ setActiveTab(newTab);
+ }, []);
+
+ return (
+
+
+
+ {strings.simplifiedCancelButton}
+
+
+ {strings.simplifiedSaveButton}
+
+ >
+ )}
+ info={(
+
+
+ {strings.simplifiedEapOverview}
+
+
+ {strings.simplifiedEapRiskAnalysis}
+
+
+ {strings.simplifiedEapEarlyAction}
+
+
+ {strings.simplifiedPlannedOperations}
+
+
+ {strings.simplifiedEnablingApproaches}
+
+
+ {strings.simplifiedDeliverAndBudget}
+
+
+ )}
+ withBackgroundColorInMainSection
+ mainSectionClassName={styles.content}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.simplifiedBackButton}
+
+ {isDefined(nextStep) ? (
+
+ {strings.simplifiedNextButton}
+
+ ) : (
+
+ {strings.simplifiedSaveButton}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/views/SimplifiedEapForm/schema.ts b/app/src/views/SimplifiedEapForm/schema.ts
new file mode 100644
index 0000000000..123477db9e
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/schema.ts
@@ -0,0 +1,356 @@
+import { type DeepReplace } from '@ifrc-go/ui/utils';
+import { isDefined } from '@togglecorp/fujs';
+import {
+ emailCondition,
+ type LiteralSchema,
+ type ObjectSchema,
+ type PartialForm,
+ type PurgeNull,
+ undefinedValue,
+} from '@togglecorp/toggle-form';
+
+import operationActivitySchema from '#components/domain/OperationActivityInput/schema';
+import { positiveNumberCondition } from '#utils/form';
+import { type GoApiBody } from '#utils/restRequest';
+
+function lessThanEqualToFiveImagesCondition(value: T[] | undefined) {
+ return isDefined(value) && Array.isArray(value) && value.length > 5
+ ? 'Maximum five images are allowed'
+ : undefined;
+}
+
+function maxOperationalTimeframeCondition(value: number | undefined) {
+ return typeof value === 'number' && value > 12
+ ? 'Timeframe cannot be more than 12 months'
+ : undefined;
+}
+
+type EapSimplifiedRequestBody = PurgeNull>;
+
+type EnableApproachesResponse = NonNullable[number];
+type ApproachEarlyActionResponse = NonNullable[number];
+type ApproachPrepositioningResponse = NonNullable[number];
+type ApproachReadinessResponse = NonNullable[number];
+type ApproachIndicatorResponse = NonNullable[number];
+
+type PlannedOperationsResponse = NonNullable[number];
+type EarlyActionResponse = NonNullable[number];
+type PrepositioningResponse = NonNullable[number];
+type ReadinessResponse = NonNullable[number];
+type IndicatorResponse = NonNullable[number];
+
+type CoverImageFileResponse = NonNullable;
+
+type HazardImagesResponse = NonNullable[number];
+type RiskImagesResponse = NonNullable[number];
+type EarlyActionImagesResponse = NonNullable[number];
+
+type ApproachEarlyActionFormFields = ApproachEarlyActionResponse & { client_id: string };
+type ApproachPrepositioningFormFields = ApproachPrepositioningResponse & { client_id: string };
+type ApproachReadinessFormFields = ApproachReadinessResponse & { client_id: string };
+type ApproachIndicatorFormFields = ApproachIndicatorResponse & { client_id: string };
+
+type CoverImageFileFields = CoverImageFileResponse & { client_id: string };
+
+type EarlyActionFormFields = EarlyActionResponse & { client_id: string };
+type PrepositioningFormFields = PrepositioningResponse & { client_id: string };
+type ReadinessFormFields = ReadinessResponse & { client_id: string };
+type IndicatorFormFields = IndicatorResponse & { client_id: string };
+
+type HazardImagesFormFields = HazardImagesResponse & { client_id: string };
+type RiskImagesFormFields = RiskImagesResponse & { client_id: string };
+type EarlyActionImagesFormFields = EarlyActionImagesResponse & { client_id: string };
+
+type EnableApproachesResponseFormFields = (
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ EnableApproachesResponse,
+ ApproachEarlyActionResponse,
+ ApproachEarlyActionFormFields
+ >,
+ ApproachPrepositioningResponse,
+ ApproachPrepositioningFormFields
+ >,
+ ApproachReadinessResponse,
+ ApproachReadinessFormFields
+ >,
+ ApproachIndicatorResponse,
+ ApproachIndicatorFormFields
+ >
+);
+
+type OperationsResponseFormFields = (
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ PlannedOperationsResponse,
+ EarlyActionResponse,
+ EarlyActionFormFields
+ >,
+ PrepositioningResponse,
+ PrepositioningFormFields
+ >,
+ ReadinessResponse,
+ ReadinessFormFields
+ >,
+ IndicatorResponse,
+ IndicatorFormFields
+ >
+);
+
+type FormFields = (
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ DeepReplace<
+ EapSimplifiedRequestBody,
+ PlannedOperationsResponse,
+ OperationsResponseFormFields
+ >,
+ EnableApproachesResponse,
+ EnableApproachesResponseFormFields
+ >,
+ CoverImageFileResponse,
+ CoverImageFileFields
+ >,
+ HazardImagesResponse,
+ HazardImagesFormFields
+ >,
+ RiskImagesResponse,
+ RiskImagesFormFields
+ >,
+ EarlyActionImagesResponse,
+ EarlyActionImagesFormFields
+ >
+);
+
+export type PartialSimplifiedEapType = PartialForm;
+type PlannedOperationalFields = ReturnType[number], PartialSimplifiedEapType>['fields']>;
+type EnableApproachesFields = ReturnType[number], PartialSimplifiedEapType>['fields']>;
+type CoverImageFileFormFields = ReturnType['fields']>;
+type IndicatorFields = ReturnType<
+ ObjectSchema<
+ NonNullable[number]['indicators']>[number],
+ PartialSimplifiedEapType
+ >['fields']
+>;
+type ApproachIndicatorFields = ReturnType<
+ ObjectSchema<
+ NonNullable[number]['indicators']>[number],
+ PartialSimplifiedEapType
+ >['fields']
+>;
+
+type RiskProtocolsFileFields = ReturnType[number], PartialSimplifiedEapType>['fields']>;
+type HazardImpactFileFields = ReturnType[number], PartialSimplifiedEapType>['fields']>;
+type EarlyActionFileFields = ReturnType[number], PartialSimplifiedEapType>['fields']>;
+
+type FormSchema = ObjectSchema;
+type FormSchemaFields = ReturnType;
+
+type FieldKeys = keyof EapSimplifiedRequestBody;
+
+type ContactFieldSuffix = 'name' | 'title' | 'email' | 'phone_number';
+type ExtractContactPrefix = KEY extends `${infer PREFIX}_name`
+ ? `${PREFIX}_title` extends FieldKeys
+ ? `${PREFIX}_email` extends FieldKeys
+ ? `${PREFIX}_phone_number` extends FieldKeys
+ ? PREFIX
+ : never
+ : never
+ : never
+ : never
+
+export type ValidContactFieldPrefixes = ExtractContactPrefix;
+
+function getContactSchema(key: KEY) {
+ type ContactSchema = {
+ [K in `${KEY}_${ContactFieldSuffix}`]: LiteralSchema
+ }
+
+ return {
+ [`${key}_name`]: {},
+ [`${key}_title`]: {},
+ [`${key}_email`]: { validations: [emailCondition] },
+ [`${key}_phone_number`]: {},
+ } as ContactSchema;
+}
+
+export const formSchema: FormSchema = {
+ fields: (): FormSchemaFields => ({
+ // Overview
+
+ // national_society: {},
+ // country: {},
+ // disaster_type: {},
+
+ cover_image_file: {
+ fields: (): CoverImageFileFormFields => ({
+ client_id: {},
+ caption: {},
+ id: { defaultValue: undefinedValue },
+ }),
+ },
+ seap_timeframe: { required: true },
+
+ ...getContactSchema('national_society_contact'),
+ ...getContactSchema('partner_ns'),
+ ...getContactSchema('ifrc_delegation_focal_point'),
+ ...getContactSchema('ifrc_head_of_delegation'),
+ ...getContactSchema('dref_focal_point'),
+ ...getContactSchema('ifrc_regional_focal_point'),
+ ...getContactSchema('ifrc_regional_ops_manager'),
+ ...getContactSchema('ifrc_regional_head_dcc'),
+ ...getContactSchema('ifrc_global_ops_coordinator'),
+
+ // Risk Analysis
+
+ prioritized_hazard_and_impact: {},
+ hazard_impact_images: {
+ keySelector: (item) => item.client_id,
+ member: () => ({
+ fields: (): HazardImpactFileFields => ({
+ client_id: {},
+ caption: {},
+ id: { defaultValue: undefinedValue },
+ }),
+ }),
+ validation: lessThanEqualToFiveImagesCondition,
+ },
+ risks_selected_protocols: {},
+ risk_selected_protocols_images: {
+ keySelector: (item) => item.client_id,
+ member: () => ({
+ fields: (): RiskProtocolsFileFields => ({
+ client_id: {},
+ caption: {},
+ id: { defaultValue: undefinedValue },
+ }),
+ }),
+ validation: lessThanEqualToFiveImagesCondition,
+ },
+ selected_early_actions: {},
+ selected_early_actions_images: {
+ keySelector: (item) => item.client_id,
+ member: () => ({
+ fields: (): EarlyActionFileFields => ({
+ client_id: {},
+ caption: {},
+ id: { defaultValue: undefinedValue },
+ }),
+ }),
+ validation: lessThanEqualToFiveImagesCondition,
+ },
+
+ // Early Action Interventions
+
+ overall_objective_intervention: {},
+ potential_geographical_high_risk_areas: {},
+ admin2: {
+ defaultValue: [],
+ },
+ people_targeted: { required: true },
+ assisted_through_operation: {},
+ selection_criteria: {},
+ trigger_statement: {},
+ seap_lead_time: { required: true },
+ seap_lead_timeframe_unit: { required: true },
+ operational_timeframe: {
+ required: true,
+ validations: [maxOperationalTimeframeCondition],
+ },
+ operational_timeframe_unit: {},
+ trigger_threshold_justification: {},
+ next_step_towards_full_eap: { required: true },
+
+ // Planned Operations
+
+ planned_operations: {
+ keySelector: (item) => item.sector,
+ member: () => ({
+ fields: (): PlannedOperationalFields => ({
+ id: { defaultValue: undefinedValue },
+ sector: {},
+ budget_per_sector: {},
+ ap_code: {},
+ indicators: {
+ keySelector: (indicator) => indicator.client_id,
+ member: () => ({
+ fields: (): IndicatorFields => ({
+ client_id: {},
+ id: { defaultValue: undefinedValue },
+ title: {},
+ target: { validations: [positiveNumberCondition] },
+ }),
+ }),
+ },
+ people_targeted: {},
+ early_action_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ readiness_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ prepositioning_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ }),
+ }),
+ },
+
+ // Enabling Approaches
+
+ enable_approaches: {
+ keySelector: (item) => item.approach,
+ member: () => ({
+ fields: (): EnableApproachesFields => ({
+ id: { defaultValue: undefinedValue },
+ approach: {},
+ budget_per_approach: {},
+ ap_code: {},
+ indicators: {
+ keySelector: (indicator) => indicator.client_id,
+ member: () => ({
+ fields: (): ApproachIndicatorFields => ({
+ client_id: {},
+ id: { defaultValue: undefinedValue },
+ title: {},
+ target: { validations: [positiveNumberCondition] },
+ }),
+ }),
+ },
+ early_action_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ readiness_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ prepositioning_activities: {
+ keySelector: (item) => item.client_id,
+ member: () => operationActivitySchema,
+ },
+ }),
+ }),
+ },
+
+ // Delivery & Budget
+
+ early_action_capability: {},
+ rcrc_movement_involvement: {},
+ total_budget: { required: true },
+ readiness_budget: { required: true },
+ pre_positioning_budget: { required: true },
+ early_action_budget: { required: true },
+ budget_file: { required: true },
+ }),
+};
diff --git a/app/src/views/SimplifiedEapForm/styles.module.css b/app/src/views/SimplifiedEapForm/styles.module.css
new file mode 100644
index 0000000000..c5bb9d5384
--- /dev/null
+++ b/app/src/views/SimplifiedEapForm/styles.module.css
@@ -0,0 +1,18 @@
+.simplified-eap-form {
+ .content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--go-ui-spacing-2xl);
+
+ .actions {
+ display: flex;
+
+ .page-actions {
+ display: flex;
+ flex-grow: 1;
+ justify-content: center;
+ gap: var(--go-ui-spacing-md);
+ }
+ }
+ }
+}
diff --git a/go-api b/go-api
index 42587133db..30f45895b2 160000
--- a/go-api
+++ b/go-api
@@ -1 +1 @@
-Subproject commit 42587133db4ab72094602b69adb0e6e96b970097
+Subproject commit 30f45895b2cb1b5d892187aafe3121130a649470
diff --git a/knip.jsonc b/knip.jsonc
index e1f6617abf..8b249e1a01 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -29,7 +29,7 @@
"eslint-plugin-react-refresh",
"eslint-plugin-simple-import-sort",
// We are using this with dotenv and cross-var so we are getting a false positive
- "openapi-typescript",
+ // "openapi-typescript",
// We are using this in eslint.config.ts but we are getting a false positive
"@typescript-eslint/parser",
"@typescript-eslint/eslint-plugin"
diff --git a/packages/ui/src/components/Checklist/index.tsx b/packages/ui/src/components/Checklist/index.tsx
index cf6c05d970..878cd929d8 100644
--- a/packages/ui/src/components/Checklist/index.tsx
+++ b/packages/ui/src/components/Checklist/index.tsx
@@ -1,4 +1,8 @@
-import React, { useCallback } from 'react';
+import {
+ type ComponentType,
+ type ReactNode,
+ useCallback,
+} from 'react';
import Checkbox, { Props as CheckboxProps } from '#components/Checkbox';
import InputError from '#components/InputError';
@@ -18,13 +22,13 @@ export interface Props<
disabled?: boolean;
error?: string;
errorOnTooltip?: boolean;
- hint?: React.ReactNode;
+ hint?: ReactNode;
hintContainerClassName?: string;
keySelector: (option: OPTION) => KEY;
- label?: React.ReactNode;
+ label?: ReactNode;
labelContainerClassName?: string;
labelSelector: (option: OPTION) => string;
- descriptionSelector?: (option: OPTION) => React.ReactNode;
+ descriptionSelector?: (option: OPTION) => ReactNode;
name: NAME;
onChange: (newValue: KEY[], name: NAME) => void;
options: OPTION[] | undefined;
@@ -32,10 +36,13 @@ export interface Props<
value: KEY[] | undefined | null;
checkListLayout?: 'inline' | 'block' | 'grid';
checkListLayoutPreferredGridColumns?: number;
+ checkListLayoutMinGridColumnSize?: string;
spacing?: SpacingType;
withPadding?: boolean;
withBackground?: boolean;
withDarkBackground?: boolean;
+ renderer?: ComponentType>
+ withoutOpticalSpacingCorrection?: boolean;
}
function CheckList<
@@ -63,10 +70,13 @@ function CheckList<
value,
checkListLayout = 'inline',
checkListLayoutPreferredGridColumns,
+ checkListLayoutMinGridColumnSize,
spacing,
withPadding,
withBackground,
withDarkBackground,
+ renderer = Checkbox,
+ withoutOpticalSpacingCorrection,
} = props;
const handleCheck = useCallback((isSelected: boolean, key: KEY) => {
@@ -100,7 +110,7 @@ function CheckList<
>
data={options}
keySelector={keySelector}
- renderer={Checkbox}
+ renderer={renderer}
rendererParams={optionListRendererParams}
/>
);
@@ -124,7 +134,7 @@ function CheckList<
{checkListLayout === 'inline' && (
{checkList}
@@ -133,7 +143,7 @@ function CheckList<
{checkListLayout === 'block' && (
{checkList}
@@ -143,7 +153,8 @@ function CheckList<
{checkList}
diff --git a/packages/ui/src/components/RawFileInput/styles.module.css b/packages/ui/src/components/RawFileInput/styles.module.css
index 9c9ea05a0c..f724c6bb10 100644
--- a/packages/ui/src/components/RawFileInput/styles.module.css
+++ b/packages/ui/src/components/RawFileInput/styles.module.css
@@ -2,6 +2,7 @@
display: contents;
.input {
+ position: absolute;
visibility: hidden;
width: 0;
height: 0;
diff --git a/packages/ui/src/components/TabLayout/index.tsx b/packages/ui/src/components/TabLayout/index.tsx
index 6ab2dfdb39..85374a4bc2 100644
--- a/packages/ui/src/components/TabLayout/index.tsx
+++ b/packages/ui/src/components/TabLayout/index.tsx
@@ -1,4 +1,7 @@
-import { CheckFillIcon } from '@ifrc-go/icons';
+import {
+ CheckFillIcon,
+ CloseLineIcon,
+} from '@ifrc-go/icons';
import { _cs } from '@togglecorp/fujs';
import InlineLayout, { type Props as InlineLayoutProps } from '#components/InlineLayout';
@@ -79,7 +82,6 @@ function TabLayout(props: Props) {
stepCompleted && styles.completed,
disabled && styles.disabled,
active && styles.active,
- // FIXME: implement this
errored && styles.errored,
className,
)}
@@ -87,7 +89,6 @@ function TabLayout(props: Props) {
withAdditionalInlinePadding={styleVariant === 'pill'}
>
{children}
-
);
@@ -125,9 +126,10 @@ function TabLayout(props: Props) {
- {stepCompleted && (
+ {!errored && stepCompleted && (
)}
+ {errored && }
diff --git a/packages/ui/src/components/TabLayout/styles.module.css b/packages/ui/src/components/TabLayout/styles.module.css
index 2892c12641..4ed14f25ce 100644
--- a/packages/ui/src/components/TabLayout/styles.module.css
+++ b/packages/ui/src/components/TabLayout/styles.module.css
@@ -14,6 +14,10 @@
user-select: none;
font-weight: var(--go-ui-font-weight-medium);
+ &.errored {
+ text-decoration: underline wavy var(--go-ui-color-primary-red);
+ }
+
&.color-variant-text {
--variant-color: var(--go-ui-color-text);
}
diff --git a/packages/ui/src/components/Table/Cell/index.tsx b/packages/ui/src/components/Table/Cell/index.tsx
index 4dd91da0de..a8fcfa1f2d 100644
--- a/packages/ui/src/components/Table/Cell/index.tsx
+++ b/packages/ui/src/components/Table/Cell/index.tsx
@@ -1,15 +1,22 @@
import type { ReactNode } from 'react';
-import { isNotDefined } from '@togglecorp/fujs';
+import {
+ _cs,
+ isNotDefined,
+} from '@togglecorp/fujs';
+
+import styles from './styles.module.css';
export interface CellProps{
className?: string;
value: T | null | undefined;
+ withLightText?: boolean;
}
function Cell(props: CellProps) {
const {
className,
value,
+ withLightText,
} = props;
if (isNotDefined(value)) {
@@ -17,7 +24,13 @@ function Cell(props: CellProps) {
}
return (
-
+
{value}
);
diff --git a/packages/ui/src/components/Table/Cell/styles.module.css b/packages/ui/src/components/Table/Cell/styles.module.css
new file mode 100644
index 0000000000..1caa482fc1
--- /dev/null
+++ b/packages/ui/src/components/Table/Cell/styles.module.css
@@ -0,0 +1,5 @@
+.cell {
+ &.with-light-text {
+ color: var(--go-ui-color-text-light);
+ }
+}
diff --git a/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/index.tsx b/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/index.tsx
index 134cafefb4..5c0eeb69b0 100644
--- a/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/index.tsx
+++ b/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/index.tsx
@@ -5,12 +5,14 @@ import styles from './styles.module.css';
export interface Props {
isExpanded?: boolean;
variant?: 'start' | 'end' | 'mid' | 'single';
+ disabled?: boolean;
}
function ExpansionIndicator(props: Props) {
const {
isExpanded,
variant,
+ disabled,
} = props;
if (!isExpanded) {
@@ -24,6 +26,7 @@ function ExpansionIndicator(props: Props) {
variant === 'start' && styles.start,
variant === 'end' && styles.end,
variant === 'single' && styles.single,
+ disabled && styles.disabled,
)}
>
diff --git a/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/styles.module.css b/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/styles.module.css
index d9e86aa760..eddbd84ae8 100644
--- a/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/styles.module.css
+++ b/packages/ui/src/components/Table/ColumnShortcuts/ExpansionIndicator/styles.module.css
@@ -19,10 +19,11 @@
.indicator {
flex-shrink: 0;
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-primary-red);
border-radius: 50%;
background-color: var(--go-ui-color-primary-red);
- width: 0.8rem;
- height: 0.8rem;
+ width: 0.8125rem;
+ height: 0.8125rem;
}
&.start {
@@ -43,4 +44,11 @@
background-color: transparent;
}
}
+
+ &.disabled {
+ .indicator {
+ border-color: var(--go-ui-color-separator);
+ background-color: var(--go-ui-color-white);
+ }
+ }
}
diff --git a/packages/ui/src/components/Table/ColumnShortcuts/index.ts b/packages/ui/src/components/Table/ColumnShortcuts/index.ts
index 7fb9ae2a7f..8356c613c7 100644
--- a/packages/ui/src/components/Table/ColumnShortcuts/index.ts
+++ b/packages/ui/src/components/Table/ColumnShortcuts/index.ts
@@ -144,7 +144,9 @@ export function createStringColumn
(
id: string,
title: string,
accessor: (item: D) => string | undefined | null,
- options?: Options, HeaderCellProps>,
+ options?: Options, HeaderCellProps> & {
+ withLightText?: (datum: D) => boolean;
+ },
) {
const item: Column, HeaderCellProps> & {
valueSelector: (item: D) => string | undefined | null,
@@ -166,6 +168,7 @@ export function createStringColumn(
cellRenderer: Cell,
cellRendererParams: (_: K, datum: D): CellProps => ({
value: accessor(datum) || (options?.defaultEmptyValue ?? '--'),
+ withLightText: options?.withLightText?.(datum),
}),
valueSelector: accessor,
valueComparator: (foo: D, bar: D) => compareString(accessor(foo), accessor(bar)),
@@ -334,6 +337,7 @@ export function createExpandColumn(
export function createExpansionIndicatorColumn(
isExpanded?: boolean,
+ getDisabled?: (datum: DATUM) => boolean,
) {
const item: Column = {
id: randomString(),
@@ -343,7 +347,7 @@ export function createExpansionIndicatorColumn(
sortable: false,
},
cellRenderer: ExpansionIndicator,
- cellRendererParams: (_, __, i, data) => {
+ cellRendererParams: (_, datum, i, data) => {
let variant: ExpansionIndicatorProps['variant'] = 'mid';
if (data.length === 1) {
@@ -357,10 +361,14 @@ export function createExpansionIndicatorColumn(
return {
isExpanded,
variant,
+ disabled: getDisabled?.(datum),
};
},
+ // cellRendererClassName: styles.expansionIndicatorCell,
+ cellContainerRendererParams: () => ({
+ withoutBorder: true,
+ }),
cellContainerClassName: styles.expansionIndicatorCellContainer,
- cellRendererClassName: styles.expansionIndicatorCell,
};
return item;
diff --git a/packages/ui/src/components/Table/TableBodyContent/index.tsx b/packages/ui/src/components/Table/TableBodyContent/index.tsx
index af0f41d444..838216e217 100644
--- a/packages/ui/src/components/Table/TableBodyContent/index.tsx
+++ b/packages/ui/src/components/Table/TableBodyContent/index.tsx
@@ -9,8 +9,6 @@ import type {
VerifyColumn,
} from '../types';
-import styles from './styles.module.css';
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Props> {
data: DATUM[] | undefined | null;
@@ -19,6 +17,7 @@ interface Props (string | undefined));
cellClassName?: string | ((key: KEY, datum: DATUM, columnKey: string) => (string | undefined));
rowModifier?: (rowOptions: RowOptions) => React.ReactNode;
+ expandedContent?: boolean;
}
function TableBodyContent<
@@ -34,6 +33,7 @@ function TableBodyContent<
rowClassName,
cellClassName,
rowModifier,
+ expandedContent,
} = props;
return (
@@ -48,6 +48,7 @@ function TableBodyContent<
cellRendererClassName,
cellRendererParams,
cellContainerClassName,
+ cellContainerRendererParams,
} = column;
const otherProps = cellRendererParams(key, datum, index, data);
@@ -59,16 +60,27 @@ function TableBodyContent<
name={id}
/>
);
+
+ const cellContainerAdditionalProps = cellContainerRendererParams?.(
+ key,
+ datum,
+ index,
+ data,
+ ) ?? {};
+
return (
{children}
@@ -78,7 +90,6 @@ function TableBodyContent<
const row = (
, 'ref'>;
+export interface TableDataAdditionalProps {
+ expandedContentCell?: boolean;
+ withoutBorder?: boolean;
+}
+
+export type Props = Omit, 'ref'> & TableDataAdditionalProps;
function TableData(props: Props) {
const {
className,
children,
+ expandedContentCell,
+ withoutBorder,
...otherProps
} = props;
return (
diff --git a/packages/ui/src/components/Table/TableData/styles.module.css b/packages/ui/src/components/Table/TableData/styles.module.css
index 7e8d347ff4..fcd5d81388 100644
--- a/packages/ui/src/components/Table/TableData/styles.module.css
+++ b/packages/ui/src/components/Table/TableData/styles.module.css
@@ -1,3 +1,15 @@
-.td {
- border-bottom: var(--go-ui-width-separator-sm) solid var(--go-ui-color-separator);
+.table-data {
+ --cell-border-color: var(--go-ui-color-separator);
+ --cell-background-color: transparent;
+
+ border-bottom: var(--go-ui-width-separator-sm) solid var(--cell-border-color);
+ background-color: var(--cell-background-color);
+ padding: var(--go-ui-spacing-sm);
+ overflow: hidden;
+ overflow-wrap: break-word;
+
+ &.expanded-content-cell {
+ --cell-border-color: var(--go-ui-color-separator-light);
+ --cell-background-color: var(--go-ui-color-background);
+ }
}
diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts
index b33da8217f..213653c306 100644
--- a/packages/ui/src/components/Table/types.ts
+++ b/packages/ui/src/components/Table/types.ts
@@ -1,3 +1,5 @@
+import { type TableDataAdditionalProps } from './TableData';
+
export type SortDirection = 'asc' | 'dsc';
export interface BaseHeader {
@@ -34,6 +36,12 @@ export interface Column {
cellRendererClassName?: string;
cellContainerClassName?: string;
+ cellContainerRendererParams?: (
+ key: KEY,
+ datum: DATA,
+ index: number,
+ data: DATA[],
+ ) => TableDataAdditionalProps;
}
export type VerifyColumn = unknown extends (
diff --git a/packages/ui/src/components/printable/Heading/index.tsx b/packages/ui/src/components/printable/Heading/index.tsx
index b6aadf09a3..61705605ec 100644
--- a/packages/ui/src/components/printable/Heading/index.tsx
+++ b/packages/ui/src/components/printable/Heading/index.tsx
@@ -1,5 +1,7 @@
import { _cs } from '@togglecorp/fujs';
+import { getSpacingValue } from '#utils/style';
+
import styles from './styles.module.css';
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
@@ -27,9 +29,13 @@ function Heading(props: Props) {
} = props;
const HeadingEl = `h${level}` as React.ElementType;
+ const spacing = getSpacingValue('2xl', -level);
return (
-
+
{children}
);
diff --git a/packages/ui/src/components/printable/Heading/styles.module.css b/packages/ui/src/components/printable/Heading/styles.module.css
index 3d7a84138a..da9c15781d 100644
--- a/packages/ui/src/components/printable/Heading/styles.module.css
+++ b/packages/ui/src/components/printable/Heading/styles.module.css
@@ -1,11 +1,11 @@
.heading {
--font-size: var(--go-ui-font-size-xl);
- margin: 0 0 var(--go-ui-spacing-sm) 0;
font-family: Montserrat, sans-serif;
font-size: var(--font-size);
font-weight: var(--go-ui-font-weight-bold);
page-break-after: avoid;
+ margin-block-start: unset;
&.level-one {
--font-size: var(--go-ui-font-size-4xl);
diff --git a/packages/ui/src/components/printable/TextOutput/index.tsx b/packages/ui/src/components/printable/TextOutput/index.tsx
index a25e0eb270..1f4cc0acf1 100644
--- a/packages/ui/src/components/printable/TextOutput/index.tsx
+++ b/packages/ui/src/components/printable/TextOutput/index.tsx
@@ -19,7 +19,9 @@ interface BaseProps {
strongLabel?: boolean;
withoutLabelColon?: boolean;
invalidText?: React.ReactNode;
- variant?: 'default' | 'contents';
+ variant?: 'block' | 'default' | 'contents';
+ withPadding?: boolean;
+ withBackground?: boolean;
}
interface BooleanProps extends BooleanOutputProps {
@@ -59,6 +61,8 @@ function TextOutput(props: Props) {
withoutLabelColon,
invalidText = DEFAULT_INVALID_TEXT,
variant = 'default',
+ withPadding,
+ withBackground,
...otherProps
} = props;
@@ -100,6 +104,9 @@ function TextOutput(props: Props) {
styles.textOutput,
variant === 'default' && styles.defaultVariant,
variant === 'contents' && styles.contentsVariant,
+ variant === 'block' && styles.blockVariant,
+ withPadding && styles.withPadding,
+ withBackground && styles.withBackground,
className,
)}
>
diff --git a/packages/ui/src/components/printable/TextOutput/styles.module.css b/packages/ui/src/components/printable/TextOutput/styles.module.css
index aa09f28635..0367a14b47 100644
--- a/packages/ui/src/components/printable/TextOutput/styles.module.css
+++ b/packages/ui/src/components/printable/TextOutput/styles.module.css
@@ -1,4 +1,19 @@
.text-output {
+ break-inside: avoid;
+
+ &.with-padding {
+ padding: var(--go-ui-spacing-sm);
+ }
+
+ &.with-background {
+ background-color: var(--go-ui-color-background);
+ }
+
+ &.block-variant {
+ display: flex;
+ flex-direction: column;
+ }
+
&.default-variant {
display: flex;
flex-wrap: wrap;
diff --git a/packages/ui/src/hooks/index.tsx b/packages/ui/src/hooks/index.tsx
index 835ae02cba..72f5842c96 100644
--- a/packages/ui/src/hooks/index.tsx
+++ b/packages/ui/src/hooks/index.tsx
@@ -5,4 +5,5 @@ export { default as useDebouncedValue } from './useDebouncedValue';
export { default as useFloatPlacement } from './useFloatPlacement';
export { default as useKeyboard } from './useKeyboard';
export { default as useSizeTracking } from './useSizeTracking';
+export { default as useSpacingToken } from './useSpacingToken';
export { default as useTranslation } from './useTranslation';
diff --git a/packages/ui/src/utils/style.ts b/packages/ui/src/utils/style.ts
index b9d84688ac..b74d2df534 100644
--- a/packages/ui/src/utils/style.ts
+++ b/packages/ui/src/utils/style.ts
@@ -64,13 +64,7 @@ export function getSpacingValue(
spacingTokens.length - 1,
);
- const spacingValue = spacingTokens[
- bound(
- startIndex,
- 0,
- spacingTokens.length - 1,
- )
- ];
+ const spacingValue = spacingTokens[startIndex];
return spacingValue;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17adc76b6d..07b01e0ce7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -64,6 +64,9 @@ importers:
'@turf/buffer':
specifier: ^6.5.0
version: 6.5.0(patch_hash=f6b966f3d2d5a66fd961363e20ecdfad1b807ae7ba8d56eb6bd57592b3fa13af)
+ diff:
+ specifier: ^8.0.2
+ version: 8.0.2
exceljs:
specifier: ^4.3.0
version: 4.4.0
@@ -3341,6 +3344,10 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ diff@8.0.2:
+ resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
+ engines: {node: '>=0.3.1'}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -9890,6 +9897,8 @@ snapshots:
diff-sequences@29.6.3: {}
+ diff@8.0.2: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -10183,7 +10192,7 @@ snapshots:
dependencies:
confusing-browser-globals: 1.0.11
eslint: 9.20.1(jiti@2.5.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
object.assign: 4.1.7
object.entries: 1.1.8
semver: 6.3.1
@@ -10192,7 +10201,7 @@ snapshots:
dependencies:
eslint: 9.20.1(jiti@2.5.1)
eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.5.1))
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.20.1(jiti@2.5.1))
eslint-plugin-react: 7.37.4(eslint@9.20.1(jiti@2.5.1))
eslint-plugin-react-hooks: 5.1.0(eslint@9.20.1(jiti@2.5.1))
@@ -10218,7 +10227,7 @@ snapshots:
stable-hash: 0.0.4
tinyglobby: 0.2.12
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@9.20.1(jiti@2.5.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.2)(eslint@9.20.1(jiti@2.5.1))
transitivePeerDependencies:
- supports-color