From ac8a0f93d040f399cae548bdb1053d5dba85a76a Mon Sep 17 00:00:00 2001 From: barshathakuri Date: Mon, 13 Oct 2025 11:42:11 +0545 Subject: [PATCH 1/2] feat(eap): Add Early Action Protocal form and views - Add EAP Application tab in account page - Add EAP Registration Form - Add simplified EAP form - Add export for simplified EAP - Add full EAP form - Add full EAP export - Add full EAP summary export - Add Admin2 input component - Add obsolete resolution modal for both EAP forms - Add save and submit functionality in both EAP forms - Add share functionality in both EAP forms and table - Add revise button for creating new version - Add utility function for for DateInput value for month and date type - Add additional attachments modal in EAP table - Add ExplanatoryNote component for modal Tootltip - Add IconButton in InfoModal - Update in Printable for export - replace diff library to diff-match-patch - new DiffTextOutput components - update in PrintableDescription and PrintableLabel - Make minor enhancements in various UI components - Add form variant in Heading - Add option to show border in container - Restructure radio icon size - Improve info popup icon size - Add TextBadge component for text limit counter - Update styling of TabLayout - Update 'step' variant TabLayout to match the designs - Add 'form' variant to Container - Fix styling of info popup - Update styling of InputLabel --- app/package.json | 2 + app/src/App/routes/index.tsx | 186 ++ app/src/components/ExplanatoryNote/index.tsx | 34 + .../ExplanatoryNote/styles.module.css | 4 + app/src/components/GoMapContainer/index.tsx | 8 +- .../GoMapContainer/styles.module.css | 17 + app/src/components/Navbar/i18n.json | 1 + app/src/components/Navbar/index.tsx | 8 + app/src/components/NonFieldError/index.tsx | 9 +- .../NonFieldError/styles.module.css | 1 - app/src/components/PerExportModal/index.tsx | 13 +- app/src/components/TabPage/index.tsx | 27 +- app/src/components/TabPage/styles.module.css | 10 +- .../components/domain/Admin2Input/i18n.json | 8 + .../components/domain/Admin2Input/index.tsx | 441 ++++ app/src/components/domain/BaseMap/index.tsx | 8 +- .../domain/ContactInputsSection/i18n.json | 9 + .../domain/ContactInputsSection/index.tsx | 139 ++ .../domain/DrefExportModal/index.tsx | 9 +- .../domain/DrefShareModal/index.tsx | 4 +- .../domain/EapExportModal/i18n.json | 13 + .../domain/EapExportModal/index.tsx | 200 ++ .../domain/EapExportModal/styles.module.css | 5 + .../domain/EapIndicatorInput/i18n.json | 8 + .../domain/EapIndicatorInput/index.tsx | 103 + .../domain/EapIndicatorInput/schema.ts | 35 + .../domain/EapIndicatorListInput/i18n.json | 8 + .../domain/EapIndicatorListInput/index.tsx | 127 ++ .../EapObsoleteResolutionModal/i18n.json | 11 + .../EapObsoleteResolutionModal/index.tsx | 95 + .../TimeSpanCheck/index.tsx | 90 + .../TimeSpanCheck/styles.module.css | 29 + .../EapOperationActivityInput/i18n.json | 8 + .../EapOperationActivityInput/index.tsx | 193 ++ .../EapOperationActivityInput/schema.ts | 32 + .../EapOperationActivityListInput/i18n.json | 14 + .../EapOperationActivityListInput/index.tsx | 185 ++ .../components/domain/EapShareModal/i18n.json | 13 + .../components/domain/EapShareModal/index.tsx | 173 ++ .../domain/GoMultiFileInput/i18n.json | 3 +- .../domain/GoMultiFileInput/index.tsx | 19 +- .../domain/GoSingleFileInput/index.tsx | 60 +- .../domain/ImageWithCaptionInput/i18n.json | 7 +- .../domain/ImageWithCaptionInput/index.tsx | 35 +- app/src/components/domain/InfoModal/index.tsx | 87 + .../MultiImageWithCaptionInput/i18n.json | 9 +- .../MultiImageWithCaptionInput/index.tsx | 35 +- .../domain/PrintableActivityOutput/index.tsx | 77 + .../UserItem => ShareUserItem}/i18n.json | 4 +- .../UserItem => ShareUserItem}/index.tsx | 6 +- .../domain/SourceInformationInput/index.tsx | 49 +- .../printable/DiffTextOutput/index.tsx | 91 + .../DiffTextOutput/styles.module.css | 18 + .../printable/PrintableContainer/index.tsx | 49 + .../PrintableContainer/styles.module.css | 17 + .../printable/PrintableDataDisplay/index.tsx | 282 +++ .../PrintableDataDisplay/styles.module.css | 59 + .../printable/PrintableDescription/index.tsx | 25 + .../printable/PrintableLabel/index.tsx | 25 + .../printable/PrintablePage/index.tsx | 100 + .../printable/PrintablePage/styles.module.css | 38 + app/src/utils/common.ts | 35 +- app/src/utils/constants.ts | 26 +- app/src/utils/domain/eap.ts | 15 + .../EapShareActions/i18n.json | 6 + .../EapShareActions/index.tsx | 62 + .../AccountMyFormsEap/EapStatus/i18n.json | 16 + .../AccountMyFormsEap/EapStatus/index.tsx | 290 +++ .../EapTableActions/BudgetFileInput/i18n.json | 12 + .../EapTableActions/BudgetFileInput/index.tsx | 139 ++ .../EapTableActions/i18n.json | 29 + .../EapTableActions/index.tsx | 523 +++++ .../views/AccountMyFormsEap/Filters/i18n.json | 6 + .../views/AccountMyFormsEap/Filters/index.tsx | 47 + app/src/views/AccountMyFormsEap/i18n.json | 11 + app/src/views/AccountMyFormsEap/index.tsx | 374 ++++ .../views/AccountMyFormsEap/styles.module.css | 3 + app/src/views/AccountMyFormsEap/utils.ts | 34 + app/src/views/AccountMyFormsLayout/i18n.json | 7 +- app/src/views/AccountMyFormsLayout/index.tsx | 5 + .../DrefApplicationForm/Overview/index.tsx | 4 +- app/src/views/DrefApplicationForm/index.tsx | 1 + app/src/views/DrefDetail/i18n.json | 15 + app/src/views/DrefDetail/index.tsx | 58 + .../DrefFinalReportForm/Overview/index.tsx | 4 +- .../Overview/index.tsx | 4 +- app/src/views/DrefProcess/i18n.json | 9 + app/src/views/DrefProcess/index.tsx | 37 + .../PrintableContactOutput/index.tsx | 75 + .../EapFullExport/TableOfContents/index.tsx | 82 + .../TableOfContents/styles.module.css | 41 + app/src/views/EapFullExport/i18n.json | 98 + app/src/views/EapFullExport/index.tsx | 1953 +++++++++++++++++ app/src/views/EapFullExport/styles.module.css | 34 + .../EAPSourceInformationInput/i18n.json | 8 + .../EAPSourceInformationInput/index.tsx | 120 + .../EapActivationProcess/i18n.json | 56 + .../EapActivationProcess/index.tsx | 483 ++++ .../EapFullForm/FinanceLogistics/i18n.json | 52 + .../EapFullForm/FinanceLogistics/index.tsx | 497 +++++ app/src/views/EapFullForm/Meal/i18n.json | 30 + app/src/views/EapFullForm/Meal/index.tsx | 240 ++ .../NationalSocietyCapacity/i18n.json | 30 + .../NationalSocietyCapacity/index.tsx | 271 +++ .../Overview/KeyActorsInput/i18n.json | 8 + .../Overview/KeyActorsInput/index.tsx | 113 + .../Overview/PartnerContactsInput/i18n.json | 10 + .../Overview/PartnerContactsInput/index.tsx | 126 ++ app/src/views/EapFullForm/Overview/i18n.json | 60 + app/src/views/EapFullForm/Overview/index.tsx | 605 +++++ .../PrioritisedImpactInput/index.tsx | 87 + .../views/EapFullForm/RiskAnalysis/i18n.json | 70 + .../views/EapFullForm/RiskAnalysis/index.tsx | 505 +++++ .../SectionQualityCriteria/i18n.json | 6 + .../SectionQualityCriteria/index.tsx | 32 + .../ApproachesInput/i18n.json | 8 + .../ApproachesInput/index.tsx | 144 ++ .../EarlyActionsInput/index.tsx | 87 + .../SelectionActions/OperationInput/i18n.json | 9 + .../SelectionActions/OperationInput/index.tsx | 160 ++ .../EapFullForm/SelectionActions/i18n.json | 67 + .../EapFullForm/SelectionActions/index.tsx | 722 ++++++ .../views/EapFullForm/TriggerModel/i18n.json | 78 + .../views/EapFullForm/TriggerModel/index.tsx | 651 ++++++ app/src/views/EapFullForm/common.tsx | 206 ++ app/src/views/EapFullForm/i18n.json | 37 + app/src/views/EapFullForm/index.tsx | 1193 ++++++++++ app/src/views/EapFullForm/schema.ts | 1298 +++++++++++ app/src/views/EapRegistration/i18n.json | 36 + app/src/views/EapRegistration/index.tsx | 382 ++++ app/src/views/EapRegistration/schema.ts | 57 + .../PrintableContactOutput/index.tsx | 75 + app/src/views/EapSimplifiedExport/i18n.json | 56 + app/src/views/EapSimplifiedExport/index.tsx | 956 ++++++++ .../EapSimplifiedExport/styles.module.css | 6 + .../DeliveryAndBudget/i18n.json | 60 + .../DeliveryAndBudget/index.tsx | 458 ++++ .../EapSimplifiedForm/EarlyAction/i18n.json | 67 + .../EapSimplifiedForm/EarlyAction/index.tsx | 534 +++++ .../ApproachesInput/i18n.json | 8 + .../ApproachesInput/index.tsx | 143 ++ .../EnablingApproaches/i18n.json | 21 + .../EnablingApproaches/index.tsx | 223 ++ .../EapSimplifiedForm/GuidanceSeap/i18n.json | 6 + .../EapSimplifiedForm/GuidanceSeap/index.tsx | 32 + .../EapSimplifiedForm/Overview/i18n.json | 41 + .../EapSimplifiedForm/Overview/index.tsx | 382 ++++ .../PartnerContactInput/i18n.json | 10 + .../PartnerContactInput/index.tsx | 121 + .../OperationsInput/i18n.json | 9 + .../OperationsInput/index.tsx | 155 ++ .../PlannedOperations/i18n.json | 21 + .../PlannedOperations/index.tsx | 223 ++ .../EapSimplifiedForm/RiskAnalysis/i18n.json | 45 + .../EapSimplifiedForm/RiskAnalysis/index.tsx | 351 +++ app/src/views/EapSimplifiedForm/common.tsx | 197 ++ app/src/views/EapSimplifiedForm/i18n.json | 35 + app/src/views/EapSimplifiedForm/index.tsx | 952 ++++++++ app/src/views/EapSimplifiedForm/schema.ts | 604 +++++ app/src/views/EapSummaryExport/i18n.json | 37 + app/src/views/EapSummaryExport/index.tsx | 563 +++++ .../views/EapSummaryExport/styles.module.css | 27 + app/src/views/EarlyActionProtocols/i18n.json | 14 + app/src/views/EarlyActionProtocols/index.tsx | 71 + .../OldDrefFinalReportForm/Overview/index.tsx | 4 +- .../ComponentInput/index.tsx | 3 +- go-api | 2 +- .../ui/src/components/InputSection/index.tsx | 9 +- .../src/components/Message/styles.module.css | 1 - .../ui/src/components/TabLayout/index.tsx | 1 - .../Table/TableBodyContent/styles.module.css | 15 - .../src/components/parked/TopBanner/index.tsx | 33 - .../parked/TopBanner/styles.module.css | 19 - pnpm-lock.yaml | 16 + 174 files changed, 22362 insertions(+), 218 deletions(-) create mode 100644 app/src/components/ExplanatoryNote/index.tsx create mode 100644 app/src/components/ExplanatoryNote/styles.module.css create mode 100644 app/src/components/domain/Admin2Input/i18n.json create mode 100644 app/src/components/domain/Admin2Input/index.tsx create mode 100644 app/src/components/domain/ContactInputsSection/i18n.json create mode 100644 app/src/components/domain/ContactInputsSection/index.tsx create mode 100644 app/src/components/domain/EapExportModal/i18n.json create mode 100644 app/src/components/domain/EapExportModal/index.tsx create mode 100644 app/src/components/domain/EapExportModal/styles.module.css create mode 100644 app/src/components/domain/EapIndicatorInput/i18n.json create mode 100644 app/src/components/domain/EapIndicatorInput/index.tsx create mode 100644 app/src/components/domain/EapIndicatorInput/schema.ts create mode 100644 app/src/components/domain/EapIndicatorListInput/i18n.json create mode 100644 app/src/components/domain/EapIndicatorListInput/index.tsx create mode 100644 app/src/components/domain/EapObsoleteResolutionModal/i18n.json create mode 100644 app/src/components/domain/EapObsoleteResolutionModal/index.tsx create mode 100644 app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/index.tsx create mode 100644 app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/styles.module.css create mode 100644 app/src/components/domain/EapOperationActivityInput/i18n.json create mode 100644 app/src/components/domain/EapOperationActivityInput/index.tsx create mode 100644 app/src/components/domain/EapOperationActivityInput/schema.ts create mode 100644 app/src/components/domain/EapOperationActivityListInput/i18n.json create mode 100644 app/src/components/domain/EapOperationActivityListInput/index.tsx create mode 100644 app/src/components/domain/EapShareModal/i18n.json create mode 100644 app/src/components/domain/EapShareModal/index.tsx create mode 100644 app/src/components/domain/InfoModal/index.tsx create mode 100644 app/src/components/domain/PrintableActivityOutput/index.tsx rename app/src/components/domain/{DrefShareModal/UserItem => ShareUserItem}/i18n.json (62%) rename app/src/components/domain/{DrefShareModal/UserItem => ShareUserItem}/index.tsx (91%) create mode 100644 app/src/components/printable/DiffTextOutput/index.tsx create mode 100644 app/src/components/printable/DiffTextOutput/styles.module.css create mode 100644 app/src/components/printable/PrintableContainer/index.tsx create mode 100644 app/src/components/printable/PrintableContainer/styles.module.css create mode 100644 app/src/components/printable/PrintableDataDisplay/index.tsx create mode 100644 app/src/components/printable/PrintableDataDisplay/styles.module.css create mode 100644 app/src/components/printable/PrintableDescription/index.tsx create mode 100644 app/src/components/printable/PrintableLabel/index.tsx create mode 100644 app/src/components/printable/PrintablePage/index.tsx create mode 100644 app/src/components/printable/PrintablePage/styles.module.css create mode 100644 app/src/utils/domain/eap.ts create mode 100644 app/src/views/AccountMyFormsEap/EapShareActions/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/EapShareActions/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/EapStatus/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/EapStatus/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/EapTableActions/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/EapTableActions/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/Filters/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/Filters/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/i18n.json create mode 100644 app/src/views/AccountMyFormsEap/index.tsx create mode 100644 app/src/views/AccountMyFormsEap/styles.module.css create mode 100644 app/src/views/AccountMyFormsEap/utils.ts create mode 100644 app/src/views/DrefDetail/i18n.json create mode 100644 app/src/views/DrefDetail/index.tsx create mode 100644 app/src/views/DrefProcess/i18n.json create mode 100644 app/src/views/DrefProcess/index.tsx create mode 100644 app/src/views/EapFullExport/PrintableContactOutput/index.tsx create mode 100644 app/src/views/EapFullExport/TableOfContents/index.tsx create mode 100644 app/src/views/EapFullExport/TableOfContents/styles.module.css create mode 100644 app/src/views/EapFullExport/i18n.json create mode 100644 app/src/views/EapFullExport/index.tsx create mode 100644 app/src/views/EapFullExport/styles.module.css create mode 100644 app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json create mode 100644 app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx create mode 100644 app/src/views/EapFullForm/EapActivationProcess/i18n.json create mode 100644 app/src/views/EapFullForm/EapActivationProcess/index.tsx create mode 100644 app/src/views/EapFullForm/FinanceLogistics/i18n.json create mode 100644 app/src/views/EapFullForm/FinanceLogistics/index.tsx create mode 100644 app/src/views/EapFullForm/Meal/i18n.json create mode 100644 app/src/views/EapFullForm/Meal/index.tsx create mode 100644 app/src/views/EapFullForm/NationalSocietyCapacity/i18n.json create mode 100644 app/src/views/EapFullForm/NationalSocietyCapacity/index.tsx create mode 100644 app/src/views/EapFullForm/Overview/KeyActorsInput/i18n.json create mode 100644 app/src/views/EapFullForm/Overview/KeyActorsInput/index.tsx create mode 100644 app/src/views/EapFullForm/Overview/PartnerContactsInput/i18n.json create mode 100644 app/src/views/EapFullForm/Overview/PartnerContactsInput/index.tsx create mode 100644 app/src/views/EapFullForm/Overview/i18n.json create mode 100644 app/src/views/EapFullForm/Overview/index.tsx create mode 100644 app/src/views/EapFullForm/RiskAnalysis/PrioritisedImpactInput/index.tsx create mode 100644 app/src/views/EapFullForm/RiskAnalysis/i18n.json create mode 100644 app/src/views/EapFullForm/RiskAnalysis/index.tsx create mode 100644 app/src/views/EapFullForm/SectionQualityCriteria/i18n.json create mode 100644 app/src/views/EapFullForm/SectionQualityCriteria/index.tsx create mode 100644 app/src/views/EapFullForm/SelectionActions/ApproachesInput/i18n.json create mode 100644 app/src/views/EapFullForm/SelectionActions/ApproachesInput/index.tsx create mode 100644 app/src/views/EapFullForm/SelectionActions/EarlyActionsInput/index.tsx create mode 100644 app/src/views/EapFullForm/SelectionActions/OperationInput/i18n.json create mode 100644 app/src/views/EapFullForm/SelectionActions/OperationInput/index.tsx create mode 100644 app/src/views/EapFullForm/SelectionActions/i18n.json create mode 100644 app/src/views/EapFullForm/SelectionActions/index.tsx create mode 100644 app/src/views/EapFullForm/TriggerModel/i18n.json create mode 100644 app/src/views/EapFullForm/TriggerModel/index.tsx create mode 100644 app/src/views/EapFullForm/common.tsx create mode 100644 app/src/views/EapFullForm/i18n.json create mode 100644 app/src/views/EapFullForm/index.tsx create mode 100644 app/src/views/EapFullForm/schema.ts create mode 100644 app/src/views/EapRegistration/i18n.json create mode 100644 app/src/views/EapRegistration/index.tsx create mode 100644 app/src/views/EapRegistration/schema.ts create mode 100644 app/src/views/EapSimplifiedExport/PrintableContactOutput/index.tsx create mode 100644 app/src/views/EapSimplifiedExport/i18n.json create mode 100644 app/src/views/EapSimplifiedExport/index.tsx create mode 100644 app/src/views/EapSimplifiedExport/styles.module.css create mode 100644 app/src/views/EapSimplifiedForm/DeliveryAndBudget/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/DeliveryAndBudget/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/EarlyAction/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/EarlyAction/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/EnablingApproaches/ApproachesInput/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/EnablingApproaches/ApproachesInput/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/EnablingApproaches/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/EnablingApproaches/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/GuidanceSeap/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/GuidanceSeap/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/Overview/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/Overview/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/PartnerContactInput/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/PartnerContactInput/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/PlannedOperations/OperationsInput/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/PlannedOperations/OperationsInput/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/PlannedOperations/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/PlannedOperations/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/RiskAnalysis/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/RiskAnalysis/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/common.tsx create mode 100644 app/src/views/EapSimplifiedForm/i18n.json create mode 100644 app/src/views/EapSimplifiedForm/index.tsx create mode 100644 app/src/views/EapSimplifiedForm/schema.ts create mode 100644 app/src/views/EapSummaryExport/i18n.json create mode 100644 app/src/views/EapSummaryExport/index.tsx create mode 100644 app/src/views/EapSummaryExport/styles.module.css create mode 100644 app/src/views/EarlyActionProtocols/i18n.json create mode 100644 app/src/views/EarlyActionProtocols/index.tsx delete mode 100644 packages/ui/src/components/Table/TableBodyContent/styles.module.css delete mode 100644 packages/ui/src/components/parked/TopBanner/index.tsx delete mode 100644 packages/ui/src/components/parked/TopBanner/styles.module.css diff --git a/app/package.json b/app/package.json index 79d60df21b..be30c223b5 100644 --- a/app/package.json +++ b/app/package.json @@ -51,6 +51,8 @@ "@togglecorp/toggle-request": "^1.0.0-beta.3", "@turf/bbox": "^6.5.0", "@turf/buffer": "^6.5.0", + "@types/diff-match-patch": "^1.0.36", + "diff-match-patch": "^1.0.5", "exceljs": "^4.4.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..d3cceb192e 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/EapSimplifiedForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Simplified EAP Form', + visibility: 'is-authenticated', + }, +}); + const accountNotifications = customWrapRoute({ parent: accountLayout, path: 'notifications', @@ -1094,6 +1181,93 @@ 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 eapFullExport = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/export/full', + component: { + render: () => import('#views/EapFullExport'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'EAP Export', + visibility: 'is-authenticated', + }, +}); + +const eapSimplifiedExport = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/export/simplified', + component: { + render: () => import('#views/EapSimplifiedExport'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'EAP Export', + visibility: 'is-authenticated', + }, +}); + +const eapSummaryExport = customWrapRoute({ + parent: rootLayout, + path: 'eap/:eapId/export/summary', + component: { + render: () => import('#views/EapSummaryExport'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'EAP Full Summary Export', + visibility: 'is-authenticated', + }, +}); + type DefaultPerProcessChild = 'new'; const perProcessLayout = customWrapRoute({ parent: rootLayout, @@ -1317,6 +1491,7 @@ const wrappedRoutes = { accountMyFormsPer, accountMyFormsDref, accountMyFormsThreeW, + accountMyFormsEap, resources, search, allThreeWProject, @@ -1353,6 +1528,9 @@ const wrappedRoutes = { termsAndConditions, operationalLearning, montandonLandingPage, + newEapDevelopmentRegistration, + fullEapForm, + simplifiedEapForm, ...regionRoutes, ...countryRoutes, ...surgeRoutes, @@ -1363,6 +1541,14 @@ const wrappedRoutes = { // Redirects preparednessOperationalLearning, obsoleteFieldReportDetails, + drefDetail, + eapDetail, + drefProcessLayout, + eapRegistrationLayout, + eapDevelopmentRegistrationForm, + eapFullExport, + eapSimplifiedExport, + eapSummaryExport, }; export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes)); diff --git a/app/src/components/ExplanatoryNote/index.tsx b/app/src/components/ExplanatoryNote/index.tsx new file mode 100644 index 0000000000..8fdcfa2bcf --- /dev/null +++ b/app/src/components/ExplanatoryNote/index.tsx @@ -0,0 +1,34 @@ +import { InformationLineIcon } from '@ifrc-go/icons'; + +import InfoModal from '#components/domain/InfoModal'; + +import styles from './styles.module.css'; + +interface Props { + content: React.ReactNode; + heading: string; + ariaLabel: string; + title: string; +} + +function ExplanatoryNote(props: Props) { + const { + content, + heading, + ariaLabel, + title, + } = props; + + return ( + } + ariaLabel={ariaLabel} + title={title} + /> + ); +} + +export default ExplanatoryNote; diff --git a/app/src/components/ExplanatoryNote/styles.module.css b/app/src/components/ExplanatoryNote/styles.module.css new file mode 100644 index 0000000000..a898de748a --- /dev/null +++ b/app/src/components/ExplanatoryNote/styles.module.css @@ -0,0 +1,4 @@ +.icon { + /* FIXME: use variables */ + font-size: 1rem; +} diff --git a/app/src/components/GoMapContainer/index.tsx b/app/src/components/GoMapContainer/index.tsx index b0449c859b..7ba28d6d48 100644 --- a/app/src/components/GoMapContainer/index.tsx +++ b/app/src/components/GoMapContainer/index.tsx @@ -50,6 +50,7 @@ interface Props { presentationModeAdditionalAfterContent?: React.ReactNode; onPresentationModeChange?: (newPresentationMode: boolean) => void; children?: React.ReactNode; + withFullHeight?: boolean; } function GoMapContainer(props: Props) { @@ -63,6 +64,7 @@ function GoMapContainer(props: Props) { presentationModeAdditionalAfterContent, onPresentationModeChange, children, + withFullHeight, } = props; const strings = useTranslation(i18n); @@ -161,6 +163,7 @@ function GoMapContainer(props: Props) { styles.goMapContainer, printMode && styles.printMode, presentationMode && styles.presentationMode, + withFullHeight && styles.withFullHeight, className, )} headingLevel={2} @@ -236,12 +239,15 @@ function GoMapContainer(props: Props) { withPadding={presentationMode} > {presentationMode && presentationModeAdditionalBeforeContent}
- + {strings.myDrefApplications} + + {strings.earlyActionProtocols} + (props: Props) { } return ( -
diff --git a/app/src/components/NonFieldError/styles.module.css b/app/src/components/NonFieldError/styles.module.css index 0c3a5adf12..db25d7b0d0 100644 --- a/app/src/components/NonFieldError/styles.module.css +++ b/app/src/components/NonFieldError/styles.module.css @@ -5,7 +5,6 @@ animation-delay: var(--go-ui-duration-animation-slow); gap: var(--go-ui-spacing-sm); color: var(--go-ui-color-red); - font-weight: var(--go-ui-font-weight-medium); .icon { flex-shrink: 0; diff --git a/app/src/components/PerExportModal/index.tsx b/app/src/components/PerExportModal/index.tsx index 732fbdb2bb..f7a855357e 100644 --- a/app/src/components/PerExportModal/index.tsx +++ b/app/src/components/PerExportModal/index.tsx @@ -14,11 +14,15 @@ import { import Link from '#components/Link'; import { type components } from '#generated/types'; -import { useRequest } from '#utils/restRequest'; +import { + type GoApiBody, + useRequest, +} from '#utils/restRequest'; import i18n from './i18n.json'; 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; @@ -45,8 +49,11 @@ function PerExportModal(props: Props) { export_id: Number(perId), export_type: 'per' as const, per_country: Number(countryId), - is_pga: false, - }), + is_pga: undefined, + summary: undefined, + version: undefined, + diff: undefined, + } satisfies ExportBody), [perId, countryId], ); diff --git a/app/src/components/TabPage/index.tsx b/app/src/components/TabPage/index.tsx index d7a2782df2..c0a4d8de99 100644 --- a/app/src/components/TabPage/index.tsx +++ b/app/src/components/TabPage/index.tsx @@ -1,5 +1,6 @@ import { DefaultMessage, + InlineLayout, ListView, } from '@ifrc-go/ui'; import { isDefined } from '@togglecorp/fujs'; @@ -24,6 +25,9 @@ interface Props { pendingMessage?: React.ReactNode; withoutMessageIcon?: boolean; withCompactMessage?: boolean; + + spacingOffset?: number; + headerAction?: React.ReactNode; } function TabPage(props: Props) { @@ -31,6 +35,7 @@ function TabPage(props: Props) { elementRef, children, wikiLinkPathName, + headerAction, empty = false, filtered = false, @@ -43,6 +48,7 @@ function TabPage(props: Props) { errorMessage, withoutMessageIcon, withCompactMessage, + spacingOffset, } = props; const mainContent = (children || empty || pending || errored || filtered) && ( @@ -70,15 +76,24 @@ function TabPage(props: Props) { ref={elementRef} className={styles.tabPage} > - {isDefined(wikiLinkPathName) && ( - - )} +
+ {isDefined(headerAction) && ( + + )} + {isDefined(wikiLinkPathName) && ( + + )} +
{mainContent} diff --git a/app/src/components/TabPage/styles.module.css b/app/src/components/TabPage/styles.module.css index 61e7afd99d..3115a8fc8f 100644 --- a/app/src/components/TabPage/styles.module.css +++ b/app/src/components/TabPage/styles.module.css @@ -2,10 +2,16 @@ position: relative; isolation: isolate; - .wiki-link { + .action { + display: flex; position: absolute; top: 0; right: 0; - transform: translateY(-100%); + align-items: center; + transform: translateY(-150%); + + &:empty { + display: none; + } } } diff --git a/app/src/components/domain/Admin2Input/i18n.json b/app/src/components/domain/Admin2Input/i18n.json new file mode 100644 index 0000000000..813a463a5a --- /dev/null +++ b/app/src/components/domain/Admin2Input/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "admin2Input", + "strings": { + "heading": "Selected Areas", + "buttonLabel": "Select Areas", + "emptyMessage": "Admin Level 2 data is not available for this country." + } +} diff --git a/app/src/components/domain/Admin2Input/index.tsx b/app/src/components/domain/Admin2Input/index.tsx new file mode 100644 index 0000000000..7af7bc6dd1 --- /dev/null +++ b/app/src/components/domain/Admin2Input/index.tsx @@ -0,0 +1,441 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { CloseLineIcon } from '@ifrc-go/icons'; +import { + Button, + ButtonLayout, + Container, + InputError, + ListView, + Modal, +} from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + listToMap, +} 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 useDebouncedValue from '#hooks/useDebouncedValue'; +import { + COLOR_BLACK, + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, + COLOR_TEXT, + COLOR_TEXT_ON_DARK, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, + MAX_PAGE_LIMIT, +} from '#utils/constants'; +import { + useLazyRequest, + useRequest, +} from '#utils/restRequest'; + +import BaseMap from '../BaseMap'; + +import i18n from './i18n.json'; + +interface Props { + name: NAME; + value: number[] | null | undefined; + onChange: (newValue: number[] | undefined, name: NAME) => void; + countryId: number; + error?: React.ReactNode; + readOnly?: boolean; +} + +function Admin2Input(props: Props) { + const { + name, + value, + onChange, + countryId, + error, + readOnly, + } = props; + + const strings = useTranslation(i18n); + + const countryDetails = useCountry({ id: countryId }); + const iso3 = countryDetails?.iso3; + + const [selectedCodes, setSelectedCodes] = useState([]); + + const selectedCodesDebounced = useDebouncedValue(selectedCodes, 300); + + const { trigger: retrieveAdmin2Codes } = useLazyRequest({ + url: '/api/v2/admin2/', + query: { + id__in: value ?? [], + limit: MAX_PAGE_LIMIT, + }, + onSuccess: (response) => { + const responseCode = response.results.map(({ code }) => code); + setSelectedCodes(responseCode); + }, + }); + + // NOTE: To check if country has admin2 value or not + const { + response: admin2TestResponse, + pending: admin2TestPending, + } = useRequest({ + skip: isNotDefined(iso3), + url: '/api/v2/admin2/', + query: { + admin1__country__iso3: iso3 ?? undefined, + // NOTE: we just need 1 value to check + limit: 1, + }, + }); + + const hasAdmin2 = !admin2TestPending + && isDefined(admin2TestResponse) + && admin2TestResponse?.results.length > 0; + + const { response: admin2Details } = useRequest({ + skip: isNotDefined(selectedCodesDebounced) || selectedCodesDebounced.length === 0, + url: '/api/v2/admin2/', + query: { + code__in: selectedCodesDebounced ?? [], + limit: MAX_PAGE_LIMIT, + }, + onSuccess: (response) => { + onChange( + response.results.map(({ id }) => id), + name, + ); + }, + }); + + useEffect(() => { + // NOTE: Hydrate selected codes from provided value(IDs) on initialization + if (isDefined(value) + && value.length > 0 + && selectedCodes.length === 0 + ) { + retrieveAdmin2Codes(null); + } + }, [retrieveAdmin2Codes, value, selectedCodes]); + + const admin2NameMap = listToMap( + admin2Details?.results, + ({ id }) => id, + ({ name: admin2Name, district_name }) => `${admin2Name} (${district_name})`, + ); + + const admin2CodeMap = listToMap( + admin2Details?.results, + ({ id }) => id, + ({ code }) => code, + ); + + 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', 'code'], + ...value.map((admin2Id) => [ + admin2CodeMap?.[admin2Id] ?? admin2Id, + COLOR_PRIMARY_RED, + ]).flat(), + defaultColor, + ], + 'fill-outline-color': COLOR_DARK_GREY, + 'fill-opacity': 1, + }, + layout: { + visibility: 'visible', + }, + }; + return options; + }, [iso3, value, admin2CodeMap]); + + 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.code)) { + return false; + } + + setSelectedCodes((prevCodes) => { + const codeIndex = prevCodes.findIndex((prevCode) => prevCode === properties.code); + + if (codeIndex === -1) { + return [...prevCodes, properties.code]; + } + + return prevCodes.toSpliced(codeIndex, 1); + }); + + return false; + }, []); + + 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 ( + + + {strings.buttonLabel} + + )} + withCompactMessage + empty={!value || value.length === 0} + emptyMessage={!hasAdmin2 ? strings.emptyMessage : undefined} + withBorder + withPadding + > + + {value?.map((admin2Id) => ( + + + + )} + readOnly + > + {admin2NameMap?.[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/ContactInputsSection/i18n.json b/app/src/components/domain/ContactInputsSection/i18n.json new file mode 100644 index 0000000000..219d358cda --- /dev/null +++ b/app/src/components/domain/ContactInputsSection/i18n.json @@ -0,0 +1,9 @@ +{ + "namespace": "contactInputsSection", + "strings": { + "contactNameInputLabel": "Name", + "contactTitleInputLabel": "Title", + "contactEmailInputLabel": "Email", + "contactPhoneInputLabel": "Phone number" + } +} diff --git a/app/src/components/domain/ContactInputsSection/index.tsx b/app/src/components/domain/ContactInputsSection/index.tsx new file mode 100644 index 0000000000..ae09641c14 --- /dev/null +++ b/app/src/components/domain/ContactInputsSection/index.tsx @@ -0,0 +1,139 @@ +import { useCallback } from 'react'; +import { + InputSection, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + type EntriesAsList, + type Error, + getErrorObject, +} from '@togglecorp/toggle-form'; + +import i18n from './i18n.json'; + +type ContactFields = 'name' | 'title' | 'email' | 'phone_number'; +type PrefixFromKey = K extends `${infer P}_${ContactFields}` ? P : never; + +type HasAllKeys = + `${P}_name` extends K + ? `${P}_title` extends K + ? `${P}_email` extends K + ? `${P}_phone_number` extends K + ? true + : false + : false + : false + : false; + +type ValidPrefixes = + PrefixFromKey extends infer P + ? P extends string + ? HasAllKeys extends true ? P : never + : never + : never; + +interface Props< + FORM_VALUE extends object, + NAME_PREFIX extends ValidPrefixes> +> { + namePrefix: NAME_PREFIX; + value: FORM_VALUE; + setFieldValue: (...entries: EntriesAsList) => void; + error: Error | undefined; + + title?: React.ReactNode; + description?: React.ReactNode; + disabled?: boolean; + readOnly?: boolean; + withAsteriskOnTitle?: boolean; + withRequiredNameAndEmail?: boolean; +} + +function ContactInputsSection< + FORM_VALUE extends object, + const NAME_PREFIX extends ValidPrefixes> +>(props: Props) { + const { + title: sectionTitle, + description, + namePrefix, + value, + setFieldValue, + error: formError, + disabled, + readOnly, + withAsteriskOnTitle, + withRequiredNameAndEmail, + } = props; + + const strings = useTranslation(i18n); + + const error = getErrorObject(formError); + + function getContactFieldValue(field: ContactFields) { + return value?.[`${namePrefix}_${field}`]; + } + + function getContactFieldError(field: ContactFields) { + return error?.[`${namePrefix}_${field}`] as string | undefined; + } + + const setContactFieldValue = useCallback( + (newValue: string | undefined, field: ContactFields) => { + const fieldName = `${namePrefix}_${field}` as const; + setFieldValue(newValue as ReturnType, fieldName); + }, + [setFieldValue, namePrefix], + ); + + return ( + + + + + + + ); +} + +export default ContactInputsSection; diff --git a/app/src/components/domain/DrefExportModal/index.tsx b/app/src/components/domain/DrefExportModal/index.tsx index 0bcebcca03..4240a3bf2f 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,12 @@ function DrefExportModal(props: Props) { export_id: id, export_type: type, is_pga: includePga, - selector: '#pdf-preview-ready', + // selector: '#pdf-preview-ready', per_country: undefined, - }; + summary: undefined, + version: undefined, + diff: undefined, + } satisfies ExportBody; }, [ id, diff --git a/app/src/components/domain/DrefShareModal/index.tsx b/app/src/components/domain/DrefShareModal/index.tsx index 7766b3881d..0185bedea9 100644 --- a/app/src/components/domain/DrefShareModal/index.tsx +++ b/app/src/components/domain/DrefShareModal/index.tsx @@ -23,7 +23,7 @@ import { useRequest, } from '#utils/restRequest'; -import UserItem from './UserItem'; +import ShareUserItem from '../ShareUserItem'; import i18n from './i18n.json'; @@ -143,7 +143,7 @@ function DrefShareModal(props: Props) { > 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..31e8c3fd62 --- /dev/null +++ b/app/src/components/domain/EapExportModal/index.tsx @@ -0,0 +1,200 @@ +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; + summary?: boolean; +} + +function EapExportModal(props: Props) { + const { + eapId, + eapType, + onClose, + version, + diff, + summary, + } = 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, + summary, + diff, + }), + [eapId, eapType, version, diff, summary], + ); + + 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/EapIndicatorInput/i18n.json b/app/src/components/domain/EapIndicatorInput/i18n.json new file mode 100644 index 0000000000..8df0c45642 --- /dev/null +++ b/app/src/components/domain/EapIndicatorInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "eapIndicatorInput", + "strings": { + "removeButtonTitle": "Remove", + "titleInputLabel": "Title", + "targetInputLabel": "Target" + } +} diff --git a/app/src/components/domain/EapIndicatorInput/index.tsx b/app/src/components/domain/EapIndicatorInput/index.tsx new file mode 100644 index 0000000000..5b812cace0 --- /dev/null +++ b/app/src/components/domain/EapIndicatorInput/index.tsx @@ -0,0 +1,103 @@ +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + IconButton, + InlineLayout, + ListView, + NumberInput, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + type ArrayError, + getErrorObject, + type LeafError, + type PartialForm, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; + +import i18n from './i18n.json'; + +type Indicator = components<'write'>['schemas']['Indicator'] & { client_id: string }; +type IndicatorFormFields = PartialForm; + +const defaultIndicatorValue: IndicatorFormFields = { + client_id: '-1', +}; + +interface Props { + value: IndicatorFormFields; + error: ArrayError | LeafError | undefined; + onChange: (value: SetValueArg, index: number) => void; + onRemove: (index: number) => void; + index: number; + disabled?: boolean; + readOnly?: boolean; +} + +function EapIndicatorInput(props: Props) { + const { + error: errorFromProps, + readOnly, + onChange, + value, + index, + onRemove, + disabled, + } = props; + + const strings = useTranslation(i18n); + + const onFieldChange = useFormObject(index, onChange, defaultIndicatorValue); + + const error = value && value.client_id && errorFromProps + ? getErrorObject(getErrorObject(errorFromProps)?.[value.client_id]) + : undefined; + + return ( + + + + )} + spacing="sm" + > + + + + + + ); +} + +export default EapIndicatorInput; diff --git a/app/src/components/domain/EapIndicatorInput/schema.ts b/app/src/components/domain/EapIndicatorInput/schema.ts new file mode 100644 index 0000000000..d502d53249 --- /dev/null +++ b/app/src/components/domain/EapIndicatorInput/schema.ts @@ -0,0 +1,35 @@ +import { + type ObjectSchema, + type PartialForm, + requiredStringCondition, + undefinedValue, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; +import { positiveNumberCondition } from '#utils/form'; + +type Indicator = components<'write'>['schemas']['Indicator']; + +export type IndicatorFormFields = PartialForm & { + client_id: string; +} + +type IndicatorSchema = ObjectSchema; + +const schema = (isSubmit: boolean): IndicatorSchema => ({ + fields: (): ReturnType => ({ + client_id: {}, + id: { defaultValue: undefinedValue }, + title: { + // FIXME: add validation for character limit + required: isSubmit, + requiredValidation: requiredStringCondition, + }, + target: { + required: isSubmit, + validations: [positiveNumberCondition], + }, + }), +}); + +export default schema; diff --git a/app/src/components/domain/EapIndicatorListInput/i18n.json b/app/src/components/domain/EapIndicatorListInput/i18n.json new file mode 100644 index 0000000000..364ada6509 --- /dev/null +++ b/app/src/components/domain/EapIndicatorListInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "eapIndicatorListInput", + "strings": { + "indicatorsTitle": "Indicators", + "addButtonLabel": "Add indicator", + "emptyMessage": "No indicators yet!" + } +} diff --git a/app/src/components/domain/EapIndicatorListInput/index.tsx b/app/src/components/domain/EapIndicatorListInput/index.tsx new file mode 100644 index 0000000000..662d07a8eb --- /dev/null +++ b/app/src/components/domain/EapIndicatorListInput/index.tsx @@ -0,0 +1,127 @@ +import { useCallback } from 'react'; +import { AddLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + ListView, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isNotDefined, + randomString, +} from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type LeafError, + type SetValueArg, + useFormArray, +} from '@togglecorp/toggle-form'; + +import EapIndicatorInput from '#components/domain/EapIndicatorInput'; +import { type IndicatorFormFields } from '#components/domain/EapIndicatorInput/schema'; +import NonFieldError from '#components/NonFieldError'; + +import i18n from './i18n.json'; + +type FormName = 'indicators'; + +interface Props { + disabled?: boolean; + readOnly?: boolean; + + name: NAME, + value: IndicatorFormFields[] | undefined; + onChange: (newValue: SetValueArg, name: NAME) => void; + error: ArrayError | LeafError | undefined; +} + +function EapIndicatorListInput(props: Props) { + const { + disabled, + readOnly, + + name, + value, + onChange, + error, + } = props; + + const strings = useTranslation(i18n); + + const { + setValue: onReadinessChange, + removeValue: onReadinessRemove, + } = useFormArray( + name, + onChange, + ); + + const handleReadinessAddButtonClick = useCallback( + () => { + const newActionItem: IndicatorFormFields = { + client_id: randomString(), + }; + + onChange( + (oldValue: IndicatorFormFields[] | undefined) => ( + [...(oldValue ?? []), newActionItem] + ), + name, + ); + }, + [onChange, name], + ); + + return ( + + {strings.indicatorsTitle} + * + + )} + headingLevel={5} + footerActions={( + + )} + withCompactMessage + empty={isNotDefined(value) + || value.length === 0} + emptyMessage={strings.emptyMessage} + footer={} + > + + {value?.map((activity, i) => ( + + ))} + + + ); +} + +export default EapIndicatorListInput; diff --git a/app/src/components/domain/EapObsoleteResolutionModal/i18n.json b/app/src/components/domain/EapObsoleteResolutionModal/i18n.json new file mode 100644 index 0000000000..34f2c6840b --- /dev/null +++ b/app/src/components/domain/EapObsoleteResolutionModal/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "eapObsoleteResolutionModal", + "strings": { + "conflictWhileSaving": "There's a newer version of current EAP form which was modified by", + "changesWillBeOverridden": "If you continue saving, the previous changes will be overwritten by your changes.", + "changesConflictWhileSaving": "Conflict while saving", + "changesCancelButton": "Cancel", + "changesOverwriteButton": "Overwrite", + "changesNoteLabel": "Note" + } +} diff --git a/app/src/components/domain/EapObsoleteResolutionModal/index.tsx b/app/src/components/domain/EapObsoleteResolutionModal/index.tsx new file mode 100644 index 0000000000..a33d8c6010 --- /dev/null +++ b/app/src/components/domain/EapObsoleteResolutionModal/index.tsx @@ -0,0 +1,95 @@ +import { + BlockLoading, + Button, + Description, + ListView, + Modal, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { getUserName } from '#utils/domain/user'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +interface Props { + fullEapId?: number; + simplifiedEapId?: number; + onOverwriteButtonClick: (newModifiedAt: string | undefined) => void; + onCancelButtonClick: (_: boolean) => void; +} + +function EapObsoleteResolutionModal(props: Props) { + const { + fullEapId, + simplifiedEapId, + onOverwriteButtonClick, + onCancelButtonClick, + } = props; + + const strings = useTranslation(i18n); + + const { pending: fullEapPending, response: fullEapResponse } = useRequest({ + skip: isNotDefined(fullEapId) || isDefined(simplifiedEapId), + url: '/api/v2/full-eap/{id}/', + pathVariables: isDefined(fullEapId) ? { id: fullEapId } : undefined, + }); + + const { pending: simplifiedEapPending, response: simplifiedEapResponse } = useRequest({ + skip: isNotDefined(simplifiedEapId) || isDefined(fullEapId), + url: '/api/v2/simplified-eap/{id}/', + pathVariables: isDefined(simplifiedEapId) + ? { + id: Number(simplifiedEapId), + } + : undefined, + }); + + const response = fullEapResponse ?? simplifiedEapResponse; + const pending = fullEapPending || simplifiedEapPending; + + return ( + + + + + )} + > + {pending ? : ( + + + {strings.changesWillBeOverridden} + + + )} + + ); +} + +export default EapObsoleteResolutionModal; diff --git a/app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/index.tsx b/app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/index.tsx new file mode 100644 index 0000000000..d94c47b047 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/index.tsx @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { + type CheckboxProps, + InputError, +} from '@ifrc-go/ui'; +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +function TimeSpanCheck(props: CheckboxProps) { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + checkmarkClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + checkmarkContainerClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + inputClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + labelContainerClassName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + description, + + className: classNameFromProps, + disabled, + error, + indeterminate, + invertedLogic = false, + label, + name, + onChange, + readOnly, + tooltip, + value, + withBackground, + withDarkBackground, + ...otherProps + } = props; + + const handleChange = useCallback( + (e: React.FormEvent) => { + const v = e.currentTarget.checked; + onChange( + invertedLogic ? !v : v, + name, + ); + }, + [name, onChange, invertedLogic], + ); + + const checked = invertedLogic ? !value : value; + + const className = _cs( + styles.checkbox, + classNameFromProps, + !indeterminate && checked && styles.checked, + withBackground && styles.withBackground, + withDarkBackground && styles.withDarkBackground, + disabled && styles.disabledCheckbox, + readOnly && styles.readOnly, + ); + + return ( + + ); +} + +export default TimeSpanCheck; diff --git a/app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/styles.module.css b/app/src/components/domain/EapOperationActivityInput/TimeSpanCheck/styles.module.css new file mode 100644 index 0000000000..00ca96a56f --- /dev/null +++ b/app/src/components/domain/EapOperationActivityInput/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/EapOperationActivityInput/i18n.json b/app/src/components/domain/EapOperationActivityInput/i18n.json new file mode 100644 index 0000000000..43c2e750d4 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "eapOperationActivityInput", + "strings": { + "operationPriorityActionLabel": "Priority action", + "operationTimeFrameLabel": "Time Frame", + "operationTimeValueLabel": "Time Value" + } +} diff --git a/app/src/components/domain/EapOperationActivityInput/index.tsx b/app/src/components/domain/EapOperationActivityInput/index.tsx new file mode 100644 index 0000000000..9b6f6da2e2 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityInput/index.tsx @@ -0,0 +1,193 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + Checklist, + IconButton, + 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 LeafError, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { TIMEFRAME_YEAR } from '#utils/constants'; + +import { type OperationActivityFormFields } from './schema'; +import TimeSpanCheck from './TimeSpanCheck'; + +import i18n from './i18n.json'; + +const defaultActivityValue: OperationActivityFormFields = { + client_id: '-1', +}; + +export type ActivityInputType = 'readiness_activities' | 'prepositioning_activities' | 'early_action_activities'; + +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 | LeafError | undefined; + onChange: (value: SetValueArg, index: number) => void; + onRemove: (index: number) => void; + index: number; + disabled?: boolean; + readOnly?: boolean; + + name: ActivityInputType; +} + +function EapOperationActivityInput(props: Props) { + const { + error: errorFromProps, + onChange, + value, + index, + onRemove, + disabled, + readOnly, + name, + } = 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(getErrorObject(errorFromProps)?.[value.client_id]) + : undefined; + + const eapTimeframeOption = useMemo(() => { + if (name === 'early_action_activities') { + return eap_timeframe?.filter((item) => item.key !== TIMEFRAME_YEAR); + } + return eap_timeframe; + }, [eap_timeframe, name]); + + const eapTimeFrameReadOnly = name === 'readiness_activities' || name === 'prepositioning_activities'; + + 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 EapOperationActivityInput; diff --git a/app/src/components/domain/EapOperationActivityInput/schema.ts b/app/src/components/domain/EapOperationActivityInput/schema.ts new file mode 100644 index 0000000000..1eb11d29d2 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityInput/schema.ts @@ -0,0 +1,32 @@ +import { + type ObjectSchema, + type PartialForm, + requiredStringCondition, + undefinedValue, +} from '@togglecorp/toggle-form'; + +import { type components } from '#generated/types'; + +type OperationActivity = components<'write'>['schemas']['OperationActivity']; + +export type OperationActivityFormFields = PartialForm & { + client_id: string; +} + +type OperationActivitySchema = ObjectSchema; + +const schema = (isSubmit: boolean): OperationActivitySchema => ({ + fields: (): ReturnType => ({ + client_id: {}, + id: { defaultValue: undefinedValue }, + activity: { + // FIXME: add validation for character limit + required: isSubmit, + requiredValidation: requiredStringCondition, + }, + time_value: {}, + timeframe: {}, + }), +}); + +export default schema; diff --git a/app/src/components/domain/EapOperationActivityListInput/i18n.json b/app/src/components/domain/EapOperationActivityListInput/i18n.json new file mode 100644 index 0000000000..2f3ba72329 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityListInput/i18n.json @@ -0,0 +1,14 @@ +{ + "namespace": "eapOperationActivityListInput", + "strings": { + "addButtonLabel": "Add activity", + "emptyMessage": "No activities yet!", + "readinessTitle": "Readiness Activities", + "readinessDescription": "Readiness activities are done year on year to ensure that the National Society is ready to conduct the early actions. These are activities that will happen irrespective of an activation. Readiness activities may include refresher training, coordination meetings with government, readiness meetings, simulations, etc. Under readiness they can include any ongoing costs and services (human resources and logistics) that are deemed indispensable for subsequent trigger-based early action activities. If, during the simplified EAP development process the National Society finds some areas for improvement to deliver on their selected early actions, these could be addressed with activities included under readiness.", + "prepositioningTitle": "Pre-positioning Activities", + "prepositioningDescription": "The National Society should preposition the materials needed to undertake the early action, especially those that may require a longer procurement process. For example, prepositioned stocks could include shelter kits (for house reinforcement), sandbags (for protecting infrastructure), or tarpaulins (for protecting water sources), etc. Food, medicine and other items with a shelf life of less than two years are not eligible as pre-positioning, they will have to be procured as part of the early actions. Pre-positioning activities are one-off and done in the first year following approval of the simplified EAP.", + "earlyActionTitle": "Early Action Activities", + "earlyActionDescription": "Early action activities are implemented once a trigger is reached and before the impact of the hazard. Early actions seek to reduce or mitigate the impact of the hazard. Consider selecting only a few early actions, especially for sudden onset events, as they will have to be implemented within a short timeframe. The early actions will be unique to each hazard and context, but may be activities such as evacuation of at-risk communities and/or livestock, early harvest of crops, cash transfer, shelter strengthening, provision of water treatment, hygiene kits or mosquito nets, etc. For more examples of early action activities, visit the {earlyActionDatabaseLink} on the Anticipation Hub.", + "earlyActionDatabaseLinkLabel": "early action database" + } +} diff --git a/app/src/components/domain/EapOperationActivityListInput/index.tsx b/app/src/components/domain/EapOperationActivityListInput/index.tsx new file mode 100644 index 0000000000..0cdd7eb833 --- /dev/null +++ b/app/src/components/domain/EapOperationActivityListInput/index.tsx @@ -0,0 +1,185 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { AddLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Description, + ListView, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToComponent } from '@ifrc-go/ui/utils'; +import { + isNotDefined, + randomString, +} from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type LeafError, + type SetValueArg, + useFormArray, +} from '@togglecorp/toggle-form'; + +import EapOperationActivityInput, { type ActivityInputType } from '#components/domain/EapOperationActivityInput'; +import { type OperationActivityFormFields } from '#components/domain/EapOperationActivityInput/schema'; +import ExplanatoryNote from '#components/ExplanatoryNote'; +import Link from '#components/Link'; +import NonFieldError from '#components/NonFieldError'; +import { TIMEFRAME_YEAR } from '#utils/constants'; + +import i18n from './i18n.json'; + +interface Props { + disabled?: boolean; + readOnly?: boolean; + + name: NAME, + value: OperationActivityFormFields[] | undefined; + onChange: (newValue: SetValueArg, name: NAME) => void; + error: ArrayError | LeafError | undefined; +} + +function EapOperationActivityListInput(props: Props) { + const { + disabled, + readOnly, + + name, + value, + onChange, + error, + } = props; + + const strings = useTranslation(i18n); + + const { + setValue: onReadinessChange, + removeValue: onReadinessRemove, + } = useFormArray( + name, + onChange, + ); + + const handleReadinessAddButtonClick = useCallback( + () => { + const timeframeValue = name === 'readiness_activities' || name === 'prepositioning_activities' + ? TIMEFRAME_YEAR + : undefined; + const newActionItem: OperationActivityFormFields = { + client_id: randomString(), + timeframe: timeframeValue, + }; + + onChange( + (oldValue: OperationActivityFormFields[] | undefined) => ( + [...(oldValue ?? []), newActionItem] + ), + name, + ); + }, + [onChange, name], + ); + + const [ + title, + description, + ] = useMemo(() => { + if (name === 'readiness_activities') { + return [strings.readinessTitle, strings.readinessDescription]; + } + + if (name === 'prepositioning_activities') { + return [strings.prepositioningTitle, strings.prepositioningDescription]; + } + + if (name === 'early_action_activities') { + return [ + strings.earlyActionTitle, + resolveToComponent( + strings.earlyActionDescription, + { + earlyActionDatabaseLink: ( + + {strings.earlyActionDatabaseLinkLabel} + + ), + }, + ), + ]; + } + + return []; + }, [name, strings]); + + return ( + + {title} + {(title && description) && ( + + {description} + + )} + /> + )} + + )} + headingLevel={5} + footerActions={( + + )} + withCompactMessage + empty={isNotDefined(value) + || value.length === 0} + emptyMessage={strings.emptyMessage} + footer={} + > + + {value?.map((activity, i) => ( + + ))} + + + ); +} + +export default EapOperationActivityListInput; diff --git a/app/src/components/domain/EapShareModal/i18n.json b/app/src/components/domain/EapShareModal/i18n.json new file mode 100644 index 0000000000..eff7f3d51f --- /dev/null +++ b/app/src/components/domain/EapShareModal/i18n.json @@ -0,0 +1,13 @@ +{ + "namespace": "eapShareModal", + "strings": { + "eapShareTitle": "share EAP", + "eapShareDescription": "Add or Remove users you with whom you want to share the EAP.", + "eapShareSuccessfully": "Successfully updated sharing preferences!", + "eapShareFailed": "Unable to save sharing preferences!", + "eapShareUpdate": "Update", + "eapSelectUserLabel": "Select users", + "eapAlreadySharedHeading": "Already shared to", + "userListEmptyMessage": "The EAP form is not shared with anyone." + } +} diff --git a/app/src/components/domain/EapShareModal/index.tsx b/app/src/components/domain/EapShareModal/index.tsx new file mode 100644 index 0000000000..ac480436ff --- /dev/null +++ b/app/src/components/domain/EapShareModal/index.tsx @@ -0,0 +1,173 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Button, + Container, + ListView, + Modal, + RawList, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import UserSearchMultiSelectInput, { type User } from '#components/domain/UserSearchMultiSelectInput'; +import useAlert from '#hooks/useAlert'; +import useInputState from '#hooks/useInputState'; +import { + useLazyRequest, + useRequest, +} from '#utils/restRequest'; + +import ShareUserItem from '../ShareUserItem'; + +import i18n from './i18n.json'; + +interface Props { + eapId: number; + onCancel: () => void; + onSuccess: () => void; +} + +const userKeySelector = (item: User) => item.id; + +function EapShareModal(props: Props) { + const { + eapId, + onCancel, + onSuccess, + } = props; + + const strings = useTranslation(i18n); + + const alert = useAlert(); + const [users, setUsers] = useInputState([]); + const [userOptions, setUserOptions] = useInputState([]); + + const { + pending: updatePending, + trigger: triggerUpdate, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/eap-registration/{id}/share/', + pathVariables: { id: eapId }, + body: () => ({ + users, + }), + onSuccess: () => { + alert.show( + strings.eapShareSuccessfully, + { variant: 'success' }, + ); + onSuccess(); + }, + onFailure: () => { + alert.show( + strings.eapShareFailed, + { variant: 'danger' }, + ); + }, + }); + + const { + pending: usersPending, + } = useRequest({ + skip: isNotDefined(eapId), + url: '/api/v2/eap-share-users/{id}/', + pathVariables: { id: eapId }, + onSuccess: (response) => { + if (isDefined(response.users)) { + setUsers(response.users); + } + + setUserOptions(response.users_details); + }, + }); + + const handleUserRemove = useCallback((userId: number) => { + setUsers((oldVal = []) => { + const index = oldVal.indexOf(userId); + + if (index === -1) return oldVal; + + const userList = [...oldVal]; + userList.splice(index, 1); + + return userList; + }); + }, [setUsers]); + + const userOptionsMap = useMemo(() => listToMap( + userOptions, + (item) => item.id, + ), [userOptions]); + + const selectedUsers = useMemo(() => ( + users?.map((item) => userOptionsMap?.[item]) + ), [userOptionsMap, users]); + + const userRendererParams = useCallback((userId: number, user: User) => ({ + userId, + user, + onUserRemove: handleUserRemove, + }), [ + handleUserRemove, + ]); + + return ( + + {strings.eapShareUpdate} + + )} + size="md" + withHeaderBorder + > + + + + + + + + + + ); +} + +export default EapShareModal; diff --git a/app/src/components/domain/GoMultiFileInput/i18n.json b/app/src/components/domain/GoMultiFileInput/i18n.json index 07d67703b1..42f738ff02 100644 --- a/app/src/components/domain/GoMultiFileInput/i18n.json +++ b/app/src/components/domain/GoMultiFileInput/i18n.json @@ -2,6 +2,7 @@ "namespace": "goMultiFileInput", "strings": { "goMultiFailedUploadMessage": "Failed to upload the file!", + "noFileSelected": "No file Selected", "goMultiDeleteButton": "Delete" } -} \ No newline at end of file +} diff --git a/app/src/components/domain/GoMultiFileInput/index.tsx b/app/src/components/domain/GoMultiFileInput/index.tsx index d4f8f74916..a83f06105c 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; @@ -170,7 +170,7 @@ function GoMultiFileInput(props: Props) { }, [onChange, name]); const disabled = disabledFromProps || pending || readOnly; - const valueUrls = isDefined(value) ? ( + const valueUrls = (isDefined(value) && value.length > 0) ? ( value.map((fileId) => ({ id: fileId, url: fileIdToUrlMap?.[fileId] })) ) : undefined; @@ -193,10 +193,14 @@ function GoMultiFileInput(props: Props) { return ( - + (props: Props) { > {children} - {clearable && value && ( + {clearable && value && value.length > 0 && ( (props: Props) { )} + {isNotDefined(valueUrls) && ( + + {strings.noFileSelected} + + )} {!withoutPreview && isDefined(valueUrls) && valueUrls.length > 0 && ( = Omit, 'value'> & { name: NAME; @@ -156,10 +156,14 @@ function GoSingleFileInput(props: Props) { return ( - + (props: Props) { > {children} - {clearable && isDefined(value) && ( - - - + {isNotDefined(selectedFileUrl) && !withoutStatus && ( + + {strings.noFileSelected} + )} + + {!withoutPreview && isDefined(selectedFileUrl) && ( + + {selectedFileUrl.split('/').pop()} + + )} + {clearable && isDefined(value) && ( + + + + )} + - {!withoutPreview && isDefined(selectedFileUrl) && ( - - {selectedFileUrl.split('/').pop()} - - )} - {isNotDefined(selectedFileUrl) && !withoutStatus && ( - - {strings.noFileSelected} - - )} {description && ( {description} 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..6d167e3adc 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 ImageWithCaptionValue = { + 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: ImageWithCaptionValue | null | undefined; + onChange: (value: SetValueArg | undefined, name: NAME) => void; + error: ObjectError | undefined; fileIdToUrlMap: Record; setFileIdToUrlMap?: React.Dispatch>>; - label: React.ReactNode; + label?: React.ReactNode; before?: React.ReactNode; after?: React.ReactNode; disabled?: boolean; @@ -46,6 +52,8 @@ interface Props { // FIXME: Move this to components function ImageWithCaptionInput(props: Props) { + const strings = useTranslation(i18n); + const { className, readOnly, @@ -56,15 +64,13 @@ function ImageWithCaptionInput(props: Props) setFileIdToUrlMap, onChange, error: formError, - label, + label = strings.defaultLabel, before, after, disabled, useCurrentLanguageForMutation, } = props; - const strings = useTranslation(i18n); - const setFieldValue = useFormObject( name, onChange, @@ -113,13 +119,14 @@ function ImageWithCaptionInput(props: Props) // FIXME: Make Go single file input with preview description={isDefined(fileUrl) ? ( {strings.imageWithCaptionPreview} ) : undefined} clearable useCurrentLanguageForMutation={useCurrentLanguageForMutation} + error={error?.id} > {label} @@ -130,7 +137,7 @@ function ImageWithCaptionInput(props: Props) readOnly={readOnly} onChange={setFieldValue} error={error?.caption} - placeholder={strings.imageWithCaptionEnterCaption} + placeholder={strings.captionInputPlaceholder} disabled={disabled} /> )} diff --git a/app/src/components/domain/InfoModal/index.tsx b/app/src/components/domain/InfoModal/index.tsx new file mode 100644 index 0000000000..ddcf1f5383 --- /dev/null +++ b/app/src/components/domain/InfoModal/index.tsx @@ -0,0 +1,87 @@ +import { + Button, + type ButtonProps, + IconButton, + Modal, +} from '@ifrc-go/ui'; +import { useBooleanState } from '@ifrc-go/ui/hooks'; + +type IconButtonProps = { + icon: React.ReactNode; + ariaLabel: string; + label?: never; + title: string; +} + +type LabelButtonProps = { + icon?: never; + ariaLabel?: string; + label?: string; +} + +type ButtonTypeProps = IconButtonProps | LabelButtonProps; + +// FIXME: make the props consistent with other similar components +// e.g. DropdownMenu +interface BaseProps extends ButtonProps { + heading?: string; + modalContent: React.ReactNode; +} + +type Props = BaseProps & ButtonTypeProps; + +// FIXME: this component should be in `/components` +function InfoModal(props: Props) { + const { + heading, + label, + modalContent, + ariaLabel, + title, + icon, + ...otherButtonProps + } = props; + + const [ + showInfoModal, + { + setTrue: setShowInfoModalTrue, + setFalse: setShowInfoModalFalse, + }, + ] = useBooleanState(false); + + return ( + <> + {icon ? ( + + {icon} + + ) : ( + + )} + {showInfoModal && ( + + {modalContent} + + )} + + ); +} + +export default InfoModal; 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..cafd69f5e1 100644 --- a/app/src/components/domain/MultiImageWithCaptionInput/index.tsx +++ b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx @@ -29,31 +29,40 @@ 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; disabled?: boolean; + description?: React.ReactNode; useCurrentLanguageForMutation?: boolean; } // FIXME: Move this to components function MultiImageWithCaptionInput(props: Props) { + const strings = useTranslation(i18n); + const { className, name, @@ -63,16 +72,15 @@ function MultiImageWithCaptionInput(props: Prop setFileIdToUrlMap, onChange, error: formError, - label, + label = strings.defaultLabel, readOnly, before, after, disabled, + description, useCurrentLanguageForMutation = false, } = props; - const strings = useTranslation(i18n); - const error = getErrorObject(formError); const { @@ -152,6 +160,7 @@ function MultiImageWithCaptionInput(props: Prop readOnly={readOnly} disabled={disabled} useCurrentLanguageForMutation={useCurrentLanguageForMutation} + description={description} > {label} @@ -185,8 +194,8 @@ function MultiImageWithCaptionInput(props: Prop (props: Prop /> {strings.imagePreviewAlt} @@ -206,7 +215,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/PrintableActivityOutput/index.tsx b/app/src/components/domain/PrintableActivityOutput/index.tsx new file mode 100644 index 0000000000..02d105c871 --- /dev/null +++ b/app/src/components/domain/PrintableActivityOutput/index.tsx @@ -0,0 +1,77 @@ +import { + compareNumber, + isNotDefined, +} from '@togglecorp/fujs'; + +import PrintableDataDisplay from '#components/printable/PrintableDataDisplay'; +import PrintableLabel from '#components/printable/PrintableLabel'; +import { type components } from '#generated/types'; + +type OperationActivity = components['schemas']['OperationActivity']; + +function getFormattedActivityTimeline(activity: OperationActivity | undefined) { + if (isNotDefined(activity)) { + return undefined; + } + + const { + time_value, + timeframe_display, + } = activity; + + const timeValueDisplay = time_value.toSorted( + (a, b) => compareNumber(a, b, 1), + ); + + return ( + `${timeValueDisplay} ${timeframe_display}` + ); +} + +function getFormattedActivityLabel(activity: OperationActivity | undefined, index: number) { + if (isNotDefined(activity)) { + return undefined; + } + + return `${index + 1}. ${activity.activity}`; +} + +interface Props { + activity: OperationActivity; + prevActivity: OperationActivity | undefined; + withDiff: boolean; + index: number; +} + +function PrintableActivityOutput(props: Props) { + const { + activity, + prevActivity, + withDiff, + index, + } = props; + + return ( + + )} + value={getFormattedActivityTimeline(activity)} + prevValue={ + getFormattedActivityTimeline(prevActivity) + } + valueType="text" + variant="contents" + withBackground + withPadding + withoutLabelColon + withDiff={withDiff} + /> + ); +} + +export default PrintableActivityOutput; diff --git a/app/src/components/domain/DrefShareModal/UserItem/i18n.json b/app/src/components/domain/ShareUserItem/i18n.json similarity index 62% rename from app/src/components/domain/DrefShareModal/UserItem/i18n.json rename to app/src/components/domain/ShareUserItem/i18n.json index e4a826abbb..6eb8e118ea 100644 --- a/app/src/components/domain/DrefShareModal/UserItem/i18n.json +++ b/app/src/components/domain/ShareUserItem/i18n.json @@ -1,6 +1,6 @@ { - "namespace": "drefShareModal", + "namespace": "shareUserItem", "strings": { "removeUser": "Remove User" } -} \ No newline at end of file +} diff --git a/app/src/components/domain/DrefShareModal/UserItem/index.tsx b/app/src/components/domain/ShareUserItem/index.tsx similarity index 91% rename from app/src/components/domain/DrefShareModal/UserItem/index.tsx rename to app/src/components/domain/ShareUserItem/index.tsx index f33f7dcd01..60eb2ba591 100644 --- a/app/src/components/domain/DrefShareModal/UserItem/index.tsx +++ b/app/src/components/domain/ShareUserItem/index.tsx @@ -17,7 +17,7 @@ interface Props { onUserRemove?: (item: number) => void; } -function UserItem(props: Props) { +function ShareUserItem(props: Props) { const { userId, user, @@ -33,7 +33,7 @@ function UserItem(props: Props) { return ( [number]; +type SourceInformationFormFields = NonNullable< + PartialDref['source_information'] +>[number]; interface Props { value: SourceInformationFormFields; error: ArrayError | undefined; - onChange: (value: SetValueArg, index: number) => void; + onChange: ( + value: SetValueArg, + index: number + ) => void; onRemove: (index: number) => void; index: number; disabled?: boolean; @@ -47,39 +50,17 @@ function SourceInformationInput(props: Props) { const strings = useTranslation(i18n); - const onFieldChange = useFormObject( - index, - onChange, - () => ({ - client_id: randomString(), - }), - ); + const onFieldChange = useFormObject(index, onChange, () => ({ + client_id: randomString(), + })); - const error = (value && value.client_id && errorFromProps) + const error = value && value.client_id && errorFromProps ? getErrorObject(errorFromProps?.[value.client_id]) : undefined; const handleSourceFieldChange = useCallback( - (newValue: string | undefined) => { - if ( - isNotDefined(newValue) - || newValue.startsWith('http://') - || newValue.startsWith('https://') - || newValue === 'h' - || newValue === 'ht' - || newValue === 'htt' - || newValue === 'http' - || newValue === 'http:' - || newValue === 'http:/' - || newValue === 'https' - || newValue === 'https:' - || newValue === 'https:/' - ) { - onFieldChange(newValue, 'source_link'); - return; - } - - onFieldChange(`https://${newValue}`, 'source_link'); + (linkValue: string | undefined) => { + onFieldChange(formatSourceLink(linkValue), 'source_link'); }, [onFieldChange], ); diff --git a/app/src/components/printable/DiffTextOutput/index.tsx b/app/src/components/printable/DiffTextOutput/index.tsx new file mode 100644 index 0000000000..fd5a918947 --- /dev/null +++ b/app/src/components/printable/DiffTextOutput/index.tsx @@ -0,0 +1,91 @@ +import { + Fragment, + useMemo, +} from 'react'; +import { + _cs, + isNotDefined, +} from '@togglecorp/fujs'; +import DiffMatchPatch from 'diff-match-patch'; + +import styles from './styles.module.css'; + +const ADDED = 1; +const REMOVED = -1; + +interface Props { + value?: string | null; + className?: string; + withDiff?: boolean; + prevValue?: string | null; +} + +function DiffTextOutput(props: Props) { + const { + className, + value, + prevValue, + withDiff = false, + } = props; + + const diff = useMemo(() => { + if (!withDiff) { + return undefined; + } + const diffMatch = new DiffMatchPatch(); + return diffMatch.diff_main(prevValue ?? '', value ?? ''); + }, [withDiff, value, prevValue]); + + if (isNotDefined(diff)) { + return ( +
+ {value} +
+ ); + } + + return ( +
+ {diff.map(([changeType, content], index) => { + if (changeType === ADDED) { + return ( + + {content} + + ); + } + + if (changeType === REMOVED) { + return ( + + {content} + + ); + } + + return ( + // eslint-disable-next-line react/no-array-index-key + + {content} + + ); + })} +
+ ); +} + +export default DiffTextOutput; diff --git a/app/src/components/printable/DiffTextOutput/styles.module.css b/app/src/components/printable/DiffTextOutput/styles.module.css new file mode 100644 index 0000000000..7e9b59945e --- /dev/null +++ b/app/src/components/printable/DiffTextOutput/styles.module.css @@ -0,0 +1,18 @@ +.diff-text-output { + 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/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..b2d6d08ed7 --- /dev/null +++ b/app/src/components/printable/PrintableDataDisplay/index.tsx @@ -0,0 +1,282 @@ +import { useMemo } from 'react'; +import { + BooleanOutput, + type BooleanOutputProps, + DateOutput, + type DateOutputProps, + ListView, + 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, + isDefined, + isNotDefined, + isTruthyString, +} 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; + withDiff: boolean; +} + +interface BooleanProps extends BooleanOutputProps { + valueType: 'boolean', + prevValue?: BooleanOutputProps['value']; +} + +interface NumberProps extends NumberOutputProps { + valueType: 'number', + prevValue?: NumberOutputProps['value']; +} + +interface DateProps extends DateOutputProps { + valueType: 'date', + prevValue?: DateProps['value']; +} + +interface TextProps { + valueType: 'text', + value: string | null | undefined; + prevValue?: TextProps['value']; +} + +interface NodeProps { + valueType?: never; + value?: React.ReactNode; + prevValue?: never; +} + +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, + withDiff, + ...otherProps + } = props; + + const valueComponent = useMemo(() => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prevValue: _, + ...componentProps + } = otherProps; + + if (componentProps.valueType === 'number') { + return ( + + ); + } + + if (componentProps.valueType === 'date') { + return ( + + ); + } + + if (componentProps.valueType === 'boolean') { + return ( + + ); + } + + if (!(componentProps.value instanceof Date)) { + return componentProps.value || invalidText; + } + + return invalidText; + }, [otherProps, invalidText]); + + const prevValueComponent = useMemo(() => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + value: _, + ...componentProps + } = otherProps; + + if (!withDiff) { + return null; + } + + if (componentProps.valueType === 'number') { + return ( + + ); + } + + if (componentProps.valueType === 'date') { + return ( + + ); + } + + if (componentProps.valueType === 'boolean') { + return ( + + ); + } + + if (isTruthyString(componentProps.prevValue)) { + return componentProps.prevValue; + } + + return invalidText; + }, [withDiff, otherProps, invalidText]); + + const { value, valueType, prevValue } = otherProps; + + const diffType: 'added' | 'removed' | 'updated' | undefined = useMemo(() => { + if (!withDiff) { + return undefined; + } + + if (isNotDefined(prevValue) && isDefined(value)) { + return 'added'; + } + + if (isNotDefined(value) && isDefined(prevValue)) { + return 'removed'; + } + + if (isDefined(valueType) && value !== prevValue) { + return 'updated'; + } + + return undefined; + }, [withDiff, value, prevValue, valueType]); + + const spacingOffset = -3; + + const spacingClassName = useSpacingToken({ + spacing, + offset: spacingOffset, + modes: withPadding ? fullSpacings : gapSpacings, + }); + + const innerPaddingClassName = useSpacingToken({ + spacing, + offset: spacingOffset, + modes: paddingSpacings, + }); + + return ( +
+ {label && ( +
+ {label} +
+ )} + {(valueComponent || prevValueComponent) && ( +
+ {isNotDefined(diffType) && valueComponent} + {isDefined(diffType) && ( + + {diffType === 'updated' && ( + {prevValueComponent} + )} + + {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..0dbe6c14e5 --- /dev/null +++ b/app/src/components/printable/PrintableDataDisplay/styles.module.css @@ -0,0 +1,59 @@ +.printable-data-display { + &.inline-variant { + display: flex; + flex-direction: row; + } + + &.with-diff-view { + border: thick double var(--go-ui-color-red); + } + + &.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; + } + + .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); + } + } + + .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..25a1f86c67 --- /dev/null +++ b/app/src/components/printable/PrintableDescription/index.tsx @@ -0,0 +1,25 @@ +import DiffTextOutput from '../DiffTextOutput'; + +interface Props { + value?: string | null; + withDiff?: boolean; + prevValue?: string | null; +} + +function PrintableDescription(props: Props) { + const { + value, + prevValue, + withDiff, + } = props; + + return ( + + ); +} + +export default PrintableDescription; diff --git a/app/src/components/printable/PrintableLabel/index.tsx b/app/src/components/printable/PrintableLabel/index.tsx new file mode 100644 index 0000000000..d0f83a0a42 --- /dev/null +++ b/app/src/components/printable/PrintableLabel/index.tsx @@ -0,0 +1,25 @@ +import DiffTextOutput from '../DiffTextOutput'; + +interface Props { + value?: string | null; + withDiff?: boolean; + prevValue?: string | null; +} + +function PrintableLabel(props: Props) { + const { + value, + prevValue, + withDiff, + } = props; + + return ( + + ); +} + +export default PrintableLabel; diff --git a/app/src/components/printable/PrintablePage/index.tsx b/app/src/components/printable/PrintablePage/index.tsx new file mode 100644 index 0000000000..2cfc8161d7 --- /dev/null +++ b/app/src/components/printable/PrintablePage/index.tsx @@ -0,0 +1,100 @@ +import React, { + useEffect, + 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; + mainRef: React.RefObject; +} + +function PrintablePage(props: Props) { + const { + className, + children, + heading, + description, + dataReady = false, + mainRef, + } = props; + + const [previewReady, setPreviewReady] = useState(false); + + 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, mainRef]); + + return ( +
+
+ IFRC + + {heading} + +
+ {description} +
+
+ {children} + {previewReady &&
} +
+ ); +} + +export default PrintablePage; diff --git a/app/src/components/printable/PrintablePage/styles.module.css b/app/src/components/printable/PrintablePage/styles.module.css new file mode 100644 index 0000000000..06f063c82c --- /dev/null +++ b/app/src/components/printable/PrintablePage/styles.module.css @@ -0,0 +1,38 @@ +.printable-page { + font-family: 'Open Sans', sans-serif; + font-size: var(--go-ui-font-size-export); + + @media screen { + margin: var(--go-ui-spacing-xl) auto; + background-color: var(--go-ui-color-foreground); + padding: var(--go-ui-export-page-margin); + width: 210mm; + min-height: 297mm; + } + + .header-section { + display: grid; + grid-template-areas: + "logo heading" + "desc desc"; + margin-block-end: var(--go-ui-spacing-xl); + + .ifrc-logo { + width: 6rem; + height: 6rem; + grid-area: logo; + } + + .heading { + text-align: end; + grid-area: heading; + } + + .description { + grid-area: desc; + text-align: end; + color: var(--go-ui-color-primary-blue); + font-size: var(--go-ui-font-size-lg); + } + } +} diff --git a/app/src/utils/common.ts b/app/src/utils/common.ts index 6210ae901c..855251944c 100644 --- a/app/src/utils/common.ts +++ b/app/src/utils/common.ts @@ -1,6 +1,10 @@ import { type Language } from '@ifrc-go/ui/contexts'; import { DEFAULT_INVALID_TEXT } from '@ifrc-go/ui/utils'; -import { isTruthyString } from '@togglecorp/fujs'; +import { + isDefined, + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; import type { GoApiResponse } from '#utils/restRequest'; @@ -68,3 +72,32 @@ export function joinStrings( ): string { return values.filter(Boolean).join(separator); } + +export function formatSourceLink(value: string | undefined): string | undefined { + if ( + isNotDefined(value) + || value.startsWith('http://') + || value.startsWith('https://') + || value === 'h' + || value === 'ht' + || value === 'htt' + || value === 'http' + || value === 'http:' + || value === 'http:/' + || value === 'https' + || value === 'https:' + || value === 'https:/' + ) { + return value; + } + + return `https://${value}`; +} + +export function lengthSmallerOrEqualToCondition(x?: number) { + return (value: string | undefined) => ( + (isDefined(value) && isDefined(x)) && value.length > x + ? `Length must be smaller or equal to ${x}` + : undefined + ); +} diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 93b6b00878..92c3646975 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,24 @@ 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_PENDING_PFA = 50 satisfies EapStatus; +export const EAP_STATUS_APPROVED = 60 satisfies EapStatus; diff --git a/app/src/utils/domain/eap.ts b/app/src/utils/domain/eap.ts new file mode 100644 index 0000000000..32ec7d334f --- /dev/null +++ b/app/src/utils/domain/eap.ts @@ -0,0 +1,15 @@ +import { isNotDefined } from '@togglecorp/fujs'; + +export function getFullDateFromYearMonth(val: string | undefined) { + if (isNotDefined(val)) { + return undefined; + } + return `${val}-01`; +} + +export function getYearMonthFromFullDate(val: string | undefined) { + if (isNotDefined(val)) { + return undefined; + } + return val?.slice(0, 7); +} diff --git a/app/src/views/AccountMyFormsEap/EapShareActions/i18n.json b/app/src/views/AccountMyFormsEap/EapShareActions/i18n.json new file mode 100644 index 0000000000..b2b3aa79ad --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapShareActions/i18n.json @@ -0,0 +1,6 @@ +{ + "namespace":"accountMyFormsEap", + "strings":{ + "shareDropDownLabel": "Share" + } +} diff --git a/app/src/views/AccountMyFormsEap/EapShareActions/index.tsx b/app/src/views/AccountMyFormsEap/EapShareActions/index.tsx new file mode 100644 index 0000000000..f09a0a6d2e --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapShareActions/index.tsx @@ -0,0 +1,62 @@ +import { useCallback } from 'react'; +import { ShareLineIcon } from '@ifrc-go/icons'; +import { + type ButtonProps, + TableActions, +} from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; + +import EapShareModal from '#components/domain/EapShareModal'; +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +export interface Props { + id: number; + disabled?: boolean; +} + +function EapShareActions(props: Props) { + const { id, disabled } = props; + + const strings = useTranslation(i18n); + + const [ + showShareModal, + { setTrue: setShowShareModalTrue, setFalse: setShowShareModalFalse }, + ] = useBooleanState(false); + + const handleShareClick: NonNullable['onClick']> = useCallback(() => { + setShowShareModalTrue(); + }, [setShowShareModalTrue]); + + return ( + <> + } + onClick={handleShareClick} + disabled={disabled} + > + {strings.shareDropDownLabel} + + )} + /> + {showShareModal && ( + + )} + + ); +} + +export default EapShareActions; diff --git a/app/src/views/AccountMyFormsEap/EapStatus/i18n.json b/app/src/views/AccountMyFormsEap/EapStatus/i18n.json new file mode 100644 index 0000000000..1e945db054 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapStatus/i18n.json @@ -0,0 +1,16 @@ +{ + "namespace":"accountMyFormsEap", + "strings":{ + "statusUpdateSuccessAlert": "Status updated successfully!", + "statusUpdateFailedAlert": "Failed to update the status!", + "updateStatusHeading": "Update Status", + "confirmStatusButtonLabel": "Confirm", + "updateStatusDescription": "Are you sure you want to update the status?", + "reviewChecklistDescription": "Upload the Review Checklist for the National Society to download and review. Make sure to keep a proper labeling of the file to avoid duplications.", + "editSimplifiedEapFormLinkLabel": "Edit sEAP", + "editFullEapFormLinkLabel": "Edit Full EAP", + "reviewChecklistInputLabel": "Select review checklist file", + "submitFormErrorMessage": "Please make sure you've filled in all the required details before proceeding!", + "noBudgetAlertTitle": "Please attach the validated budget file before proceeding!" + } +} diff --git a/app/src/views/AccountMyFormsEap/EapStatus/index.tsx b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx new file mode 100644 index 0000000000..6ecb4835f5 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx @@ -0,0 +1,290 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + ArrowRightFillIcon, + UploadLineIcon, +} from '@ifrc-go/icons'; +import { + Alert, + Button, + Description, + DropdownMenu, + Label, + ListView, + Modal, + RawFileInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import Link from '#components/Link'; +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useAlert from '#hooks/useAlert'; +import { + EAP_STATUS_APPROVED, + EAP_STATUS_NS_ADDRESSING_COMMENTS, + EAP_STATUS_PENDING_PFA, + EAP_STATUS_TECHNICALLY_VALIDATED, + EAP_STATUS_UNDER_DEVELOPMENT, + EAP_STATUS_UNDER_REVIEW, + EAP_TYPE_FULL, + EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; +import { + type GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; +import { type ResponseObjectError } from '#utils/restRequest/error'; + +import { type EapListItem } from '../utils'; + +import i18n from './i18n.json'; + +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_NS_ADDRESSING_COMMENTS, + EAP_STATUS_PENDING_PFA, + ], + [EAP_STATUS_PENDING_PFA]: [EAP_STATUS_APPROVED], + [EAP_STATUS_APPROVED]: [], +}; + +export interface Props { + eapId: number; + status: EapStatus; + onStatusUpdate?: () => void; + hasValidatedBudgetFile?: boolean; + details: EapListItem; +} + +function EapStatus(props: Props) { + const { + eapId, + status, + onStatusUpdate, + hasValidatedBudgetFile, + details, + } = props; + + const simplifiedEapDetails = details.simplified_eap_details; + const fullEapDetails = details.full_eap_details; + + const alert = useAlert(); + + const { eap_eap_status: eapStatusOptions } = useGlobalEnums(); + const [newStatus, setNewStatus] = useState(); + const [checklistFile, setChecklistFile] = useState(); + const [responseFormErrors, setResponseFormErrors] = useState(); + + const strings = useTranslation(i18n); + + 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( + strings.statusUpdateSuccessAlert, + { variant: 'success' }, + ); + }, + formData: true, + onFailure: (error) => { + const { + value: { formErrors, messageForNotification }, + } = error; + + if (isDefined(formErrors)) { + setResponseFormErrors(formErrors); + } + + alert.show( + strings.statusUpdateFailedAlert, + { + variant: 'danger', + description: messageForNotification, + }, + ); + }, + }); + + // FIXME: fix typings in the server + const requestBody = useMemo( + () => ({ + status: newStatus, + review_checklist_file: checklistFile, + } as EapStatusBody), + [newStatus, checklistFile], + ); + + const handleStatusUpdateCancel = useCallback(() => { + setNewStatus(undefined); + }, []); + + const confirmDisabled = ( + (newStatus === EAP_STATUS_NS_ADDRESSING_COMMENTS && isNotDefined(checklistFile)) + || (newStatus === EAP_STATUS_PENDING_PFA && !hasValidatedBudgetFile) + || isDefined(responseFormErrors) + ); + + return ( + <> + + {eapStatusOptions?.map((option) => ( + + {option.value} + + ))} + + {isDefined(newStatus) && ( + + {details.eap_type === EAP_TYPE_SIMPLIFIED && responseFormErrors && ( + + {strings.editSimplifiedEapFormLinkLabel} + + )} + {details.eap_type === EAP_TYPE_FULL && responseFormErrors && ( + + {strings.editFullEapFormLinkLabel} + + )} + + + )} + > + + + {strings.updateStatusDescription} + + + + + + + {newStatus === EAP_STATUS_NS_ADDRESSING_COMMENTS && ( + + + {strings.reviewChecklistDescription} + + } + accept=".pdf, .docx, .pptx, .xlsx, .xlsm" + > + {strings.reviewChecklistInputLabel} + + + + )} + {isDefined(responseFormErrors) && ( + + )} + {newStatus === EAP_STATUS_PENDING_PFA && !hasValidatedBudgetFile && ( + + )} + + + )} + + ); +} + +export default EapStatus; diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/i18n.json b/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/i18n.json new file mode 100644 index 0000000000..544ab68ac1 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/i18n.json @@ -0,0 +1,12 @@ +{ + "namespace":"accountMyFormsEap", + "strings":{ + "newBudgetFileUploadLabel": "Upload validated budget", + "updateBudgetFileLabel": "Update validated budget", + "uploadFileDescription": "Please select the validate budget file to be uploaded!", + "fileInputLabel": "Select a file to upload", + "confirmButtonLabel": "Confirm", + "uploadSuccessfulMessage": "Budget file uploaded successfully!", + "uploadFailureMessage": "Failed to upload the budget file!" + } +} diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/index.tsx b/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/index.tsx new file mode 100644 index 0000000000..de9faa01dc --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/BudgetFileInput/index.tsx @@ -0,0 +1,139 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { UploadLineIcon } from '@ifrc-go/icons'; +import { + Button, + Description, + Label, + ListView, + Modal, + RawFileInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import useAlert from '#hooks/useAlert'; +import { + type GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type UploadBudgetFileBody = GoApiBody<'/api/v2/eap-registration/{id}/upload-validated-budget-file/', 'POST'>; + +interface Props { + eapId: number; + onBudgetFileUpload?: () => void; + hasBudgetFile?: boolean; +} + +function BudgetFileInput(props: Props) { + const { + eapId, + onBudgetFileUpload, + hasBudgetFile, + } = props; + + const strings = useTranslation(i18n); + + const alert = useAlert(); + + const [budgetFile, setBudgetFile] = useState(); + const [showUploadModal, setShowUploadModal] = useState(false); + + const { trigger: triggerUploadBudget } = useLazyRequest({ + method: 'POST', + url: '/api/v2/eap-registration/{id}/upload-validated-budget-file/', + pathVariables: { + id: eapId, + }, + body: (fields: UploadBudgetFileBody) => fields, + onSuccess: () => { + if (onBudgetFileUpload) { + onBudgetFileUpload(); + } + + alert.show( + strings.uploadSuccessfulMessage, + { variant: 'success' }, + ); + + setBudgetFile(undefined); + setShowUploadModal(false); + }, + formData: true, + onFailure: (error) => { + alert.show( + strings.uploadFailureMessage, + { + variant: 'danger', + description: error.value.messageForNotification, + }, + ); + }, + }); + + // FIXME: fix typings in the server + const requestBody = useMemo( + () => ({ + validated_budget_file: budgetFile, + } as unknown as UploadBudgetFileBody), + [budgetFile], + ); + + const handleUploadModalClose = useCallback(() => { + setShowUploadModal(false); + setBudgetFile(undefined); + }, []); + + return ( + <> + + {showUploadModal && ( + + {strings.confirmButtonLabel} + + )} + > + + + {strings.uploadFileDescription} + + } + > + {strings.fileInputLabel} + + + + + )} + + ); +} + +export default BudgetFileInput; diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json b/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json new file mode 100644 index 0000000000..1e4f9f11b6 --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/i18n.json @@ -0,0 +1,29 @@ +{ + "namespace":"accountMyFormsEap", + "strings":{ + "startFullEapLinkLabel": "Start Full EAP", + "startSimplifiedEapLinkLabel": "Start sEAP", + "editFullEapLinkLabel": "Edit Full EAP", + "viewFullEapLinkLabel": "View Full EAP", + "exportWithChangesButtonLabel": "Export v{version} PDF (with track changes)", + "exportButtonLabel": "Export", + "exportSummaryButtonLabel": "Export Summary", + "previewExportLinkLabel": "Preview Export", + "previewSummaryExportLinkLabel": "Preview Summary Export", + "downloadReviewChecklistLinkLabel": "Review Checklist v{version} (with IFRC comments)", + "downloadUpdatedChecklistLinkLabel": "Review Checklist v{version} (with NS comments)", + "downloadBudgetFileLabel": "Budget v{version}", + "editSimplifiedEapLinkLabel": "Edit sEAP", + "viewSimplifiedEapLinkLabel": "View sEAP", + "reviseEapLabel": "Revise EAP", + "reviseEapMessage": "Revising this EAP will create a new version. You can make any necessary changes in the new version.", + "downloadValidatedBudgetLinkLabel": "Download Validated Budget", + "additionalFilesButtonLabel": "Additional Attachments", + "theoryOfChangeTableLinkLabel": "Theory of Change Table", + "fullReviseSuccessAlert": "Full EAP revised successfully", + "fullReviseFailedAlert": "Full EAP revision failed", + "simplifiedReviseSuccessAlert": "Simplified EAP revised successfully", + "simplifiedReviseFailedAlert": "Simplified EAP revision failed", + "forecastTableLinkLabel": "Forecast Table" + } +} diff --git a/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx b/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx new file mode 100644 index 0000000000..811af6c20f --- /dev/null +++ b/app/src/views/AccountMyFormsEap/EapTableActions/index.tsx @@ -0,0 +1,523 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + DocumentPdfLineIcon, + DownloadTwoLineIcon, +} from '@ifrc-go/icons'; +import { + Button, + ConfirmButton, + ListView, + Modal, +} from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import EapExportModal from '#components/domain/EapExportModal'; +import Link from '#components/Link'; +import { environment } from '#config'; +import useAlert from '#hooks/useAlert'; +import useRouting from '#hooks/useRouting'; +import { + EAP_STATUS_NS_ADDRESSING_COMMENTS, + EAP_STATUS_PENDING_PFA, + EAP_STATUS_TECHNICALLY_VALIDATED, + EAP_STATUS_UNDER_DEVELOPMENT, + EAP_TYPE_FULL, + EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; +import { useLazyRequest } from '#utils/restRequest'; + +import { type EapExpandedListItem } from '../utils'; +import BudgetFileInput from './BudgetFileInput'; + +import i18n from './i18n.json'; + +export interface Props { + expandedListItem: EapExpandedListItem; + onUpdate?: () => void; +} + +function EapTableActions(props: Props) { + const { + expandedListItem, + onUpdate, + } = props; + + const { + type, + eap, + details, + } = expandedListItem; + + const [exportWithDiffView, setExportWithDiffView] = useState(false); + const [summaryExport, setSummaryExport] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + const [ + showAdditionalFileModal, + { + setTrue: setShowAdditionalFileModalTrue, + setFalse: setShowAdditionalFileModalFalse, + }, + ] = useBooleanState(false); + + const alert = useAlert(); + const { navigate } = useRouting(); + + const strings = useTranslation(i18n); + + const setShowExportModalTrue = useCallback((withDiff?: boolean) => { + if (type === 'pending-pfa') { + setSummaryExport(true); + } + setExportWithDiffView(!!withDiff); + setShowExportModal(true); + }, [type]); + + const setShowExportModalFalse = useCallback(() => { + setSummaryExport(false); + setExportWithDiffView(false); + setShowExportModal(false); + }, []); + + const latestId = useMemo(() => { + if (eap.eap_type === EAP_TYPE_SIMPLIFIED) { + return eap.latest_simplified_eap ?? undefined; + } + + if (eap.eap_type === EAP_TYPE_FULL) { + return eap.latest_full_eap ?? undefined; + } + + return undefined; + }, [eap]); + + const { + trigger: reviseSEAP, + pending: reviseSEAPPending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/simplified-eap/{id}/revise/', + pathVariables: isDefined(latestId) ? { id: latestId } : undefined, + body: () => ({} as never), + onSuccess: () => { + alert.show( + strings.simplifiedReviseSuccessAlert, + { variant: 'success' }, + ); + navigate( + 'simplifiedEapForm', + { params: { eapId: eap.id } }, + ); + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.simplifiedReviseFailedAlert, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + const { + trigger: reviseFullEAP, + pending: reviseFullEAPPending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/full-eap/{id}/revise/', + pathVariables: isDefined(latestId) ? { id: latestId } : undefined, + body: () => ({} as never), + onSuccess: () => { + alert.show( + strings.fullReviseSuccessAlert, + { variant: 'success' }, + ); + navigate( + 'fullEapForm', + { params: { eapId: eap.id } }, + ); + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.fullReviseFailedAlert, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + const handleReviseClick = useCallback( + () => { + if (eap.eap_type === EAP_TYPE_SIMPLIFIED) { + reviseSEAP(null); + } + + if (eap.eap_type === EAP_TYPE_FULL) { + reviseFullEAP(null); + } + }, + [ + eap.eap_type, + reviseSEAP, + reviseFullEAP, + ], + ); + + const latestVersion = useMemo(() => { + if (eap.eap_type === EAP_TYPE_SIMPLIFIED) { + return eap.simplified_eap_details.find(({ id }) => latestId === id)?.version; + } + + if (eap.eap_type === EAP_TYPE_FULL) { + return eap.full_eap_details.find(({ id }) => latestId === id)?.version; + } + + return undefined; + }, [eap, latestId]); + + const isCreated = isDefined(latestId); + const isLocked = isDefined(details) && !!details.data.is_locked; + + const isLatestVersion = useMemo(() => { + if (eap.eap_type === EAP_TYPE_SIMPLIFIED) { + return eap.latest_simplified_eap === details?.data.id; + } + + if (eap.eap_type === EAP_TYPE_FULL) { + return eap.latest_full_eap === details?.data.id; + } + + return false; + }, [eap, details]); + + const isEditable = useMemo(() => { + if (isCreated && !isLatestVersion) { + return false; + } + + if (isLocked) { + return false; + } + + if (eap.status !== EAP_STATUS_UNDER_DEVELOPMENT + && eap.status !== EAP_STATUS_NS_ADDRESSING_COMMENTS) { + return false; + } + + return true; + }, [isCreated, isLatestVersion, isLocked, eap]); + + const isRevised = useMemo(() => { + if (!isLatestVersion) { + return false; + } + if (!isLocked) { + return false; + } + if (eap.status !== EAP_STATUS_NS_ADDRESSING_COMMENTS) { + return false; + } + return true; + }, [eap, isLocked, isLatestVersion]); + + return ( + + {type === 'registration' && isNotDefined(eap.eap_type) && isNotDefined(details) && ( + + + {strings.startSimplifiedEapLinkLabel} + + + {strings.startFullEapLinkLabel} + + + )} + {type === 'development' && ( + <> + {environment === 'development' && eap.eap_type === EAP_TYPE_SIMPLIFIED && isCreated && ( + } + > + {strings.previewExportLinkLabel} + + )} + {environment === 'development' && eap.eap_type === EAP_TYPE_FULL && isCreated && ( + } + > + {strings.previewExportLinkLabel} + + )} + {details?.eapType === EAP_TYPE_FULL + && (isDefined(details?.data.theory_of_change_table_file_details) + && isDefined(details?.data.forecast_table_file_details)) + && ( + + )} + {isDefined(details?.data.version) + && details.data.version > 1 + && ( + + )} + {isDefined(details?.data?.review_checklist_file) && ( + } + > + {resolveToString( + strings.downloadReviewChecklistLinkLabel, + { + version: details.data.version, + }, + )} + + )} + {isDefined(details?.data?.updated_checklist_file_details?.file) + && isDefined(details.data.version) && ( + } + > + {resolveToString( + strings.downloadUpdatedChecklistLinkLabel, + { + version: details.data.version - 1, + }, + )} + + )} + {isDefined(details?.data.budget_file_details) && ( + } + > + {resolveToString( + strings.downloadBudgetFileLabel, + { + version: details.data.version, + }, + )} + + )} + {isRevised && ( + + {strings.reviseEapLabel} + + )} + {eap.eap_type === EAP_TYPE_SIMPLIFIED && isEditable && ( + + {strings.editSimplifiedEapLinkLabel} + + )} + {eap.eap_type === EAP_TYPE_SIMPLIFIED && !isEditable && ( + + {strings.viewSimplifiedEapLinkLabel} + + )} + {eap.eap_type === EAP_TYPE_FULL && isEditable && ( + + {strings.editFullEapLinkLabel} + + )} + {eap.eap_type === EAP_TYPE_FULL && !isEditable && ( + + {strings.viewFullEapLinkLabel} + + )} + + )} + {type === 'validated' && ( + <> + {isDefined(eap.validated_budget_file) && ( + } + > + {strings.downloadValidatedBudgetLinkLabel} + + )} + {eap.status === EAP_STATUS_TECHNICALLY_VALIDATED && ( + + )} + + )} + {type === 'pending-pfa' && eap.status >= EAP_STATUS_PENDING_PFA && ( + + + {eap.eap_type === EAP_TYPE_FULL && ( + <> + } + > + {strings.previewSummaryExportLinkLabel} + + + + )} + + )} + {showExportModal && isDefined(eap.eap_type) && ( + + )} + {showAdditionalFileModal && details?.eapType === EAP_TYPE_FULL && ( + + + {isDefined(details?.data.theory_of_change_table_file_details) && ( + } + > + {strings.theoryOfChangeTableLinkLabel} + + )} + {isDefined(details?.data.forecast_table_file_details) && ( + } + > + {strings.forecastTableLinkLabel} + + )} + + + )} + + ); +} + +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..2585e83fbd --- /dev/null +++ b/app/src/views/AccountMyFormsEap/index.tsx @@ -0,0 +1,374 @@ +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, + isTruthyString, + listToMap, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { + EAP_STATUS_APPROVED, + EAP_STATUS_PENDING_PFA, + EAP_STATUS_TECHNICALLY_VALIDATED, + EAP_TYPE_FULL, + EAP_TYPE_SIMPLIFIED, +} from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import EapShareActions, { type Props as EapShareActionsProps } from './EapShareActions'; +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'; +import styles from './styles.module.css'; + +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', + strings.eapStatus, + EapStatus, + (key, row) => ({ + eapId: key, + status: row.status, + hasValidatedBudgetFile: isTruthyString(row.validated_budget_file), + onStatusUpdate: reloadEapList, + eapType: row.eap_type, + details: row, + }), + { columnClassName: styles.status }, + ), + createElementColumn( + 'actions', + '', + EapShareActions, + (_, item) => ({ + id: item.id, + disabled: (item.eap_type === EAP_TYPE_FULL + && isNotDefined(item.latest_full_eap)) + || (item.eap_type === EAP_TYPE_SIMPLIFIED + && isNotDefined(item.latest_simplified_eap)), + }), + + ), + 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', + lastUpdated: eapListItem.technically_validated_at ?? undefined, + details: undefined, + disabled: status < EAP_STATUS_TECHNICALLY_VALIDATED, + } satisfies EapExpandedListItem, + { + label: 'Approved (Pending PFA)', + lastUpdated: eapListItem.pending_pfa_at ?? undefined, + eap: eapListItem, + type: 'pending-pfa', + details: undefined, + disabled: status < EAP_STATUS_PENDING_PFA, + } satisfies EapExpandedListItem, + { + label: 'Approved', + eap: eapListItem, + lastUpdated: eapListItem.approved_at ?? undefined, + type: 'approved', + details: undefined, + disabled: status < EAP_STATUS_APPROVED, + } 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, + onUpdate: reloadEapList, + }), + ), + createEmptyColumn(), + createEmptyColumn(), + ]), + [strings.eapLastUpdated, reloadEapList], + ); + + 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/styles.module.css b/app/src/views/AccountMyFormsEap/styles.module.css new file mode 100644 index 0000000000..f3760413eb --- /dev/null +++ b/app/src/views/AccountMyFormsEap/styles.module.css @@ -0,0 +1,3 @@ +.status { + min-width: 18rem; +} diff --git a/app/src/views/AccountMyFormsEap/utils.ts b/app/src/views/AccountMyFormsEap/utils.ts new file mode 100644 index 0000000000..c12eda4c8e --- /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' | 'validated' | 'pending-pfa' | 'approved'; + 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..992e8f5685 100644 --- a/app/src/views/AccountMyFormsLayout/i18n.json +++ b/app/src/views/AccountMyFormsLayout/i18n.json @@ -3,7 +3,8 @@ "strings": { "fieldReportTabTitle": "Field Report", "perTabTitle": "PER", - "drefTabTitle": "DREF", - "threeWTabTitle": "3W" + "drefTabTitle": "DREF Applications", + "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/Overview/index.tsx b/app/src/views/DrefApplicationForm/Overview/index.tsx index 91cef6d20e..95299448fa 100644 --- a/app/src/views/DrefApplicationForm/Overview/index.tsx +++ b/app/src/views/DrefApplicationForm/Overview/index.tsx @@ -47,11 +47,11 @@ import CountrySelectInput from '#components/domain/CountrySelectInput'; import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; import DrefShareModal from '#components/domain/DrefShareModal'; -import UserItem from '#components/domain/DrefShareModal/UserItem'; import { type FieldReportItem as FieldReportSearchItem } from '#components/domain/FieldReportSearchSelectInput'; import GoSingleFileInput from '#components/domain/GoSingleFileInput'; import ImageWithCaptionInput from '#components/domain/ImageWithCaptionInput'; import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput'; +import ShareUserItem from '#components/domain/ShareUserItem'; import { type User } from '#components/domain/UserSearchMultiSelectInput'; import Link from '#components/Link'; import TabPage from '#components/TabPage'; @@ -290,7 +290,7 @@ function Overview(props: Props) { > diff --git a/app/src/views/DrefApplicationForm/index.tsx b/app/src/views/DrefApplicationForm/index.tsx index ca7344daf4..f564c84a0b 100644 --- a/app/src/views/DrefApplicationForm/index.tsx +++ b/app/src/views/DrefApplicationForm/index.tsx @@ -311,6 +311,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/DrefFinalReportForm/Overview/index.tsx b/app/src/views/DrefFinalReportForm/Overview/index.tsx index a5602f15cb..295b0d167b 100644 --- a/app/src/views/DrefFinalReportForm/Overview/index.tsx +++ b/app/src/views/DrefFinalReportForm/Overview/index.tsx @@ -40,9 +40,9 @@ import CountrySelectInput from '#components/domain/CountrySelectInput'; import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; import DrefShareModal from '#components/domain/DrefShareModal'; -import UserItem from '#components/domain/DrefShareModal/UserItem'; import ImageWithCaptionInput from '#components/domain/ImageWithCaptionInput'; import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput'; +import ShareUserItem from '#components/domain/ShareUserItem'; import { type User } from '#components/domain/UserSearchMultiSelectInput'; import Link from '#components/Link'; import useCountry from '#hooks/domain/useCountry'; @@ -229,7 +229,7 @@ function Overview(props: Props) { > diff --git a/app/src/views/DrefOperationalUpdateForm/Overview/index.tsx b/app/src/views/DrefOperationalUpdateForm/Overview/index.tsx index d693470245..e8ce1f7ad4 100644 --- a/app/src/views/DrefOperationalUpdateForm/Overview/index.tsx +++ b/app/src/views/DrefOperationalUpdateForm/Overview/index.tsx @@ -46,9 +46,9 @@ import CountrySelectInput from '#components/domain/CountrySelectInput'; import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; import DrefShareModal from '#components/domain/DrefShareModal'; -import UserItem from '#components/domain/DrefShareModal/UserItem'; import ImageWithCaptionInput from '#components/domain/ImageWithCaptionInput'; import NationalSocietySelectInput from '#components/domain/NationalSocietySelectInput'; +import ShareUserItem from '#components/domain/ShareUserItem'; import { type User } from '#components/domain/UserSearchMultiSelectInput'; import Link from '#components/Link'; import useCountry from '#hooks/domain/useCountry'; @@ -281,7 +281,7 @@ function Overview(props: Props) { > 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/EapFullExport/PrintableContactOutput/index.tsx b/app/src/views/EapFullExport/PrintableContactOutput/index.tsx new file mode 100644 index 0000000000..145208e619 --- /dev/null +++ b/app/src/views/EapFullExport/PrintableContactOutput/index.tsx @@ -0,0 +1,75 @@ +import { isTruthyString } from '@togglecorp/fujs'; + +import PrintableContainer from '#components/printable/PrintableContainer'; +import PrintableDataDisplay from '#components/printable/PrintableDataDisplay'; +import { type GoApiResponse } from '#utils/restRequest'; + +type FullEap = GoApiResponse<'/api/v2/full-eap/{id}/'>; +type FieldKeys = keyof FullEap; + +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 + +type ValidContactFieldPrefixes = ExtractContactPrefix; + +interface Props { + label: React.ReactNode; + namePrefix: ValidContactFieldPrefixes; + data: FullEap | undefined | null; + prevData: FullEap | undefined | null; + withDiff: boolean; +} + +function PrintableContactOutput(props: Props) { + const { + label, + namePrefix, + data, + prevData, + withDiff, + } = props; + + const nameKey = `${namePrefix}_name` satisfies FieldKeys; + const titleKey = `${namePrefix}_title` satisfies FieldKeys; + const emailKey = `${namePrefix}_email` satisfies FieldKeys; + const phoneNumberKey = `${namePrefix}_phone_number` satisfies FieldKeys; + + const value = [ + data?.[nameKey], + data?.[titleKey], + data?.[emailKey], + data?.[phoneNumberKey], + ].filter(isTruthyString).join(', '); + + const prevValue = [ + prevData?.[nameKey], + prevData?.[titleKey], + prevData?.[emailKey], + prevData?.[phoneNumberKey], + ].filter(isTruthyString).join(', '); + + return ( + + + + ); +} + +export default PrintableContactOutput; diff --git a/app/src/views/EapFullExport/TableOfContents/index.tsx b/app/src/views/EapFullExport/TableOfContents/index.tsx new file mode 100644 index 0000000000..a7c7c4790e --- /dev/null +++ b/app/src/views/EapFullExport/TableOfContents/index.tsx @@ -0,0 +1,82 @@ +import { + type RefObject, + useEffect, + useState, +} from 'react'; +import { randomString } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +type HeadingData = { + id: string; + text: string; + level: number; + children?: HeadingData[]; +} + +interface Props { + mainRef: RefObject; +} + +function TableOfContents(props: Props) { + const { mainRef } = props; + const [headings, setHeadings] = useState([]); + + useEffect(() => { + const contentElement = mainRef.current; + + if (!contentElement) return; + + const elements = Array.from(contentElement.querySelectorAll('h2, h3')); + + const headingList: HeadingData[] = []; + + elements.forEach((elem) => { + const level = Number(elem.tagName.substring(1)); + + if (level === 2) { + headingList.push({ + id: randomString(), + text: elem.textContent || '', + level, + }); + } + + const lastHeadingData = headingList[headingList.length - 1]; + if (level === 3 && lastHeadingData) { + if (!lastHeadingData.children) { + lastHeadingData.children = []; + } + + lastHeadingData.children?.push({ + id: randomString(), + text: elem.textContent || '', + level, + }); + } + }); + + setHeadings(headingList); + }, [mainRef]); + + return ( +
    + {headings.map((heading) => ( +
  1. + {heading.text} + {heading.children && ( +
      + {heading.children?.map((subHeading) => ( +
    1. + {subHeading.text} +
    2. + ))} +
    + )} +
  2. + ))} +
+ ); +} + +export default TableOfContents; diff --git a/app/src/views/EapFullExport/TableOfContents/styles.module.css b/app/src/views/EapFullExport/TableOfContents/styles.module.css new file mode 100644 index 0000000000..804e5fcf38 --- /dev/null +++ b/app/src/views/EapFullExport/TableOfContents/styles.module.css @@ -0,0 +1,41 @@ +.table-of-contents { + padding-inline-start: 0; + list-style: none; + counter-reset: section; + + > li { + margin: unset; + font-weight: var(--go-ui-font-weight-bold); + margin-block-end: var(--go-ui-spacing-xs); + counter-reset: subsection; + counter-increment: section; + + &::before { + content: counter(section) ". "; + } + } + + ol { + padding-inline-start: 0; + list-style: none; + + > li { + margin: unset; + counter-increment: subsection; + margin-block-start: var(--go-ui-spacing-3xs); + + &::before { + content: counter(section) "." counter(subsection) " "; + } + } + } + + .sub-section { + font-weight: var(--go-ui-font-weight-light); + } +} + + + + + diff --git a/app/src/views/EapFullExport/i18n.json b/app/src/views/EapFullExport/i18n.json new file mode 100644 index 0000000000..c6dcb3fe31 --- /dev/null +++ b/app/src/views/EapFullExport/i18n.json @@ -0,0 +1,98 @@ +{ + "namespace": "eapFullExport", + "strings": { + "summaryPageTitle": "Early Action Protocol Summary", + "hazardLabel": "Hazard", + "prioritizedImpactsSummaryLabel": "Prioritized risks/impacts to be addressed by early actions", + "earlyActionsSummaryLabel": "Proposed Early Actions by based on the IFRC’s Planned Operation Matrix", + "houseHoldsSummaryLabel": "Potential No. of households to be reached", + "eapBudgetSummaryLabel": "EAP budget", + "pageTitleFullText": "Full", + "pageTitleEapText": "Early Action Protocol", + "overviewHeading": "Overview", + "eapNoLabel": "EAP No", + "eapTimeframeLabel": "EAP Timeframe", + "eapApprovedLabel": "EAP Approved", + "objectiveLabel": "Objective", + "nationalLabel": "National", + "nationalSocietyContactLabel": "National Society Contact", + "partnerNationalSocietyContactLabel": "Partner National Society Contact", + "delegationLabel": "Delegation", + "delegationFocalLabel": "IFRC Delegation AA Focal Point", + "delegationHeadLabel": "IFRC Head of Delegation", + "regionalGlobalLabel": "Regional and Global", + "drefFocalLabel": "DREF Focal Point", + "regionalFocalLabel":"IFRC Regional AA Focal Point", + "regionalOpsLabel":"IFRC Regional Ops Manager", + "regionalHeadLabel":"IFRC Regional Head of DCC", + "globalOpsLabel":"IFRC Global Ops Coordinator", + "stakeholdersHeading":"Stakeholders", + "workWithGovernmentLabel":"Did you work with the government and other relevant actors in the development of this EAP? *", + "workWithGovernmentDescription":"Please briefly describe the process", + "keyActorsHeading": "Key Actors", + "technicalWorkingHeading": "Technical working groups in place", + "isTechnicalLabel": "Is there TWG in place?", + "workingDescriptionLabel": "Please briefly describe the process", + "riskAnalysisHeading": "Risk Analysis", + "hazardSelectionHeading": "Hazard Selection", + "exposedElementsLabel": "Exposed elements and their vulnerability factors", + "prioritizedImpactHeading": "Prioritized Impact", + "listPrioritizedImpactLabel": "List of prioritized impacts", + "triggerModelHeading": "Trigger Model", + "triggerStatementLabel": "Trigger Statement", + "leadTimeLabel": "Lead Time", + "forecastSelectionLabel": "Forecast Selection", + "downloadForecastTableLabel": "Download forecast table", + "definitionJustificationLabel": "Definition and justification of impact level", + "identificationInterventionLabel": "Identification of the intervention area", + "selectionOfActionHeading": "Selection of Actions", + "listEarlyActionsLabel": "List of Early Actions", + "earlySelectionLabel": "Early Action Selection Process", + "downloadTheoryChangeTableLabel": "Download Theory of Change Table", + "evidenceBaseLabel": "Evidence Base", + "usefulnessActionsLabel": "Usefulness of actions in case of non-occurring event", + "totalBudgetLabel": "Total Budget", + "downloadBudgetLabel": "Download Full Budget Template", + "feasibilityLabel": "Feasibility", + "activationProcessHeading": "EAP Activation Process", + "actionProcessLabel": "Early Action implementation process", + "triggerActivationLabel": "Trigger activation system", + "peopleTargetLabel": "People Targeted", + "selectionTargetLabel": "Selection of target population", + "stopMechanismLabel": "Stop Mechanism", + "sourceInformationLabel": "Sources of Information", + "sourceForecastLabel": "Sources of Forecast", + "nameLabel": "Name", + "linkLabel": "Link", + "descriptionLabel": "Description", + "titleLabel": "Title", + "mealHeading": "Monitoring, Evaluation, Accountability Learning(Meal)", + "mealLabel": "Meal", + "nationalSocietyHeading": "National Society Capacity", + "operationalThematicLabel": "Operational, thematic and administrative capacity", + "strategiesPlanLabel": "Strategies and plans", + "financialCapacityLabel": "Financial capacity to advance funds", + "financeLogisticsHeading": "Finance and Logistics", + "budgetDescriptionLabel": "Budget Description", + "totalReadinessLabel": "Total Readiness", + "readinessBudgetDescriptionLabel": "Readiness Budget Description", + "totalPrepositioningLabel": "Total Pre-positioning", + "prepositioningBudgetDescriptionLabel": "Pre-positioning Budget Description", + "totalEarlyActionsLabel": "Total Early Actions", + "earlyActionsBudgetDescriptionLabel": "Early Actions Budget Description", + "eapEndorsementLabel": "EAP Endorsement", + "plannedOperationsHeading": "Planned Operations", + "operationBudgetLabel": "Budget", + "apCodeLabel": "AP Code", + "operationPeopleTargetedLabel": "No. people targeted", + "indicatorsHeading": "Indicators", + "indicatorTitleLabel": "Title", + "indicatorTargetLabel": "Target", + "readinessActivitiesHeading": "Readiness activities", + "prepositioningActivitiesHeading": "Prepositioning activities", + "earlyActionActivitiesHeading": "Prioritized early actions", + "enablingApproachesLabel": "Enabling Approaches", + "contactInformationHeading": "Contact information", + "contactInformationDescription": "For further information, specifically related to this simplified EAP please contact:" + } +} diff --git a/app/src/views/EapFullExport/index.tsx b/app/src/views/EapFullExport/index.tsx new file mode 100644 index 0000000000..7355490d71 --- /dev/null +++ b/app/src/views/EapFullExport/index.tsx @@ -0,0 +1,1953 @@ +import { + useMemo, + useRef, +} from 'react'; +import { + useParams, + useSearchParams, +} from 'react-router-dom'; +import { ListView } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { Image } from '@ifrc-go/ui/printable'; +import { + isDefined, + isFalsyString, + isNotDefined, + isTruthyString, + listToMap, +} from '@togglecorp/fujs'; + +import PrintableActivityOutput from '#components/domain/PrintableActivityOutput'; +import Link from '#components/printable/Link'; +import PrintableContainer from '#components/printable/PrintableContainer'; +import PrintableDataDisplay from '#components/printable/PrintableDataDisplay'; +import PrintableDescription from '#components/printable/PrintableDescription'; +import PrintableLabel from '#components/printable/PrintableLabel'; +import PrintablePage from '#components/printable/PrintablePage'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { useRequest } from '#utils/restRequest'; + +import PrintableContactOutput from './PrintableContactOutput'; +import TableOfContents from './TableOfContents'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +/** @knipignore */ +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { eapId } = useParams<{ eapId: string }>(); + + const { pending: eapRegistrationPending, response: eapRegistrationResponse } = useRequest({ + skip: isFalsyString(eapId), + url: '/api/v2/eap-registration/{id}/', + pathVariables: isTruthyString(eapId) + ? { + id: Number(eapId), + } + : undefined, + }); + + const mainRef = useRef(null); + const [searchParams] = useSearchParams(); + + const strings = useTranslation(i18n); + + const version = searchParams.get('version') ?? undefined; + const withDiff = searchParams.get('diff')?.toLowerCase() === 'true'; + + const selectedFullEap = eapRegistrationResponse?.full_eap_details?.find( + (fullEap) => String(fullEap.version) === String(version), + ); + + const latestFullEapVersion = eapRegistrationResponse?.latest_full_eap; + const latestFullEap = eapRegistrationResponse?.full_eap_details?.find( + (fullEap) => fullEap.version === latestFullEapVersion, + ); + + const currentFullEap = selectedFullEap ?? latestFullEap; + const currentFullEapId = currentFullEap?.id; + + const prevFullEapVersion = isDefined(currentFullEap?.version) && currentFullEap.version > 1 + ? currentFullEap.version - 1 + : undefined; + + const prevFullEap = eapRegistrationResponse?.full_eap_details.find( + (fullEap) => fullEap.version === prevFullEapVersion, + ); + + const { pending: fullEapPending, response: fullEapResponse } = useRequest({ + skip: isNotDefined(currentFullEapId), + url: '/api/v2/full-eap/{id}/', + pathVariables: isDefined(currentFullEapId) + ? { + id: Number(currentFullEapId), + } + : undefined, + }); + + const { pending: prevFullEapPending, response: prevFullEapResponse } = useRequest({ + skip: isNotDefined(prevFullEap) || !withDiff, + url: '/api/v2/full-eap/{id}/', + pathVariables: isDefined(prevFullEap) + ? { + id: Number(prevFullEap.id), + } + : undefined, + }); + + const { response: apCodeOptions } = useRequest({ + url: '/api/v2/eap/options/', + }); + + 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, + approved_at, + } = eapRegistrationResponse ?? {}; + + const { + cover_image_file, + objective, + + partner_contacts, + + admin2_details, + is_worked_with_government, + worked_with_government_description, + key_actors, + is_technical_working_groups, + technically_working_group_title, + technical_working_groups_in_place_description, + + hazard_selection, + hazard_selection_images, + exposed_element_and_vulnerability_factor, + exposed_element_and_vulnerability_factor_images, + prioritized_impact, + prioritized_impact_images, + prioritized_impacts, + risk_analysis_source_of_information, + + trigger_statement, + trigger_statement_source_of_information, + lead_time, + forecast_selection, + forecast_selection_images, + forecast_table_file_details, + definition_and_justification_impact_level, + definition_and_justification_impact_level_images, + identification_of_the_intervention_area, + identification_of_the_intervention_area_images, + trigger_model_source_of_information, + + early_actions, + early_action_selection_process, + early_action_selection_process_images, + theory_of_change_table_file_details, + evidence_base, + evidence_base_source_of_information, + + planned_operations, + enabling_approaches, + usefulness_of_actions, + feasibility, + + early_action_implementation_process, + early_action_implementation_images, + trigger_activation_system, + trigger_activation_system_images, + people_targeted, + selection_of_target_population, + stop_mechanism, + activation_process_source_of_information, + + meal, + + operational_administrative_capacity, + strategies_and_plans, + advance_financial_capacity, + + total_budget, + budget_description, + budget_file_details, + readiness_budget, + readiness_cost_description, + pre_positioning_budget, + prepositioning_cost_description, + early_action_budget, + early_action_cost_description, + eap_endorsement, + } = fullEapResponse ?? {}; + + const { + objective: prev_objective, + + partner_contacts: prev_partner_contacts, + + is_worked_with_government: prev_is_worked_with_government, + worked_with_government_description: prev_worked_with_government_description, + key_actors: prev_key_actors, + is_technical_working_groups: prev_is_technical_working_groups, + technically_working_group_title: prev_technically_working_group_title, + technical_working_groups_in_place_description: + prev_technical_working_groups_in_place_description, + + hazard_selection: prev_hazard_selection, + exposed_element_and_vulnerability_factor: + prev_exposed_element_and_vulnerability_factor, + prioritized_impact: prev_prioritized_impact, + prioritized_impacts: prev_prioritized_impacts, + risk_analysis_source_of_information: + prev_risk_analysis_source_of_information, + + trigger_statement: prev_trigger_statement, + trigger_statement_source_of_information: + prev_trigger_statement_source_of_information, + lead_time: prev_lead_time, + forecast_selection: prev_forecast_selection, + definition_and_justification_impact_level: + prev_definition_and_justification_impact_level, + identification_of_the_intervention_area: + prev_identification_of_the_intervention_area, + trigger_model_source_of_information: + prev_trigger_model_source_of_information, + + early_actions: prev_early_actions, + early_action_selection_process: prev_early_action_selection_process, + evidence_base: prev_evidence_base, + evidence_base_source_of_information: + prev_evidence_base_source_of_information, + + planned_operations: prev_planned_operations, + enabling_approaches: prev_enabling_approaches, + usefulness_of_actions: prev_usefulness_of_actions, + feasibility: prev_feasibility, + + early_action_implementation_process: + prev_early_action_implementation_process, + trigger_activation_system: prev_trigger_activation_system, + people_targeted: prev_people_targeted, + selection_of_target_population: prev_selection_of_target_population, + stop_mechanism: prev_stop_mechanism, + activation_process_source_of_information: + prev_activation_process_source_of_information, + + meal: prev_meal, + + operational_administrative_capacity: + prev_operational_administrative_capacity, + strategies_and_plans: prev_strategies_and_plans, + advance_financial_capacity: prev_advance_financial_capacity, + + total_budget: prev_total_budget, + budget_description: prev_budget_description, + readiness_budget: prev_readiness_budget, + readiness_cost_description: prev_readiness_cost_description, + pre_positioning_budget: prev_pre_positioning_budget, + prepositioning_cost_description: prev_prepositioning_cost_description, + early_action_budget: prev_early_action_budget, + early_action_cost_description: prev_early_action_cost_description, + eap_endorsement: prev_eap_endorsement, + } = prevFullEapResponse ?? {}; + + const eapTitle = [ + country_details?.name, + admin2_details?.map(({ name }) => name).join(', '), + disaster_type_details?.name, + ] + .filter(isTruthyString) + .join(' | '); + + const prevKeyActorsMapping = useMemo( + () => listToMap(prev_key_actors ?? [], (actor) => actor.national_society), + [prev_key_actors], + ); + + const prevPartnerContactsMapping = useMemo( + () => listToMap(prev_partner_contacts ?? [], (partner) => partner.id!), + [prev_partner_contacts], + ); + + const prevRiskSourceInformationMapping = useMemo( + () => listToMap( + prev_risk_analysis_source_of_information ?? [], + (actor) => actor.id!, + ), + [prev_risk_analysis_source_of_information], + ); + + const prevTriggerStatementSourceInformationMapping = useMemo( + () => listToMap( + prev_trigger_statement_source_of_information ?? [], + (actor) => actor.id!, + ), + [prev_trigger_statement_source_of_information], + ); + + const prevTriggerModelSourceInformationMapping = useMemo( + () => listToMap( + prev_trigger_model_source_of_information ?? [], + (actor) => actor.id!, + ), + [prev_trigger_model_source_of_information], + ); + + const prevEvidenceBaseSourceInformationMapping = useMemo( + () => listToMap( + prev_evidence_base_source_of_information ?? [], + (actor) => actor.id!, + ), + [prev_evidence_base_source_of_information], + ); + + const prevPlannedOperationsMapping = useMemo( + () => listToMap(prev_planned_operations ?? [], (actor) => actor.sector!), + [prev_planned_operations], + ); + + const prevEnableApproachesMapping = useMemo( + () => listToMap(prev_enabling_approaches ?? [], (approach) => approach.id!), + [prev_enabling_approaches], + ); + + const prevActivationSourceInformationMapping = useMemo( + () => listToMap( + prev_activation_process_source_of_information ?? [], + (actor) => actor.id!, + ), + [prev_activation_process_source_of_information], + ); + + const prevPrioritizedImpactsMapping = useMemo( + () => listToMap(prev_prioritized_impacts ?? [], (impact) => impact.id!), + [prev_prioritized_impacts], + ); + + const prevEarlyActionsMapping = useMemo( + () => listToMap(prev_early_actions ?? [], (action) => action.id!), + [prev_early_actions], + ); + + const previewReady = !eapRegistrationPending && !fullEapPending && !prevFullEapPending; + + return ( + + {strings.pageTitleFullText} +
+ {strings.pageTitleEapText} + + )} + description={eapTitle ?? '--'} + dataReady={previewReady} + > + +
+ + + )} + strongLabel + variant="contents" + withPadding + withBackground + withDiff={false} + /> + + {prioritized_impacts?.map((impact) => ( +
  • + +
  • + ))} + + )} + strongLabel + variant="contents" + withPadding + withBackground + withDiff={false} + /> + + {early_actions?.map((action) => ( +
  • + +
  • + ))} + + )} + strongLabel + variant="contents" + withPadding + withBackground + withDiff={false} + /> + + + ( + + ))} + variant="contents" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + strongLabel + variant="contents" + withPadding + withBackground + withDiff={false} + /> +
    +
    + + + + + {isDefined(cover_image_file?.file) && ( + + + + )} + + + + + + + + + )} + strongValue + variant="block" + withPadding + withBackground + withDiff={false} + /> + + + + + + + + + + {partner_contacts?.map((partner) => { + const prevPartner = prevPartnerContactsMapping[partner.id!]; + return ( + + ); + })} + + + + + + + + + + + + + + + + + + + + + + + + {key_actors?.map((actor) => ( + + )} + > + + + ))} + + + + + + + + + + + + + + + + + + + {hazard_selection_images?.map((hazard) => ( + + ))} + + + + + +
    + {exposed_element_and_vulnerability_factor_images?.map((element) => ( + + ))} +
    +
    + + +
      + {prioritized_impacts?.map((impact) => ( +
    1. + +
    2. + ))} +
    +
    +
    + + + + +
    + {prioritized_impact_images?.map((element) => ( + + ))} +
    +
    + + + {risk_analysis_source_of_information?.map((source) => ( + + + )} + variant="inline" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + withPadding + withBackground + variant="inline" + strongLabel + withDiff={false} + /> + + ))} + + +
    + + + + + + + + + + + + {trigger_statement_source_of_information?.map((trigger) => ( + + + )} + variant="inline" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + withPadding + withBackground + variant="inline" + strongLabel + withDiff={false} + /> + + ))} + + + + + + +
    + {forecast_selection_images?.map((element) => ( + + ))} +
    +
    + + + {strings.downloadForecastTableLabel} + + + + + + +
    + {definition_and_justification_impact_level_images?.map( + (element) => ( + + ), + )} +
    +
    + + + + +
    + {identification_of_the_intervention_area_images?.map((element) => ( + + ))} +
    +
    + + + {trigger_model_source_of_information?.map((trigger) => ( + + + )} + variant="inline" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + withPadding + withBackground + variant="inline" + strongLabel + withDiff={false} + /> + + ))} + + +
    + + + +
      + {early_actions?.map((action) => ( +
    1. + +
    2. + ))} +
    +
    +
    + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + +
    + {early_action_selection_process_images?.map((element) => ( + + ))} +
    +
    + + + {strings.downloadTheoryChangeTableLabel} + + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + {evidence_base_source_of_information?.map((trigger) => ( + + + )} + variant="inline" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + withPadding + withBackground + variant="inline" + strongLabel + withDiff={false} + /> + + ))} + + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + +
    + + {planned_operations?.map((operation) => { + const prevOperation = prevPlannedOperationsMapping?.[operation.sector]; + + const apCodeSectorValue = apCodeOptions?.sector_ap_codes + ?.[operation.sector]?.join(', '); + + const prevApCodeSectorValue = apCodeOptions?.sector_ap_codes + ?.[prevOperation?.sector]?.join(', '); + + const prevOperationIndicatorMap = listToMap( + prevOperation?.indicators, + ({ id }) => id!, + ); + const prevReadinessActivitiesMap = listToMap( + prevOperation?.readiness_activities, + ({ id }) => id!, + ); + const prevPrepositioningActivitiesMap = listToMap( + prevOperation?.prepositioning_activities, + ({ id }) => id!, + ); + const prevEarlyActionActivitiesMap = listToMap( + prevOperation?.early_action_activities, + ({ id }) => id!, + ); + + return ( + + + + + + + + + +
    + + + {operation.indicators.map((indicator) => { + const prevIndicator = isDefined(indicator.previous_id) + ? prevOperationIndicatorMap?.[indicator.previous_id] + : undefined; + + return ( + + )} + value={indicator.target} + prevValue={prevIndicator?.target} + valueType="number" + variant="contents" + withBackground + withPadding + withoutLabelColon + withDiff={withDiff} + /> + ); + })} +
    +
    + +
    + {operation.readiness_activities.map((activity, index) => { + const prevActivity = isDefined(activity.previous_id) + ? prevReadinessActivitiesMap?.[activity.previous_id] + : undefined; + + return ( + + ); + })} +
    +
    + +
    + {operation.prepositioning_activities.map( + (activity, index) => { + const prevActivity = prevPrepositioningActivitiesMap + ?.[activity.id!]; + return ( + + ); + }, + )} +
    +
    + +
    + {operation.early_action_activities.map((activity, index) => { + const prevActivity = isDefined(activity.previous_id) + ? prevEarlyActionActivitiesMap?.[activity.previous_id] + : undefined; + + return ( + + ); + })} +
    +
    +
    + ); + })} +
    + + {enabling_approaches?.map((approach) => { + const prevApproach = prevEnableApproachesMapping?.[approach.approach]; + + const apCodeApproachValue = apCodeOptions?.approach_ap_codes + ?.[approach.approach]?.join(', '); + + const prevApCodeApproachValue = isDefined(prevApproach) + ? apCodeOptions?.approach_ap_codes + ?.[prevApproach.approach]?.join(', ') + : '-'; + + const prevApproachIndicatorMap = listToMap( + prevApproach?.indicators, + ({ id }) => id!, + ); + const prevReadinessActivitiesMap = listToMap( + prevApproach?.readiness_activities, + ({ id }) => id!, + ); + const prevPrepositioningActivitiesMap = listToMap( + prevApproach?.prepositioning_activities, + ({ id }) => id!, + ); + const prevEarlyActionActivitiesMap = listToMap( + prevApproach?.early_action_activities, + ({ id }) => id!, + ); + + return ( + + + + + + + + +
    + + + {approach.indicators.map((indicator) => { + const prevIndicator = isDefined(indicator.previous_id) + ? prevApproachIndicatorMap?.[indicator.previous_id] + : undefined; + + return ( + + )} + value={indicator.target} + prevValue={prevIndicator?.target} + valueType="number" + variant="contents" + withBackground + withPadding + withoutLabelColon + withDiff={withDiff} + /> + ); + })} +
    +
    + +
    + {approach.readiness_activities.map((activity, index) => { + const prevActivity = isDefined(activity.previous_id) + ? prevReadinessActivitiesMap?.[activity.previous_id] + : undefined; + + return ( + + ); + })} +
    +
    + +
    + {approach.prepositioning_activities.map((activity, index) => { + const prevActivity = isDefined(activity.previous_id) + ? prevPrepositioningActivitiesMap + ?.[activity.previous_id] + : undefined; + + return ( + + ); + })} +
    +
    + +
    + {approach.early_action_activities.map((activity, index) => { + const prevActivity = isDefined(activity.previous_id) + ? prevEarlyActionActivitiesMap?.[activity.previous_id] + : undefined; + + return ( + + ); + })} +
    +
    +
    + ); + })} +
    + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + +
    + {early_action_implementation_images?.map((element) => ( + + ))} +
    +
    + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + +
    + {trigger_activation_system_images?.map((element) => ( + + ))} +
    +
    + + + + + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + {activation_process_source_of_information?.map((source) => ( + + + )} + variant="inline" + withPadding + withBackground + strongLabel + withDiff={false} + /> + + )} + withPadding + withBackground + variant="inline" + strongLabel + withDiff={false} + /> + + ))} + + +
    + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + )} + withoutLabelColon + variant="block" + strongLabel + withDiff={false} + /> + + + + + + + + + + + + + + + + + {strings.downloadBudgetLabel} + + + + + + + + + + + + + + + +
    + ); +} + +Component.displayName = 'EapFullExport'; diff --git a/app/src/views/EapFullExport/styles.module.css b/app/src/views/EapFullExport/styles.module.css new file mode 100644 index 0000000000..c4f79b8b21 --- /dev/null +++ b/app/src/views/EapFullExport/styles.module.css @@ -0,0 +1,34 @@ +.summary { + display: grid; + grid-gap: var(--go-ui-width-separator-md); + grid-template-columns: 3fr 7fr; +} + +.meta-items { + display: flex; + flex-direction: column; + gap: var(--go-ui-width-separator-md); + + .meta-children-items { + display: grid; + grid-gap: var(--go-ui-width-separator-md); + grid-template-columns: 1fr 1fr 1fr; + } +} + +.activity-items, +.indicator-items { + display: grid; + grid-gap: var(--go-ui-width-separator-md); + grid-template-columns: 2fr 1fr; +} + +.image-items { + display: flex; + gap: var(--go-ui-width-separator-md); +} + +.sources { + display: flex; + gap: var(--go-ui-spacing-md); +} diff --git a/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json b/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json new file mode 100644 index 0000000000..fb51cf85e1 --- /dev/null +++ b/app/src/views/EapFullForm/EAPSourceInformationInput/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "eapFullForm", + "strings": { + "eapSourceInformationNameLabel": "Name", + "eapSourceInformationLinkLabel": "Link", + "eapSourceInformationDeleteButton": "Delete Source Information" + } +} diff --git a/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx b/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx new file mode 100644 index 0000000000..c096522555 --- /dev/null +++ b/app/src/views/EapFullForm/EAPSourceInformationInput/index.tsx @@ -0,0 +1,120 @@ +import { useCallback } from 'react'; +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + Container, + IconButton, + InlineView, + ListView, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { randomString } from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type PartialForm, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import { type components } from '#generated/types'; +import { formatSourceLink } from '#utils/common'; + +import i18n from './i18n.json'; + +type EAPSourceInformation = components['schemas']['EAPSourceInformation'] & { + client_id: string; +}; + +export type SourceInformationFormFields = PartialForm< + EAPSourceInformation, + 'client_id' +>; + +interface Props { + value: SourceInformationFormFields; + error: ArrayError | undefined; + onChange: ( + value: SetValueArg, + index: number + ) => void; + onRemove: (index: number) => void; + index: number; + disabled?: boolean; + readOnly?: boolean; +} + +function EAPSourceInformationInput(props: Props) { + const { + error: errorFromProps, + onChange, + value, + index, + onRemove, + disabled, + readOnly, + } = props; + + const strings = useTranslation(i18n); + + const onFieldChange = useFormObject(index, onChange, () => ({ + client_id: randomString(), + })); + + const error = value && value.client_id && errorFromProps + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + const handleSourceFieldChange = useCallback( + (linkValue: string | undefined) => { + onFieldChange(formatSourceLink(linkValue), 'source_link'); + }, + [onFieldChange], + ); + + return ( + } + withPadding + withBorder + > + + + + )} + > + + + + + + + ); +} + +export default EAPSourceInformationInput; diff --git a/app/src/views/EapFullForm/EapActivationProcess/i18n.json b/app/src/views/EapFullForm/EapActivationProcess/i18n.json new file mode 100644 index 0000000000..86a443d576 --- /dev/null +++ b/app/src/views/EapFullForm/EapActivationProcess/i18n.json @@ -0,0 +1,56 @@ +{ + "namespace": "eapFullForm", + "strings": { + "activationProcessHeading": "EAP Activation Process", + "activationProcessTooltip": "It is crucial to select early actions that have the most potential to reduce the identified risks(s) and are feasible to implement given the lead time of the forecast. It is important to describe briefly: Who was involved? What data was consulted? Was research conducted? Were communities involved? For more guidance see FbF Manual, Chapter 4.2 Select Early Actions.", + "activationProcessTitle": "Early action implementation process", + "activationProcessDescription1": "Include a matrix/flowchart for a quick overview of the early action implementation process.", + "activationProcessDescription2": "Early Describe the step-by-step process from Day 1 to Day X for the implementation of the selected early actions. Indicate the day when the Stop Mechanism would occur. Include all critical and support tasks that are necessary for each of the steps. Each task should indicate the position of the person responsible (including when cash-based actions are planned liaison with the financial service provider)... implementation process", + "activationProcessDescriptionLabel": "Description", + "activationProcessExplanatoryLabel": "Explanatory Note", + "activationProcessRequiredPointsLabel": "Required Points", + "activationImplementationExplanatoryNote": "As a crucial component of the EAP, once the trigger has been reached, everyone involved should be knowledgeable about what will be done, where, when and by whom. The described implementation process shows that each step of the activation has been thought through and considered and that implementation in the lead time available is possible. The set of tasks described in this section should cover all activities from the moment the trigger is reached (Day 1) to the completion of post-impact surveys (Day X).", + "activationRequiredPoint1": "Include a matrix/flowchart for a quick overview of the early action implementation process.", + "activationRequiredPoint2": "Describe the step-by-step process from Day 1 to Day X for the implementation of the selected early actions. Indicate the day when the Stop Mechanism would occur. Include all critical and support tasks that are necessary for each of the steps. Each task should indicate the position of the person responsible (including when cash-based actions are planned liaison with the financial service provider).", + "activationRequiredPoint3": "For each action, include at which level it will take place (HQ, branch, community).", + "activationRequiredPoint4": "Each NS should have a detailed version of this process, including communication flows, for each task and the name of the person responsible with their contact information. This document should be regularly updated.", + "activationProcessUploadLabel": "Upload", + "activationTriggerTitle": "Trigger activation system", + "activationTriggerDescription1": "Describe the automatic system used to monitor the forecasts, generate the intervention map and send the alert message when the trigger is reached.", + "activationTriggerDescription2": "If this automatic system does not yet exist, explain how forecasts will be monitored, intervention maps generated and how the relevant actors will be informed that the trigger has been reached.", + "activationTriggerDescription3": "Indicate who gives the signal to start the activation.", + "activationTriggerExplanatoryNote": "The activation process starts with the message that the trigger has been reached (on Day 1). Ideally, there is a system in place to automatically monitor the forecasts and send an automatic message of alert to relevant actors as soon as a trigger is reached. It is expected that this will be executed by the national meteorological office and/or national DRM authority. If this automatic system does not exist, a mechanism needs to be in place to monitor the forecasts and alert relevant actors as soon as a trigger is reached to initiate the early actions.", + "activationTriggerRequiredPoint1": "Describe the automatic system used to monitor the forecasts, generate the intervention map and send the alert message when the trigger is reached.", + "activationTriggerRequiredPoint2": "If this automatic system does not yet exist, explain how forecasts will be monitored, intervention maps generated and how the relevant actors will be informed that the trigger has been reached.", + "activationTriggerRequiredPoint3": "Indicate who gives the signal to start the activation.", + "activationPeopleTargetedTitle": "People Targeted", + "activationPeopleTargetedDescription": "Specify number of people targeted", + "activationSelectionPopulationTitle": "Selection of target population", + "activationSelectionDescription1": "Provide a short summary of the target population, (the number, location etc.)", + "activationSelectionDescription2": "Describe how the target population will be selected, with a special focus on feasibility in the short period of time between forecast and event", + "activationSelectionDescription3": "If the EAP is intending to use Social Protection systems or other government beneficiary databases, indicate how the potential number of targeted households be selected", + "activationSelectionExplanatoryNote": "FbF aims to protect the most vulnerable from the impact of extreme weather events. Based on the analysis on vulnerability and exposure (in section 3) and on the described mechanism for identifying intervention areas/communities (in section 4- Intervention area), it needs to be clear, how vulnerability criteria and impact forecasts will be applied to determine who will be targeted.", + "activationStopMechanismTitle": "Stop Mechanism", + "activationStopDescription1": "Indicate on which day of activation the stop mechanism is foreseen, and who is responsible to give the signal to stop.", + "activationStopDescription2": "Describe when the stop mechanism begins and whether in-kind/cash distribution would be stopped or not. For cash actions cancelled, how would this be coordinated with the financial service provider? For in-kind distribution, what would happen with the perishable items?", + "activationStopDescription3": "Explain how it would be communicated to communities and stakeholders that the activities are being stopped.", + "activationStopMechanismExplanatoryNote": "For forecast triggers with a lead time of more than three days, the EAP should include the description of a stop mechanism. This means that if a later forecast – prior to the start of activities (related to the early action(s)) shows that the event is no longer likely to occur, the activation of the EAP will be stopped to avoid generating further use of resources. For example, if the 6-day forecast on Day 1 indicates high risk of heavy rainfall and thereby triggers the activation and the new 6-day-forecast released on Day 3 shows that the risk has significantly lowered, the trigger level is no longer reached. If the start of distributions was planned for Day 4, activation should be stopped. Items that have been purchased based on the trigger being reached and are not distributed due to the stop mechanism should be stored in the warehouse for a future activation. For forecast triggers with a lead time of less than 3 days, the EAP should include the description of what the National Society would do if the forecast changes in strength or location within the last three days before the event.", + "activationAttachFilesTitle": "Attach Relevant Files", + "activationAttachFilesDescription": "Attach any additional maps, documentation, files, images, etc.", + "activationSourceOfInformationTitle": "Sources of Information", + "activationAddNewButtonLabel": "Add", + "activationSelectImagesLabel": "Select images", + "activationImageCountLabel": "5 images max(optional)", + "activationSourceOfInformationDescription": "Add the description of the sources one at a time. If the source has a link, add in the second field.", + "activationSectionHeading": "Quality Criteria: EAP activation process", + "activationSectionCriteriaIntroduction1": "There is a mechanism in place to monitor the forecasts (ad /or trigger related indicators) and alert relevant actors as soon as a trigger is reached to initiate the early actions. This implies that the process to trigger the FbF system is clearly understood by all the key staff of the NS and relevant partners.", + "activationSectionCriteriaComment11": "Ideally, there is a system in place to automatically monitor the forecasts and send an automatic message of alert to relevant actors as soon as a trigger is reached. It is expected that this will be executed by the national meteorological office and/or national DRM authority. If this automatic system does not exist, a mechanism needs to be in place to monitor the forecasts/indicators and alert relevant actors as soon as a trigger is reached to initiate the early actions", + "activationSectionCriteriaComment12": "If the trigger is based on a combination of multiple indicators (e.g. for slow onset hazard) and no suitable product exists yet, the EAP needs to explain who will provide the data and how it will be combined and analyzed. This may need to be a consortium of institutions, each providing pieces of information, therefore a clear communication mechanism is vital.", + "activationSectionCriteriaComment13": "Any risk (e.g. certain data is not collected in time) and mitigation measures (e.g.MOU) should be outlined.", + "activationSectionCriteriaIntroduction2": "The EAP clearly explains how the target beneficiaries within the intervention areas will be selected.", + "activationSectionCriteriaComment2": "Once an intervention area has been identified, the NS still has to have a clear process to select which households in that area it will provide assistance to. The selection process needs to be feasible within the lead time.", + "activationSectionCriteriaIntroduction3": "The EAP includes the description of a stop mechanism for forecast triggers with a lead time of more than three days. For forecast triggers with a lead time of less than 3 days, the EAP should include the description of what the National Society would do if the forecast changes in strength or location within the last three days before the event.", + "activationSectionCriteriaComment31": "If the forecast triggers action, but then in subsequent days the forecast reduces below the trigger, the action should be able to stop. Exceptions might be accepted if the trigger chosen has a very high probability.", + "activationSectionCriteriaComment32": "In some cases, a stop mechanism may not be possible (very short lead time or limited frequency of forecast for seasonal forecast)" + } +} diff --git a/app/src/views/EapFullForm/EapActivationProcess/index.tsx b/app/src/views/EapFullForm/EapActivationProcess/index.tsx new file mode 100644 index 0000000000..66677083ea --- /dev/null +++ b/app/src/views/EapFullForm/EapActivationProcess/index.tsx @@ -0,0 +1,483 @@ +import { useCallback } from 'react'; +import { AddLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Description, + InputSection, + Label, + ListView, + NumberInput, + TextArea, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { randomString } from '@togglecorp/fujs'; +import { + type EntriesAsList, + type Error, + getErrorObject, + getErrorString, + useFormArray, +} from '@togglecorp/toggle-form'; + +import GoMultiFileInput from '#components/domain/GoMultiFileInput'; +import MultiImageWithCaptionInput from '#components/domain/MultiImageWithCaptionInput'; +import ExplanatoryNote from '#components/ExplanatoryNote'; +import NonFieldError from '#components/NonFieldError'; +import TabPage from '#components/TabPage'; + +import { charLimits } from '../common'; +import EAPSourceInformationInput, { type SourceInformationFormFields } from '../EAPSourceInformationInput'; +import { type PartialEapFullFormType } from '../schema'; +import SectionQualityCriteria from '../SectionQualityCriteria'; + +import i18n from './i18n.json'; + +interface Props { + value: PartialEapFullFormType; + setFieldValue: (...entries: EntriesAsList) => void; + error: Error | undefined; + disabled?: boolean; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch< + React.SetStateAction> + >; + readOnly?: boolean; +} + +function EapActivationProcess(props: Props) { + const { + value, + setFieldValue, + error: formError, + disabled, + fileIdToUrlMap, + setFileIdToUrlMap, + readOnly, + } = props; + + const strings = useTranslation(i18n); + + const error = getErrorObject(formError); + + const { + setValue: onRiskSourceInformationChange, + removeValue: onRiskSourceInformationRemove, + } = useFormArray< + 'activation_process_source_of_information', + SourceInformationFormFields + >('activation_process_source_of_information', setFieldValue); + + const handleSourceInformationAdd = useCallback(() => { + const newSourceInformationItem: SourceInformationFormFields = { + client_id: randomString(), + }; + + setFieldValue( + (oldValue: SourceInformationFormFields[] | undefined) => [ + ...(oldValue ?? []), + newSourceInformationItem, + ], + 'activation_process_source_of_information' as const, + ); + }, [setFieldValue]); + + return ( + + + + + {strings.activationSectionCriteriaComment11} + + + {strings.activationSectionCriteriaComment12} + + + {strings.activationSectionCriteriaComment13} + + + + + {strings.activationSectionCriteriaComment2} + + + + + {strings.activationSectionCriteriaComment31} + + + {strings.activationSectionCriteriaComment32} + + + + )} + /> + )} + > + + {strings.activationProcessHeading} + + {strings.activationProcessTooltip} + + )} + /> + + )} + variant="form" + > + + + + + + {strings.activationImplementationExplanatoryNote} + + + + + +
      +
    • + {strings.activationRequiredPoint1} +
    • +
    • + {strings.activationRequiredPoint2} +
    • +
    • + {strings.activationRequiredPoint3} +
    • +
    • + {strings.activationRequiredPoint4} +
    • +
    +
    +
    +
    + )} + /> + )} + description={( +
      +
    • {strings.activationProcessDescription1}
    • +
    • {strings.activationProcessDescription2}
    • +
    + )} + withAsteriskOnTitle + > +