diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e425b47b..a553bca41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,45 @@ env: GITHUB_WORKFLOW: true jobs: + ui: + name: UI library + environment: 'test' + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/ui + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18.x' + cache: 'yarn' + + - name: Install dependencies on UI library + run: yarn install --frozen-lockfile + + - name: Typecheck + run: yarn typecheck + + - name: Lint CSS + run: yarn lint:css + + - name: Lint JS + run: yarn lint:js + + - name: build UI library + run: yarn build + + - uses: actions/upload-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + test: name: Run tests environment: 'test' runs-on: ubuntu-latest + needs: [ui] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -33,15 +68,23 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Run test - run: yarn test + working-directory: app + translation: name: Identify error with translation files runs-on: ubuntu-latest + needs: [ui] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -49,14 +92,23 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Identify error with translation files run: yarn lint:translation + working-directory: app + unimported: name: Identify unused files runs-on: ubuntu-latest + needs: [ui] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -64,14 +116,23 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Identify unused files run: yarn lint:unused + working-directory: app + lint: name: Lint JS runs-on: ubuntu-latest + needs: [ui] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -79,14 +140,23 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Lint JS run: yarn lint:js + working-directory: app + lint-css: name: Lint CSS runs-on: ubuntu-latest + needs: [ui] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -94,11 +164,19 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Lint CSS run: yarn lint:css + working-directory: app + # FIXME: Identify a way to generate schema before we run typecheck # typecheck: # name: Typecheck @@ -111,14 +189,14 @@ jobs: # cache: 'yarn' # - name: Install dependencies - # run: yarn install --prefer-offline --frozen-lockfile + # run: yarn install --frozen-lockfile # - name: Typecheck # run: yarn typecheck build: name: Build environment: 'test' - needs: [lint, lint-css, test] + needs: [lint, lint-css, test, ui] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -127,8 +205,15 @@ jobs: node-version: '18.x' cache: 'yarn' + - uses: actions/download-artifact@v4 + with: + name: ui-build + path: packages/ui/dist + - name: Install dependencies - run: yarn install --prefer-offline --frozen-lockfile + run: yarn install --frozen-lockfile + working-directory: app - name: Build run: yarn build + working-directory: app diff --git a/.dockerignore b/app/.dockerignore similarity index 100% rename from .dockerignore rename to app/.dockerignore diff --git a/.eslintignore b/app/.eslintignore similarity index 100% rename from .eslintignore rename to app/.eslintignore diff --git a/.unimportedrc.json b/app/.unimportedrc.json similarity index 100% rename from .unimportedrc.json rename to app/.unimportedrc.json diff --git a/Dockerfile b/app/Dockerfile similarity index 100% rename from Dockerfile rename to app/Dockerfile diff --git a/docker-compose.yml b/app/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to app/docker-compose.yml diff --git a/env.ts b/app/env.ts similarity index 100% rename from env.ts rename to app/env.ts diff --git a/app/eslint.config.js b/app/eslint.config.js new file mode 100644 index 000000000..891f8a8af --- /dev/null +++ b/app/eslint.config.js @@ -0,0 +1,134 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import process from 'process'; + +const dirname = process.cwd(); + +const compat = new FlatCompat({ + baseDirectory: dirname, + resolvePluginsRelativeTo: dirname, +}); + +const appConfigs = compat.config({ + env: { + node: true, + browser: true, + es2020: true, + }, + root: true, + extends: [ + 'airbnb', + 'airbnb/hooks', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: [ + '@typescript-eslint', + 'react-refresh', + 'simple-import-sort', + 'import-newlines' + ], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'] + }, + 'import/resolver': { + typescript: { + project: [ + './tsconfig.json', + ], + }, + }, + }, + rules: { + 'react-refresh/only-export-components': 'warn', + + 'no-unused-vars': 0, + '@typescript-eslint/no-unused-vars': 1, + + 'no-use-before-define': 0, + '@typescript-eslint/no-use-before-define': 1, + + 'no-shadow': 0, + '@typescript-eslint/no-shadow': ['error'], + + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + '**/*.test.{ts,tsx}', + 'eslint.config.js', + 'postcss.config.cjs', + 'stylelint.config.cjs', + 'vite.config.ts', + ], + optionalDependencies: false, + }, + ], + + indent: ['error', 4, { SwitchCase: 1 }], + + 'import/no-cycle': ['error', { allowUnsafeDynamicCyclicDependency: true }], + + 'react/react-in-jsx-scope': 'off', + 'camelcase': 'off', + + 'react/jsx-indent': ['error', 4], + 'react/jsx-indent-props': ['error', 4], + 'react/jsx-filename-extension': ['error', { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], + + 'import/extensions': ['off', 'never'], + + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + + 'react/require-default-props': ['warn', { ignoreFunctionalComponents: true }], + 'simple-import-sort/imports': 'warn', + 'simple-import-sort/exports': 'warn', + 'import-newlines/enforce': ['warn', 1] + }, + overrides: [ + { + files: ['*.js', '*.jsx', '*.ts', '*.tsx'], + rules: { + 'simple-import-sort/imports': [ + 'error', + { + 'groups': [ + // side effect imports + ['^\\u0000'], + // packages `react` related packages come first + ['^react', '^@?\\w'], + // internal packages + ['^#.+$'], + // parent imports. Put `..` last + // other relative imports. Put same-folder imports and `.` last + ['^\\.\\.(?!/?$)', '^\\.\\./?$', '^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], + // style imports + ['^.+\\.json$', '^.+\\.module.css$'], + ] + } + ] + } + } + ] +}).map((conf) => ({ + ...conf, + files: ['src/**/*.tsx', 'src/**/*.jsx', 'src/**/*.ts', 'src/**/*.js'], + ignores: ['src/generated/types.ts'], +})); + +const otherConfig = { + files: ['*.js', '*.ts', '*.cjs'], + ...js.configs.recommended, +}; + +export default [ + ...appConfigs, + otherConfig, +]; diff --git a/index.html b/app/index.html similarity index 100% rename from index.html rename to app/index.html diff --git a/app/package.json b/app/package.json new file mode 100644 index 000000000..a6c55b0b0 --- /dev/null +++ b/app/package.json @@ -0,0 +1,110 @@ +{ + "name": "go-web-app", + "version": "7.0.17", + "type": "module", + "private": true, + "scripts": { + "initialize:type": "mkdir -p generated/ && yarn initialize:type:go-api && yarn initialize:type:risk-api", + "initialize:type:go-api": "test -f ./generated/types.ts && true || cp types.stub.ts ./generated/types.ts", + "initialize:type:risk-api": "test -f ./generated/riskTypes.ts && true || cp types.stub.ts ./generated/riskTypes.ts", + "prestart": "yarn initialize:type", + "pretypecheck": "yarn initialize:type", + "prelint:js": "yarn initialize:type", + "prelint:unused": "yarn initialize:type", + "prebuild": "yarn initialize:type", + "start": "vite", + "build": "vite build", + "generate:type": "yarn generate:type:go-api && yarn generate:type:risk-api", + "generate:type:go-api": "dotenv -- cross-var openapi-typescript \"%APP_API_ENDPOINT%api-docs/\" -o ./generated/types.ts --alphabetize", + "generate:type:risk-api": "dotenv -- cross-var openapi-typescript \"%APP_RISK_API_ENDPOINT%api-docs/\" -o ./generated/riskTypes.ts --alphabetize", + "typecheck": "tsc", + "lint:js": "eslint src", + "lint:css": "stylelint \"./src/**/*.css\"", + "lint:unused": "unimported", + "lint:translation": "node ./scripts/translator.js", + "lint": "yarn lint:js && yarn lint:css && yarn lint:unused && yarn lint:translation", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "surge:deploy": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); cp build/index.html build/200.html; surge -p build/ -d https://ifrc-go-$branch.surge.sh", + "surge:teardown": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); surge teardown https://ifrc-go-$branch.surge.sh", + "postinstall": "patch-package" + }, + "dependencies": { + "@ifrc-go/icons": "^1.2.0", + "@ifrc-go/ui": "^0.0.1", + "@mapbox/mapbox-gl-draw": "^1.2.0", + "@sentry/react": "^7.81.1", + "@tinymce/tinymce-react": "^4.3.0", + "@togglecorp/fujs": "^2.1.1", + "@togglecorp/re-map": "^0.2.0-beta-6", + "@togglecorp/toggle-form": "^2.0.4", + "@togglecorp/toggle-request": "^1.0.0-beta.2", + "@turf/bbox": "^6.5.0", + "@turf/buffer": "^6.5.0", + "exceljs": "^4.3.0", + "file-saver": "^2.0.5", + "html-to-image": "^1.11.11", + "mapbox-gl": "^1.13.0", + "papaparse": "^5.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-focus-on": "^3.8.1", + "react-router-dom": "^6.18.0", + "sanitize-html": "^2.10.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^2.0.3", + "@julr/vite-plugin-validate-env": "^1.0.1", + "@types/file-saver": "^2.0.5", + "@types/html2canvas": "^1.0.0", + "@types/mapbox-gl": "^1.13.0", + "@types/node": "^20.1.3", + "@types/papaparse": "^5.3.8", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@types/sanitize-html": "^2.9.0", + "@typescript-eslint/eslint-plugin": "^5.59.5", + "@typescript-eslint/parser": "^5.59.5", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.14", + "cross-var": "^1.1.0", + "dotenv-cli": "^7.2.1", + "eslint": "^8.40.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-import-exports-imports-resolver": "^1.0.1", + "eslint-plugin-import-newlines": "^1.3.4", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "eslint-plugin-simple-import-sort": "^10.0.0", + "fast-glob": "^3.3.0", + "happy-dom": "^9.18.3", + "openapi-typescript": "6.5.5", + "patch-package": "^7.0.0", + "postcss": "^8.3.0", + "postcss-nested": "^6.0.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^8.3.2", + "postinstall-postinstall": "^2.1.0", + "rollup-plugin-visualizer": "^5.9.0", + "stylelint": "^15.6.1", + "stylelint-config-concentric": "^2.0.2", + "stylelint-config-recommended": "^12.0.0", + "stylelint-no-unused-selectors": "git+https://github.com/toggle-corp/stylelint-no-unused-selectors#e0831e1", + "stylelint-value-no-unknown-custom-properties": "^4.0.0", + "surge": "^0.23.1", + "typescript": "^5.0.4", + "unimported": "1.28.0", + "vite": "^5.0.10", + "vite-plugin-checker": "^0.6.2", + "vite-plugin-compression2": "^0.11.0", + "vite-plugin-radar": "^0.9.2", + "vite-plugin-svgr": "^4.2.0", + "vite-plugin-webfont-dl": "^3.9.1", + "vite-tsconfig-paths": "^4.2.2", + "vitest": "^1.1.0" + } +} diff --git a/postcss.config.cjs b/app/postcss.config.cjs similarity index 100% rename from postcss.config.cjs rename to app/postcss.config.cjs diff --git a/public/go-icon.svg b/app/public/go-icon.svg similarity index 100% rename from public/go-icon.svg rename to app/public/go-icon.svg diff --git a/scripts/translator.js b/app/scripts/translator.js similarity index 96% rename from scripts/translator.js rename to app/scripts/translator.js index 8e5bac987..3f78c2091 100644 --- a/scripts/translator.js +++ b/app/scripts/translator.js @@ -1,9 +1,9 @@ +import { isDefined, listToMap, mapToList } from '@togglecorp/fujs'; import fg from 'fast-glob'; -import { cwd, exit } from 'process'; +import { readFile } from 'fs'; import { join } from 'path'; +import { cwd, exit } from 'process'; import { promisify } from 'util'; -import { readFile } from 'fs'; -import { mapToList, listToMap, isDefined, unique } from '@togglecorp/fujs'; const glob = fg.glob; diff --git a/src/App/Auth.tsx b/app/src/App/Auth.tsx similarity index 95% rename from src/App/Auth.tsx rename to app/src/App/Auth.tsx index 4cf6b0f01..999e9dd14 100644 --- a/src/App/Auth.tsx +++ b/app/src/App/Auth.tsx @@ -1,12 +1,16 @@ -import { type ReactElement, Fragment } from 'react'; +import { + Fragment, + type ReactElement, +} from 'react'; import { Navigate, useParams, } from 'react-router-dom'; -import useAuth from '#hooks/domain/useAuth'; import FourHundredThree from '#components/FourHundredThree'; +import useAuth from '#hooks/domain/useAuth'; import usePermissions from '#hooks/domain/usePermissions'; + import { type ExtendedProps } from './routes/common'; interface Props { diff --git a/src/App/PageError/i18n.json b/app/src/App/PageError/i18n.json similarity index 100% rename from src/App/PageError/i18n.json rename to app/src/App/PageError/i18n.json diff --git a/app/src/App/PageError/index.tsx b/app/src/App/PageError/index.tsx new file mode 100644 index 000000000..6a10d7acb --- /dev/null +++ b/app/src/App/PageError/index.tsx @@ -0,0 +1,98 @@ +import { + useCallback, + useEffect, +} from 'react'; +import { useRouteError } from 'react-router-dom'; +import { Button } from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; + +import Link from '#components/Link'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function PageError() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errorResponse = useRouteError() as unknown as any; + const strings = useTranslation(i18n); + + useEffect( + () => { + // eslint-disable-next-line no-console + console.error(errorResponse); + }, + [errorResponse], + ); + + const [ + fullErrorVisible, + { + toggle: toggleFullErrorVisibility, + }, + ] = useBooleanState(false); + + const handleReloadButtonClick = useCallback( + () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.location.reload(true); + }, + [], + ); + + return ( +
+
+
+

+ {strings.errorPageIssueMessage} +

+
+ {errorResponse?.error?.message + ?? errorResponse?.message + ?? strings.errorPageUnexpectedMessage} +
+ + {fullErrorVisible && ( + <> +
+ {errorResponse?.error?.stack + ?? errorResponse?.stack ?? strings.errorPageStackTrace} +
+
+ {strings.errorSeeDeveloperConsole} +
+ + )} +
+
+ {/* NOTE: using the anchor element as it will refresh the page */} + + {strings.errorPageGoBack} + + +
+
+
+ ); +} + +export default PageError; diff --git a/src/App/PageError/styles.module.css b/app/src/App/PageError/styles.module.css similarity index 100% rename from src/App/PageError/styles.module.css rename to app/src/App/PageError/styles.module.css diff --git a/app/src/App/index.tsx b/app/src/App/index.tsx new file mode 100644 index 000000000..de0d0435b --- /dev/null +++ b/app/src/App/index.tsx @@ -0,0 +1,269 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + createBrowserRouter, + RouterProvider, +} from 'react-router-dom'; +import { + AlertContext, + type AlertContextProps, + type AlertParams, + type Language, + LanguageContext, + type LanguageContextProps, + type LanguageNamespaceStatus, +} from '@ifrc-go/ui/contexts'; +import * as Sentry from '@sentry/react'; +import { + isDefined, + unique, +} from '@togglecorp/fujs'; +import mapboxgl from 'mapbox-gl'; + +import goLogo from '#assets/icons/go-logo-2020.svg'; +import { + appTitle, + mbtoken, +} from '#config'; +import RouteContext from '#contexts/route'; +import UserContext, { + UserAuth, + UserContextProps, +} from '#contexts/user'; +import { + KEY_LANGUAGE_STORAGE, + KEY_USER_STORAGE, +} from '#utils/constants'; +import { + getFromStorage, + removeFromStorage, + setToStorage, +} from '#utils/localStorage'; +import { RequestContext } from '#utils/restRequest'; +import { + processGoError, + processGoOptions, + processGoResponse, + processGoUrls, +} from '#utils/restRequest/go'; + +import wrappedRoutes, { unwrappedRoutes } from './routes'; + +import styles from './styles.module.css'; + +const requestContextValue = { + transformUrl: processGoUrls, + transformOptions: processGoOptions, + transformResponse: processGoResponse, + transformError: processGoError, +}; +const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouter); +const router = sentryCreateBrowserRouter(unwrappedRoutes); +mapboxgl.accessToken = mbtoken; +mapboxgl.setRTLTextPlugin( + 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.3/mapbox-gl-rtl-text.js', + // eslint-disable-next-line no-console + (err) => { console.error(err); }, + true, +); + +function Application() { + // ALERTS + + const [alerts, setAlerts] = useState([]); + + const addAlert = useCallback((alert: AlertParams) => { + setAlerts((prevAlerts) => unique( + [...prevAlerts, alert], + (a) => a.name, + ) ?? prevAlerts); + }, [setAlerts]); + + const removeAlert = useCallback((name: AlertParams['name']) => { + setAlerts((prevAlerts) => { + const i = prevAlerts.findIndex((a) => a.name === name); + if (i === -1) { + return prevAlerts; + } + + const newAlerts = [...prevAlerts]; + newAlerts.splice(i, 1); + + return newAlerts; + }); + }, [setAlerts]); + + const updateAlert = useCallback((name: AlertParams['name'], paramsWithoutName: Omit) => { + setAlerts((prevAlerts) => { + const i = prevAlerts.findIndex((a) => a.name === name); + if (i === -1) { + return prevAlerts; + } + + const updatedAlert = { + ...prevAlerts[i], + paramsWithoutName, + }; + + const newAlerts = [...prevAlerts]; + newAlerts.splice(i, 1, updatedAlert); + + return newAlerts; + }); + }, [setAlerts]); + + const alertContextValue: AlertContextProps = useMemo(() => ({ + alerts, + addAlert, + updateAlert, + removeAlert, + }), [alerts, addAlert, updateAlert, removeAlert]); + + // AUTH + + const [userAuth, setUserAuth] = useState(); + + const hydrateUserAuth = useCallback(() => { + const userDetailsFromStorage = getFromStorage(KEY_USER_STORAGE); + if (userDetailsFromStorage) { + setUserAuth(userDetailsFromStorage); + } + }, []); + + const removeUserAuth = useCallback(() => { + removeFromStorage(KEY_USER_STORAGE); + setUserAuth(undefined); + }, []); + + const setAndStoreUserAuth = useCallback((newUserDetails: UserAuth) => { + setUserAuth(newUserDetails); + setToStorage( + KEY_USER_STORAGE, + newUserDetails, + ); + }, []); + + // Translation + + const [strings, setStrings] = useState({}); + const [currentLanguage, setCurrentLanguage] = useState('en'); + const [ + languageNamespaceStatus, + setLanguageNamespaceStatus, + ] = useState>({}); + + const setAndStoreCurrentLanguage = useCallback( + (newLanugage: Language) => { + setCurrentLanguage(newLanugage); + setToStorage(KEY_LANGUAGE_STORAGE, newLanugage); + }, + [], + ); + + const registerLanguageNamespace = useCallback( + (namespace: string, fallbackStrings: Record) => { + setStrings( + (prevValue) => { + if (isDefined(prevValue[namespace])) { + return { + ...prevValue, + [namespace]: { + ...fallbackStrings, + ...prevValue[namespace], + }, + }; + } + + return { + ...prevValue, + [namespace]: fallbackStrings, + }; + }, + ); + + setLanguageNamespaceStatus((prevValue) => { + if (isDefined(prevValue[namespace])) { + return prevValue; + } + + return { + ...prevValue, + // NOTE: This will fetch if the data is not already fetched + [namespace]: prevValue[namespace] === 'fetched' ? 'fetched' : 'queued', + }; + }); + }, + [setStrings], + ); + + // Hydration + useEffect(() => { + hydrateUserAuth(); + + const language = getFromStorage(KEY_LANGUAGE_STORAGE); + setCurrentLanguage(language ?? 'en'); + }, [hydrateUserAuth]); + + const userContextValue = useMemo( + () => ({ + userAuth, + hydrateUserAuth, + setUserAuth: setAndStoreUserAuth, + removeUserAuth, + }), + [userAuth, hydrateUserAuth, setAndStoreUserAuth, removeUserAuth], + ); + + const languageContextValue = useMemo( + () => ({ + languageNamespaceStatus, + setLanguageNamespaceStatus, + currentLanguage, + setCurrentLanguage: setAndStoreCurrentLanguage, + strings, + setStrings, + registerNamespace: registerLanguageNamespace, + }), + [ + languageNamespaceStatus, + setLanguageNamespaceStatus, + currentLanguage, + setAndStoreCurrentLanguage, + strings, + registerLanguageNamespace, + ], + ); + + return ( + + + + + + + IFRC GO + {`${appTitle} loading...`} + + )} + /> + + + + + + ); +} + +const App = Sentry.withProfiler(Application); +export default App; diff --git a/src/App/routes/CountryRoutes.tsx b/app/src/App/routes/CountryRoutes.tsx similarity index 99% rename from src/App/routes/CountryRoutes.tsx rename to app/src/App/routes/CountryRoutes.tsx index 1ecd80e7c..412216a24 100644 --- a/src/App/routes/CountryRoutes.tsx +++ b/app/src/App/routes/CountryRoutes.tsx @@ -1,9 +1,13 @@ import { - Navigate, generatePath, + Navigate, useParams, } from 'react-router-dom'; -import { isDefined, isTruthyString } from '@togglecorp/fujs'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; + import { countryIdToRegionIdMap } from '#utils/domain/country'; import Auth from '../Auth'; diff --git a/src/App/routes/RegionRoutes.tsx b/app/src/App/routes/RegionRoutes.tsx similarity index 100% rename from src/App/routes/RegionRoutes.tsx rename to app/src/App/routes/RegionRoutes.tsx diff --git a/src/App/routes/SmartNavigate.tsx b/app/src/App/routes/SmartNavigate.tsx similarity index 93% rename from src/App/routes/SmartNavigate.tsx rename to app/src/App/routes/SmartNavigate.tsx index 67f11e65e..965c08d8a 100644 --- a/src/App/routes/SmartNavigate.tsx +++ b/app/src/App/routes/SmartNavigate.tsx @@ -1,9 +1,12 @@ import { Navigate, - useLocation, type NavigateProps, + useLocation, } from 'react-router-dom'; -import { isDefined, isTruthyString } from '@togglecorp/fujs'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; type RouteKey = string; diff --git a/src/App/routes/SurgeRoutes.tsx b/app/src/App/routes/SurgeRoutes.tsx similarity index 99% rename from src/App/routes/SurgeRoutes.tsx rename to app/src/App/routes/SurgeRoutes.tsx index 1170f1bd9..45aedaa96 100644 --- a/src/App/routes/SurgeRoutes.tsx +++ b/app/src/App/routes/SurgeRoutes.tsx @@ -3,16 +3,18 @@ import { Outlet, useParams, } from 'react-router-dom'; -import { isDefined, isTruthyString } from '@togglecorp/fujs'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; import type { MyOutputNonIndexRouteObject } from '#utils/routes'; import Auth from '../Auth'; - import { customWrapRoute, - rootLayout, type ExtendedProps, + rootLayout, } from './common'; type DefaultSurgeChild = 'overview'; diff --git a/app/src/App/routes/common.tsx b/app/src/App/routes/common.tsx new file mode 100644 index 000000000..48cba20a1 --- /dev/null +++ b/app/src/App/routes/common.tsx @@ -0,0 +1,59 @@ +import { + type MyInputIndexRouteObject, + type MyInputNonIndexRouteObject, + type MyOutputIndexRouteObject, + type MyOutputNonIndexRouteObject, + wrapRoute, +} from '#utils/routes'; +import { Component as RootLayout } from '#views/RootLayout'; + +import Auth from '../Auth'; +import PageError from '../PageError'; + +export interface Perms { + isDrefRegionalCoordinator: (regionId: number | undefined) => boolean, + isRegionAdmin: (regionId: number | undefined) => boolean, + isCountryAdmin: (countryId: number | undefined) => boolean, + isRegionPerAdmin: (regionId: number | undefined) => boolean, + isCountryPerAdmin: (countryId: number | undefined) => boolean, + isPerAdmin: boolean, + isIfrcAdmin: boolean, + isSuperUser: boolean, +} + +export type ExtendedProps = { + title: string, + visibility: 'is-authenticated' | 'is-not-authenticated' | 'anything', + permissions?: ( + permissions: Perms, + params: Record | undefined | null, + ) => boolean; +}; + +export interface CustomWrapRoute { + ( + myRouteOptions: MyInputIndexRouteObject + ): MyOutputIndexRouteObject + ( + myRouteOptions: MyInputNonIndexRouteObject + ): MyOutputNonIndexRouteObject +} + +export const customWrapRoute: CustomWrapRoute = wrapRoute; + +// NOTE: We should not use layout or index routes in links + +export const rootLayout = customWrapRoute({ + path: '/', + errorElement: , + component: { + eagerLoad: true, + render: RootLayout, + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'IFRC GO', + visibility: 'anything', + }, +}); diff --git a/app/src/App/routes/index.tsx b/app/src/App/routes/index.tsx new file mode 100644 index 000000000..830d16c15 --- /dev/null +++ b/app/src/App/routes/index.tsx @@ -0,0 +1,1218 @@ +import { Navigate } from 'react-router-dom'; + +import { unwrapRoute } from '#utils/routes'; + +import Auth from '../Auth'; +import { + customWrapRoute, + rootLayout, +} from './common'; +import countryRoutes from './CountryRoutes'; +import regionRoutes from './RegionRoutes'; +import SmartNavigate from './SmartNavigate'; +import surgeRoutes from './SurgeRoutes'; + +const fourHundredFour = customWrapRoute({ + parent: rootLayout, + path: '*', + component: { + render: () => import('#views/FourHundredFour'), + props: {}, + }, + context: { + title: '404', + visibility: 'anything', + }, +}); + +const login = customWrapRoute({ + parent: rootLayout, + path: 'login', + component: { + render: () => import('#views/Login'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Login', + visibility: 'is-not-authenticated', + }, +}); + +const register = customWrapRoute({ + parent: rootLayout, + path: 'register', + component: { + render: () => import('#views/Register'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Register', + visibility: 'is-not-authenticated', + }, +}); + +const recoverAccount = customWrapRoute({ + parent: rootLayout, + path: 'recover-account', + component: { + render: () => import('#views/RecoverAccount'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Recover Account', + visibility: 'is-not-authenticated', + }, +}); + +const recoverAccountConfirm = customWrapRoute({ + parent: rootLayout, + path: 'recover-account/:username/:token', + component: { + render: () => import('#views/RecoverAccountConfirm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Recover Account Confirm', + visibility: 'is-not-authenticated', + }, +}); + +const resendValidationEmail = customWrapRoute({ + parent: rootLayout, + path: 'resend-validation-email', + component: { + render: () => import('#views/ResendValidationEmail'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Resend Validation Email', + visibility: 'is-not-authenticated', + }, +}); + +const home = customWrapRoute({ + parent: rootLayout, + index: true, + component: { + render: () => import('#views/Home'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Home', + visibility: 'anything', + }, +}); + +const emergencies = customWrapRoute({ + parent: rootLayout, + path: 'emergencies', + component: { + render: () => import('#views/Emergencies'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergencies', + visibility: 'anything', + }, +}); + +type DefaultEmergenciesChild = 'details'; +const emergenciesLayout = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/:emergencyId', + forwardPath: 'details' satisfies DefaultEmergenciesChild, + component: { + render: () => import('#views/Emergency'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencySlug = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/slug/:slug', + component: { + render: () => import('#views/EmergencySlug'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencyFollow = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/:emergencyId/follow', + component: { + render: () => import('#views/EmergencyFollow'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Follow Emergency', + visibility: 'is-authenticated', + }, +}); + +const emergencyIndex = customWrapRoute({ + parent: emergenciesLayout, + index: true, + component: { + eagerLoad: true, + render: SmartNavigate, + props: { + to: 'details' satisfies DefaultEmergenciesChild, + replace: true, + hashToRouteMap: { + '#details': 'details', + '#reports': 'reports', + '#activities': 'activities', + '#surge': 'surge', + }, + // TODO: make this typesafe + forwardUnmatchedHashTo: 'additional-info', + }, + }, + context: { + title: 'Emergency', + visibility: 'anything', + }, +}); + +const emergencyDetails = customWrapRoute({ + parent: emergenciesLayout, + path: 'details' satisfies DefaultEmergenciesChild, + component: { + render: () => import('#views/EmergencyDetails'), + props: {}, + }, + context: { + title: 'Emergency Details', + visibility: 'anything', + }, +}); + +const emergencyReportsAndDocuments = customWrapRoute({ + parent: emergenciesLayout, + path: 'reports', + component: { + render: () => import('#views/EmergencyReportAndDocument'), + props: {}, + }, + context: { + title: 'Emergency Reports and Documents', + visibility: 'anything', + }, +}); + +const emergencyActivities = customWrapRoute({ + parent: emergenciesLayout, + path: 'activities', + component: { + render: () => import('#views/EmergencyActivities'), + props: {}, + }, + context: { + title: 'Emergency Activities', + visibility: 'anything', + }, +}); +const emergencySurge = customWrapRoute({ + parent: emergenciesLayout, + path: 'surge', + component: { + render: () => import('#views/EmergencySurge'), + props: {}, + }, + context: { + title: 'Emergency Surge', + visibility: 'anything', + }, +}); + +// TODO: remove this route +const emergencyAdditionalInfoOne = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-1', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 1, + }, + }, + context: { + title: 'Emergency Additional Tab 1', + visibility: 'anything', + }, +}); +// TODO: remove this route +const emergencyAdditionalInfoTwo = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-2', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 2, + }, + }, + context: { + title: 'Emergency Additional Tab 2', + visibility: 'anything', + }, +}); +// TODO: remove this route +const emergencyAdditionalInfoThree = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info-3', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: { + infoPageId: 3, + }, + }, + context: { + title: 'Emergency Additional Tab 3', + visibility: 'anything', + }, +}); + +const emergencyAdditionalInfo = customWrapRoute({ + parent: emergenciesLayout, + path: 'additional-info/:tabId?', + component: { + render: () => import('#views/EmergencyAdditionalTab'), + props: {}, + }, + context: { + title: 'Emergency Additional Info Tab', + visibility: 'anything', + }, +}); + +type DefaultPreparednessChild = 'global-summary'; +const preparednessLayout = customWrapRoute({ + parent: rootLayout, + path: 'preparedness', + forwardPath: 'global-summary' satisfies DefaultPreparednessChild, + component: { + render: () => import('#views/Preparedness'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Preparedness', + visibility: 'anything', + }, +}); + +const preparednessIndex = customWrapRoute({ + parent: preparednessLayout, + index: true, + component: { + eagerLoad: true, + render: SmartNavigate, + props: { + to: 'global-summary' satisfies DefaultPreparednessChild, + replace: true, + hashToRouteMap: { + '#global-summary': 'global-summary', + '#global-performance': 'global-performance', + '#resources-catalogue': 'resources-catalogue', + '#operational-learning': 'operational-learning', + }, + }, + }, + context: { + title: 'Preparedness', + visibility: 'anything', + }, +}); + +const preparednessGlobalSummary = customWrapRoute({ + parent: preparednessLayout, + path: 'global-summary' satisfies DefaultPreparednessChild, + component: { + render: () => import('#views/PreparednessGlobalSummary'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Preparedness - Global Summary', + visibility: 'anything', + }, +}); + +const preparednessGlobalPerformance = customWrapRoute({ + parent: preparednessLayout, + path: 'global-performance', + component: { + render: () => import('#views/PreparednessGlobalPerformance'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Preparedness - Global Performace', + visibility: 'anything', + }, +}); + +const preparednessGlobalCatalogue = customWrapRoute({ + parent: preparednessLayout, + path: 'resources-catalogue', + component: { + render: () => import('#views/PreparednessCatalogueResources'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Preparedness - Catalogue of Learning', + visibility: 'anything', + }, +}); + +// FIXME: update name to `preparednessOperationalLearning` +const preparednessGlobalOperational = customWrapRoute({ + parent: preparednessLayout, + path: 'operational-learning', + component: { + render: () => import('#views/PreparednessOperationalLearning'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Preparedness - Operational Learning', + visibility: 'anything', + }, +}); + +const globalThreeW = customWrapRoute({ + parent: rootLayout, + path: 'three-w/projects', + component: { + render: () => import('#views/GlobalThreeW'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Global 3W', + visibility: 'anything', + }, +}); + +const newThreeWProject = customWrapRoute({ + parent: rootLayout, + path: 'three-w/projects/new', + component: { + render: () => import('#views/ThreeWProjectForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New 3W Project', + visibility: 'is-authenticated', + }, +}); + +const threeWProjectDetail = customWrapRoute({ + parent: rootLayout, + path: 'three-w/projects/:projectId/', + component: { + render: () => import('#views/ThreeWProjectDetail'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: '3W Project Details', + visibility: 'anything', + }, +}); + +const threeWProjectEdit = customWrapRoute({ + parent: rootLayout, + path: 'three-w/projects/:projectId/edit', + component: { + render: () => import('#views/ThreeWProjectForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit 3W Project', + visibility: 'is-authenticated', + }, +}); + +const newThreeWActivity = customWrapRoute({ + parent: rootLayout, + path: 'three-w/activities/new', + component: { + render: () => import('#views/ThreeWActivityForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New 3W Activity', + visibility: 'is-authenticated', + }, +}); + +const threeWActivityDetail = customWrapRoute({ + parent: rootLayout, + path: 'three-w/activities/:activityId', + component: { + render: () => import('#views/ThreeWActivityDetail'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: '3W Activity Detail', + visibility: 'anything', + }, +}); + +const threeWActivityEdit = customWrapRoute({ + parent: rootLayout, + path: 'three-w/activities/:activityId/edit', + component: { + render: () => import('#views/ThreeWActivityForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit 3W Activity', + visibility: 'is-authenticated', + }, +}); + +type DefaultRiskWatchChild = 'seasonal'; +const riskWatchLayout = customWrapRoute({ + parent: rootLayout, + path: 'risk-watch', + forwardPath: 'seasonal' satisfies DefaultRiskWatchChild, + component: { + render: () => import('#views/RiskWatch'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Risk Watch', + visibility: 'anything', + }, +}); + +const riskWatchIndex = customWrapRoute({ + parent: riskWatchLayout, + index: true, + component: { + eagerLoad: true, + render: Navigate, + props: { + to: 'seasonal' satisfies DefaultRiskWatchChild, + replace: true, + }, + }, + context: { + title: 'Risk Watch', + visibility: 'anything', + }, +}); + +const riskWatchSeasonal = customWrapRoute({ + parent: riskWatchLayout, + path: 'seasonal' satisfies DefaultRiskWatchChild, + component: { + render: () => import('#views/RiskWatchSeasonal'), + props: {}, + }, + context: { + title: 'Seasonal Risk Watch', + visibility: 'anything', + }, +}); + +const riskWatchImminent = customWrapRoute({ + parent: riskWatchLayout, + path: 'imminent', + component: { + render: () => import('#views/RiskWatchImminent'), + props: {}, + }, + context: { + title: 'Imminent Risk Watch', + visibility: 'anything', + }, +}); + +type DefaultAccountChild = 'details'; +const accountLayout = customWrapRoute({ + parent: rootLayout, + path: 'account', + forwardPath: 'details' satisfies DefaultAccountChild, + component: { + render: () => import('#views/Account'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Account', + visibility: 'is-authenticated', + }, +}); + +const accountIndex = customWrapRoute({ + parent: accountLayout, + index: true, + component: { + eagerLoad: true, + render: SmartNavigate, + props: { + to: 'details' satisfies DefaultAccountChild, + replace: true, + hashToRouteMap: { + '#account-information': 'details', + '#notifications': 'notifications', + '#per-forms': 'my-forms/per', + '#my-dref-applications': 'my-forms/dref', + '#three-w-forms': 'my-forms/three-w', + }, + }, + }, + context: { + title: 'Account', + visibility: 'anything', + }, +}); + +const accountDetails = customWrapRoute({ + parent: accountLayout, + path: 'details' satisfies DefaultAccountChild, + component: { + render: () => import('#views/AccountDetails'), + props: {}, + }, + context: { + title: 'Account Details', + visibility: 'is-authenticated', + }, +}); + +type DefaultAccountMyFormsChild = 'field-report'; +const accountMyFormsLayout = customWrapRoute({ + parent: accountLayout, + path: 'my-forms', + forwardPath: 'field-report' satisfies DefaultAccountMyFormsChild, + component: { + render: () => import('#views/AccountMyFormsLayout'), + props: {}, + }, + context: { + title: 'Account - My Forms', + visibility: 'is-authenticated', + }, +}); + +const accountMyFormsIndex = customWrapRoute({ + parent: accountMyFormsLayout, + index: true, + component: { + eagerLoad: true, + render: Navigate, + props: { + to: 'field-report' satisfies DefaultAccountMyFormsChild, + replace: true, + }, + }, + context: { + title: 'Account - My Forms', + visibility: 'anything', + }, +}); + +const accountMyFormsFieldReport = customWrapRoute({ + parent: accountMyFormsLayout, + path: 'field-report' satisfies DefaultAccountMyFormsChild, + component: { + render: () => import('#views/AccountMyFormsFieldReport'), + props: {}, + }, + context: { + title: 'Account - Field Report Forms', + visibility: 'is-authenticated', + }, +}); + +const accountMyFormsPer = customWrapRoute({ + parent: accountMyFormsLayout, + path: 'per', + component: { + render: () => import('#views/AccountMyFormsPer'), + props: {}, + }, + context: { + title: 'Account - PER Forms', + visibility: 'is-authenticated', + }, +}); + +const accountMyFormsDref = customWrapRoute({ + parent: accountMyFormsLayout, + path: 'dref', + component: { + render: () => import('#views/AccountMyFormsDref'), + props: {}, + }, + context: { + title: 'Account - DREF Applications', + visibility: 'is-authenticated', + }, +}); + +const accountMyFormsThreeW = customWrapRoute({ + parent: accountMyFormsLayout, + path: 'three-w', + component: { + render: () => import('#views/AccountMyFormsThreeW'), + props: {}, + }, + context: { + title: 'Account - 3W', + visibility: 'is-authenticated', + }, +}); + +const accountNotifications = customWrapRoute({ + parent: accountLayout, + path: 'notifications', + component: { + render: () => import('#views/AccountNotifications'), + props: {}, + }, + context: { + title: 'Account - Notifications', + visibility: 'is-authenticated', + }, +}); + +const resources = customWrapRoute({ + parent: rootLayout, + path: 'resources', + component: { + render: () => import('#views/Resources'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Resources', + visibility: 'anything', + }, +}); + +const search = customWrapRoute({ + parent: rootLayout, + path: 'search', + component: { + render: () => import('#views/Search'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Search', + visibility: 'anything', + }, +}); + +const allThreeWProject = customWrapRoute({ + parent: rootLayout, + path: 'three-w/projects/all', + component: { + render: () => import('#views/AllThreeWProject'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All 3W Projects', + visibility: 'anything', + }, +}); + +const allThreeWActivity = customWrapRoute({ + parent: rootLayout, + path: 'three-w/activities/all', + component: { + render: () => import('#views/AllThreeWActivity'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All 3W Activities', + visibility: 'anything', + }, +}); + +const allAppeals = customWrapRoute({ + parent: rootLayout, + path: 'appeals/all', + component: { + render: () => import('#views/AllAppeals'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All Appeals', + visibility: 'anything', + }, +}); + +const allEmergencies = customWrapRoute({ + parent: rootLayout, + path: 'emergencies/all', + component: { + render: () => import('#views/AllEmergencies'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All Emergencies', + visibility: 'anything', + }, +}); + +const allFieldReports = customWrapRoute({ + parent: rootLayout, + path: 'field-reports/all', + component: { + render: () => import('#views/AllFieldReports'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All Field Reports', + visibility: 'anything', + }, +}); + +const allFlashUpdates = customWrapRoute({ + parent: rootLayout, + path: 'flash-updates/all', + component: { + render: () => import('#views/AllFlashUpdates'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All Flash Updates', + visibility: 'is-authenticated', + permissions: ({ isIfrcAdmin }) => isIfrcAdmin, + }, +}); + +const flashUpdateFormNew = customWrapRoute({ + parent: rootLayout, + path: 'flash-updates/new', + component: { + render: () => import('#views/FlashUpdateForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New Flash Update', + visibility: 'is-authenticated', + permissions: ({ isIfrcAdmin }) => isIfrcAdmin, + }, +}); + +const flashUpdateFormEdit = customWrapRoute({ + parent: rootLayout, + path: 'flash-updates/:flashUpdateId/edit', + component: { + render: () => import('#views/FlashUpdateForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit Flash Update', + visibility: 'is-authenticated', + permissions: ({ isIfrcAdmin }) => isIfrcAdmin, + }, +}); + +// FIXME: rename this route to flashUpdateDetails +const flashUpdateFormDetails = customWrapRoute({ + parent: rootLayout, + path: 'flash-updates/:flashUpdateId', + component: { + render: () => import('#views/FlashUpdateDetails'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Flash Update Details', + visibility: 'anything', + permissions: ({ isIfrcAdmin }) => isIfrcAdmin, + }, +}); + +const allSurgeAlerts = customWrapRoute({ + parent: rootLayout, + path: 'alerts/all', + component: { + render: () => import('#views/AllSurgeAlerts'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'All Surge Alerts', + visibility: 'anything', + }, +}); + +const newDrefApplicationForm = customWrapRoute({ + parent: rootLayout, + path: 'dref-applications/new', + component: { + render: () => import('#views/DrefApplicationForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New DREF Application Form', + visibility: 'is-authenticated', + }, +}); + +const drefApplicationForm = customWrapRoute({ + parent: rootLayout, + path: 'dref-applications/:drefId/edit', + component: { + render: () => import('#views/DrefApplicationForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit DREF Application Form', + visibility: 'is-authenticated', + }, +}); + +const drefApplicationExport = customWrapRoute({ + path: 'dref-applications/:drefId/export', + component: { + render: () => import('#views/DrefApplicationExport'), + props: {}, + }, + parent: rootLayout, + wrapperComponent: Auth, + context: { + title: 'DREF Application Export', + visibility: 'is-authenticated', + }, +}); + +const drefOperationalUpdateForm = customWrapRoute({ + parent: rootLayout, + path: 'dref-operational-updates/:opsUpdateId/edit', + component: { + render: () => import('#views/DrefOperationalUpdateForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit DREF Operational Update Form', + visibility: 'is-authenticated', + }, +}); + +const drefOperationalUpdateExport = customWrapRoute({ + path: 'dref-operational-updates/:opsUpdateId/export', + component: { + render: () => import('#views/DrefOperationalUpdateExport'), + props: {}, + }, + parent: rootLayout, + wrapperComponent: Auth, + context: { + title: 'DREF Operational Update Export', + visibility: 'is-authenticated', + }, +}); +const drefFinalReportForm = customWrapRoute({ + parent: rootLayout, + path: 'dref-final-reports/:finalReportId/edit', + component: { + render: () => import('#views/DrefFinalReportForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit DREF Final Report Form', + visibility: 'is-authenticated', + }, +}); + +const drefFinalReportExport = customWrapRoute({ + path: 'dref-final-reports/:finalReportId/export', + component: { + render: () => import('#views/DrefFinalReportExport'), + props: {}, + }, + parent: rootLayout, + wrapperComponent: Auth, + context: { + title: 'DREF Final Report Export', + visibility: 'is-authenticated', + }, +}); + +const fieldReportFormNew = customWrapRoute({ + parent: rootLayout, + path: 'field-reports/new', + component: { + render: () => import('#views/FieldReportForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New Field Report Form', + visibility: 'is-authenticated', + }, +}); + +const fieldReportFormEdit = customWrapRoute({ + parent: rootLayout, + path: 'field-reports/:fieldReportId/edit', + component: { + render: () => import('#views/FieldReportForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit Field Report Form', + visibility: 'is-authenticated', + }, +}); + +const fieldReportDetails = customWrapRoute({ + parent: rootLayout, + path: 'field-reports/:fieldReportId', + component: { + render: () => import('#views/FieldReportDetails'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Field Report Details', + visibility: 'anything', + }, +}); + +type DefaultPerProcessChild = 'new'; +const perProcessLayout = customWrapRoute({ + parent: rootLayout, + path: 'per-process', + forwardPath: 'new' satisfies DefaultPerProcessChild, + component: { + render: () => import('#views/PerProcessForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'PER Process', + visibility: 'is-authenticated', + }, +}); + +const perProcessFormIndex = customWrapRoute({ + parent: perProcessLayout, + index: true, + component: { + eagerLoad: true, + render: Navigate, + props: { + to: 'new' satisfies DefaultPerProcessChild, + replace: true, + }, + }, + context: { + title: 'PER Process', + visibility: 'anything', + }, +}); + +const newPerOverviewForm = customWrapRoute({ + parent: perProcessLayout, + path: 'new' satisfies DefaultPerProcessChild, + component: { + render: () => import('#views/PerOverviewForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'New PER Overview', + visibility: 'is-authenticated', + permissions: ({ + isSuperUser, + isPerAdmin, + }) => isSuperUser || isPerAdmin, + }, +}); + +const perOverviewForm = customWrapRoute({ + parent: perProcessLayout, + path: ':perId/overview', + component: { + render: () => import('#views/PerOverviewForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit PER Overview', + visibility: 'is-authenticated', + }, +}); + +const perAssessmentForm = customWrapRoute({ + parent: perProcessLayout, + path: ':perId/assessment', + component: { + render: () => import('#views/PerAssessmentForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit PER Assessment', + visibility: 'is-authenticated', + }, +}); + +const perPrioritizationForm = customWrapRoute({ + parent: perProcessLayout, + path: ':perId/prioritization', + component: { + render: () => import('#views/PerPrioritizationForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit PER Prioritization', + visibility: 'is-authenticated', + }, +}); + +const perWorkPlanForm = customWrapRoute({ + parent: perProcessLayout, + path: ':perId/work-plan', + component: { + render: () => import('#views/PerWorkPlanForm'), + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'Edit PER Work Plan', + visibility: 'is-authenticated', + }, +}); + +// Redirect Routes + +const wrappedRoutes = { + fourHundredFour, + rootLayout, + login, + register, + recoverAccount, + recoverAccountConfirm, + resendValidationEmail, + home, + emergencies, + emergencySlug, + emergencyFollow, + emergenciesLayout, + emergencyDetails, + emergencyIndex, + emergencyReportsAndDocuments, + emergencyActivities, + emergencySurge, + emergencyAdditionalInfoOne, + emergencyAdditionalInfoTwo, + emergencyAdditionalInfoThree, + emergencyAdditionalInfo, + preparednessLayout, + preparednessGlobalSummary, + preparednessGlobalPerformance, + preparednessGlobalCatalogue, + preparednessGlobalOperational, + preparednessIndex, + perProcessFormIndex, + globalThreeW, + newThreeWProject, + threeWProjectEdit, + threeWActivityEdit, + threeWActivityDetail, + newThreeWActivity, + accountLayout, + accountIndex, + accountDetails, + accountMyFormsLayout, + accountMyFormsIndex, + accountNotifications, + accountMyFormsFieldReport, + accountMyFormsPer, + accountMyFormsDref, + accountMyFormsThreeW, + resources, + search, + allThreeWProject, + allThreeWActivity, + allAppeals, + allEmergencies, + allFieldReports, + allSurgeAlerts, + allFlashUpdates, + newDrefApplicationForm, + drefApplicationForm, + drefApplicationExport, + drefOperationalUpdateForm, + drefOperationalUpdateExport, + drefFinalReportForm, + drefFinalReportExport, + fieldReportFormNew, + fieldReportFormEdit, + fieldReportDetails, + flashUpdateFormNew, + flashUpdateFormDetails, + flashUpdateFormEdit, + riskWatchLayout, + riskWatchIndex, + riskWatchImminent, + riskWatchSeasonal, + perProcessLayout, + perOverviewForm, + newPerOverviewForm, + perAssessmentForm, + perPrioritizationForm, + perWorkPlanForm, + threeWProjectDetail, + + ...regionRoutes, + ...countryRoutes, + ...surgeRoutes, + +}; + +export const unwrappedRoutes = unwrapRoute(Object.values(wrappedRoutes)); + +export default wrappedRoutes; + +export type WrappedRoutes = typeof wrappedRoutes; diff --git a/src/App/styles.module.css b/app/src/App/styles.module.css similarity index 100% rename from src/App/styles.module.css rename to app/src/App/styles.module.css diff --git a/src/assets/content/operational_timeline_body.svg b/app/src/assets/content/operational_timeline_body.svg similarity index 100% rename from src/assets/content/operational_timeline_body.svg rename to app/src/assets/content/operational_timeline_body.svg diff --git a/src/assets/content/operational_timeline_title.svg b/app/src/assets/content/operational_timeline_title.svg similarity index 100% rename from src/assets/content/operational_timeline_title.svg rename to app/src/assets/content/operational_timeline_title.svg diff --git a/src/assets/content/per_approach_notext.svg b/app/src/assets/content/per_approach_notext.svg similarity index 100% rename from src/assets/content/per_approach_notext.svg rename to app/src/assets/content/per_approach_notext.svg diff --git a/src/assets/icons/arc_logo.png b/app/src/assets/icons/arc_logo.png similarity index 100% rename from src/assets/icons/arc_logo.png rename to app/src/assets/icons/arc_logo.png diff --git a/src/assets/icons/aurc_logo.svg b/app/src/assets/icons/aurc_logo.svg similarity index 100% rename from src/assets/icons/aurc_logo.svg rename to app/src/assets/icons/aurc_logo.svg diff --git a/src/assets/icons/brc_logo.png b/app/src/assets/icons/brc_logo.png similarity index 100% rename from src/assets/icons/brc_logo.png rename to app/src/assets/icons/brc_logo.png diff --git a/src/assets/icons/crc_logo.png b/app/src/assets/icons/crc_logo.png similarity index 100% rename from src/assets/icons/crc_logo.png rename to app/src/assets/icons/crc_logo.png diff --git a/src/assets/icons/dnk_logo.png b/app/src/assets/icons/dnk_logo.png similarity index 100% rename from src/assets/icons/dnk_logo.png rename to app/src/assets/icons/dnk_logo.png diff --git a/src/assets/icons/ericsson_logo.png b/app/src/assets/icons/ericsson_logo.png similarity index 100% rename from src/assets/icons/ericsson_logo.png rename to app/src/assets/icons/ericsson_logo.png diff --git a/src/assets/icons/eru.jpg b/app/src/assets/icons/eru.jpg similarity index 100% rename from src/assets/icons/eru.jpg rename to app/src/assets/icons/eru.jpg diff --git a/src/assets/icons/esp_logo.svg b/app/src/assets/icons/esp_logo.svg similarity index 100% rename from src/assets/icons/esp_logo.svg rename to app/src/assets/icons/esp_logo.svg diff --git a/src/assets/icons/frc_logo.png b/app/src/assets/icons/frc_logo.png similarity index 100% rename from src/assets/icons/frc_logo.png rename to app/src/assets/icons/frc_logo.png diff --git a/src/assets/icons/go-logo-2020.svg b/app/src/assets/icons/go-logo-2020.svg similarity index 100% rename from src/assets/icons/go-logo-2020.svg rename to app/src/assets/icons/go-logo-2020.svg diff --git a/src/assets/icons/ifrc-square.png b/app/src/assets/icons/ifrc-square.png similarity index 100% rename from src/assets/icons/ifrc-square.png rename to app/src/assets/icons/ifrc-square.png diff --git a/src/assets/icons/jrc_logo.png b/app/src/assets/icons/jrc_logo.png similarity index 100% rename from src/assets/icons/jrc_logo.png rename to app/src/assets/icons/jrc_logo.png diff --git a/src/assets/icons/nlrc_logo.jpg b/app/src/assets/icons/nlrc_logo.jpg similarity index 100% rename from src/assets/icons/nlrc_logo.jpg rename to app/src/assets/icons/nlrc_logo.jpg diff --git a/src/assets/icons/pdc_logo.svg b/app/src/assets/icons/pdc_logo.svg similarity index 100% rename from src/assets/icons/pdc_logo.svg rename to app/src/assets/icons/pdc_logo.svg diff --git a/src/assets/icons/risk/cyclone.png b/app/src/assets/icons/risk/cyclone.png similarity index 100% rename from src/assets/icons/risk/cyclone.png rename to app/src/assets/icons/risk/cyclone.png diff --git a/src/assets/icons/risk/drought.png b/app/src/assets/icons/risk/drought.png similarity index 100% rename from src/assets/icons/risk/drought.png rename to app/src/assets/icons/risk/drought.png diff --git a/src/assets/icons/risk/earthquake.png b/app/src/assets/icons/risk/earthquake.png similarity index 100% rename from src/assets/icons/risk/earthquake.png rename to app/src/assets/icons/risk/earthquake.png diff --git a/src/assets/icons/risk/flood.png b/app/src/assets/icons/risk/flood.png similarity index 100% rename from src/assets/icons/risk/flood.png rename to app/src/assets/icons/risk/flood.png diff --git a/src/assets/icons/risk/wildfire.png b/app/src/assets/icons/risk/wildfire.png similarity index 100% rename from src/assets/icons/risk/wildfire.png rename to app/src/assets/icons/risk/wildfire.png diff --git a/src/assets/icons/swiss.svg b/app/src/assets/icons/swiss.svg similarity index 100% rename from src/assets/icons/swiss.svg rename to app/src/assets/icons/swiss.svg diff --git a/src/assets/icons/us_aid.svg b/app/src/assets/icons/us_aid.svg similarity index 100% rename from src/assets/icons/us_aid.svg rename to app/src/assets/icons/us_aid.svg diff --git a/src/assets/images/surge-im-composition.jpg b/app/src/assets/images/surge-im-composition.jpg similarity index 100% rename from src/assets/images/surge-im-composition.jpg rename to app/src/assets/images/surge-im-composition.jpg diff --git a/src/assets/images/surge-im-pyramid.png b/app/src/assets/images/surge-im-pyramid.png similarity index 100% rename from src/assets/images/surge-im-pyramid.png rename to app/src/assets/images/surge-im-pyramid.png diff --git a/src/assets/images/surge-im-support-responsible-operation.jpg b/app/src/assets/images/surge-im-support-responsible-operation.jpg similarity index 100% rename from src/assets/images/surge-im-support-responsible-operation.jpg rename to app/src/assets/images/surge-im-support-responsible-operation.jpg diff --git a/app/src/components/CatalogueInfoCard/index.tsx b/app/src/components/CatalogueInfoCard/index.tsx new file mode 100644 index 000000000..d2b522a60 --- /dev/null +++ b/app/src/components/CatalogueInfoCard/index.tsx @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { + Container, + List, +} from '@ifrc-go/ui'; +import { _cs } from '@togglecorp/fujs'; + +import Link, { Props as LinkProps } from '#components/Link'; + +import styles from './styles.module.css'; + +export type LinkData = LinkProps & { + title: string; +} + +const catalogueInfoKeySelector = (item: LinkData) => item.title; +interface Props { + className?: string; + title: string; + data: LinkData[]; + description?: string; + descriptionClassName?: string; +} + +function CatalogueInfoCard(props: Props) { + const { + className, + title, + data, + description, + descriptionClassName, + } = props; + + const rendererParams = useCallback( + (_: string, value: LinkData): LinkProps => { + if (value.external) { + return { + href: value.href, + children: value.title, + external: true, + withLinkIcon: value.withLinkIcon, + }; + } + + return { + to: value.to, + urlParams: value.urlParams, + urlSearch: value.urlSearch, + urlHash: value.urlHash, + children: value.title, + withLinkIcon: value.withLinkIcon, + }; + }, + [], + ); + + return ( + + + + ); +} + +export default CatalogueInfoCard; diff --git a/src/components/CatalogueInfoCard/styles.module.css b/app/src/components/CatalogueInfoCard/styles.module.css similarity index 100% rename from src/components/CatalogueInfoCard/styles.module.css rename to app/src/components/CatalogueInfoCard/styles.module.css diff --git a/app/src/components/DropdownMenuItem/index.tsx b/app/src/components/DropdownMenuItem/index.tsx new file mode 100644 index 000000000..24b6ac22f --- /dev/null +++ b/app/src/components/DropdownMenuItem/index.tsx @@ -0,0 +1,125 @@ +import { + useCallback, + useContext, +} from 'react'; +import { + Button, + ButtonProps, + ConfirmButton, + ConfirmButtonProps, +} from '@ifrc-go/ui'; +import { DropdownMenuContext } from '@ifrc-go/ui/contexts'; +import { isDefined } from '@togglecorp/fujs'; + +import Link, { Props as LinkProps } from '#components/Link'; + +type CommonProp = { + persist?: boolean; +} + +type ButtonTypeProps = Omit, 'type'> & { + type: 'button'; +} + +type LinkTypeProps = LinkProps & { + type: 'link'; +} + +type ConfirmButtonTypeProps = Omit, 'type'> & { + type: 'confirm-button', +} + +type Props = CommonProp & (ButtonTypeProps | LinkTypeProps | ConfirmButtonTypeProps); + +function DropdownMenuItem(props: Props) { + const { + type, + onClick, + persist = false, + } = props; + const { setShowDropdown } = useContext(DropdownMenuContext); + + const handleLinkClick = useCallback( + () => { + if (!persist) { + setShowDropdown(false); + } + // TODO: maybe add onClick here? + }, + [setShowDropdown, persist], + ); + + const handleButtonClick = useCallback( + (name: NAME, e: React.MouseEvent) => { + if (!persist) { + setShowDropdown(false); + } + if (isDefined(onClick) && type !== 'link') { + onClick(name, e); + } + }, + [setShowDropdown, type, onClick, persist], + ); + + if (type === 'link') { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type: _, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persist: __, + variant = 'dropdown-item', + ...otherProps + } = props; + + return ( + + ); + } + + if (type === 'button') { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type: _, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persist: __, + variant = 'dropdown-item', + ...otherProps + } = props; + + return ( + + + + + + )} + /> + )} +
+ {printMode && ( +
+ {title} + + + )} + actions={( + {strings.downloadHeaderLogoAltText} + )} + /> + )} +
+ + +
+ {strings.mapDisclaimer} +
+
+ {mapSources} +
+
+ + {strings.copyrightMapbox} + + + {strings.copyrightOSM} + + + {strings.improveMapLabel} + +
+
+ )} + /> +
+ {!printMode && !withoutDownloadButton && ( + + + + )} + {footer} + + + ); +} + +export default MapContainerWithDisclaimer; diff --git a/src/components/MapContainerWithDisclaimer/styles.module.css b/app/src/components/MapContainerWithDisclaimer/styles.module.css similarity index 100% rename from src/components/MapContainerWithDisclaimer/styles.module.css rename to app/src/components/MapContainerWithDisclaimer/styles.module.css diff --git a/src/components/MapPopup/i18n.json b/app/src/components/MapPopup/i18n.json similarity index 100% rename from src/components/MapPopup/i18n.json rename to app/src/components/MapPopup/i18n.json diff --git a/app/src/components/MapPopup/index.tsx b/app/src/components/MapPopup/index.tsx new file mode 100644 index 000000000..2961bb258 --- /dev/null +++ b/app/src/components/MapPopup/index.tsx @@ -0,0 +1,77 @@ +import { CloseLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + ContainerProps, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; +import { MapPopup as BasicMapPopup } from '@togglecorp/re-map'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const popupOptions: mapboxgl.PopupOptions = { + closeButton: false, + closeOnClick: false, + closeOnMove: false, + offset: 8, + className: styles.mapPopup, + maxWidth: 'unset', +}; + +interface Props extends ContainerProps { + coordinates: mapboxgl.LngLatLike; + children: React.ReactNode; + onCloseButtonClick: () => void; +} + +function MapPopup(props: Props) { + const { + children, + coordinates, + onCloseButtonClick, + actions, + childrenContainerClassName, + ...containerProps + } = props; + + const strings = useTranslation(i18n); + + return ( + + ); +} + +export default MapPopup; diff --git a/src/components/MapPopup/styles.module.css b/app/src/components/MapPopup/styles.module.css similarity index 100% rename from src/components/MapPopup/styles.module.css rename to app/src/components/MapPopup/styles.module.css diff --git a/src/components/Navbar/AuthenticatedUserDropdown/i18n.json b/app/src/components/Navbar/AuthenticatedUserDropdown/i18n.json similarity index 100% rename from src/components/Navbar/AuthenticatedUserDropdown/i18n.json rename to app/src/components/Navbar/AuthenticatedUserDropdown/i18n.json diff --git a/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx b/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx new file mode 100644 index 000000000..01932e17a --- /dev/null +++ b/app/src/components/Navbar/AuthenticatedUserDropdown/index.tsx @@ -0,0 +1,72 @@ +import { + useCallback, + useContext, +} from 'react'; +import { DropdownMenu } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import UserContext from '#contexts/user'; +import useAuth from '#hooks/domain/useAuth'; +import useUserMe from '#hooks/domain/useUserMe'; +import { getUserName } from '#utils/domain/user'; + +import i18n from './i18n.json'; + +interface Props { + className?: string; +} + +function AuthenticatedUserDropdown(props: Props) { + const { + className, + } = props; + + const strings = useTranslation(i18n); + const { isAuthenticated } = useAuth(); + + const { + userAuth: userDetails, + removeUserAuth: removeUser, + } = useContext(UserContext); + const userMe = useUserMe(); + + const handleLogoutConfirm = useCallback(() => { + removeUser(); + window.location.reload(); + }, [removeUser]); + + if (!isAuthenticated) { + return null; + } + + return ( + + + {strings.userMenuAccount} + + + {strings.userMenuLogout} + + + ); +} + +export default AuthenticatedUserDropdown; diff --git a/src/components/Navbar/CountryDropdown/i18n.json b/app/src/components/Navbar/CountryDropdown/i18n.json similarity index 100% rename from src/components/Navbar/CountryDropdown/i18n.json rename to app/src/components/Navbar/CountryDropdown/i18n.json diff --git a/app/src/components/Navbar/CountryDropdown/index.tsx b/app/src/components/Navbar/CountryDropdown/index.tsx new file mode 100644 index 000000000..70ee7fa28 --- /dev/null +++ b/app/src/components/Navbar/CountryDropdown/index.tsx @@ -0,0 +1,201 @@ +import { + useContext, + useMemo, + useState, +} from 'react'; +import { + matchPath, + useLocation, +} from 'react-router-dom'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { + Container, + DropdownMenu, + Message, + Tab, + TabList, + TabPanel, + Tabs, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { rankedSearchOnList } from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isFalsyString, + isNotDefined, +} from '@togglecorp/fujs'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import RouteContext from '#contexts/route'; +import useCountry from '#hooks/domain/useCountry'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useInputState from '#hooks/useInputState'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function CountryDropdown() { + const location = useLocation(); + + const strings = useTranslation(i18n); + const { + regionsLayout: regionRoute, + } = useContext(RouteContext); + const { + api_region_name: regionOptions, + } = useGlobalEnums(); + type RegionKey = NonNullable<(typeof regionOptions)>[number]['key']; + + const match = matchPath( + { + path: regionRoute.absolutePath, + end: false, + }, + location.pathname, + ); + + const regionIdFromMatch = useMemo( + () => { + const regionId = match?.params?.regionId; + if (isFalsyString(regionId)) { + return undefined; + } + + const regionIdSafe = Number(regionId); + + if ( + regionIdSafe !== 0 + && regionIdSafe !== 1 + && regionIdSafe !== 2 + && regionIdSafe !== 3 + && regionIdSafe !== 4 + ) { + return undefined; + } + + return regionIdSafe; + }, + [match], + ); + + const isEmpty = isNotDefined(regionOptions) || regionOptions.length === 0; + + const [activeRegion, setActiveRegion] = useState(regionIdFromMatch ?? 0); + const [countrySearch, setCountrySearch] = useInputState(undefined); + + const allCountries = useCountry(); + const countriesInSelectedRegion = useMemo( + () => ( + rankedSearchOnList( + allCountries?.filter(({ region }) => region === activeRegion), + countrySearch, + ({ name }) => name, + ) + ), + [activeRegion, allCountries, countrySearch], + ); + + return ( + + {isEmpty && ( + + )} + {!isEmpty && ( + + + {regionOptions?.map( + (region) => ( + + {region.value} + + ), + )} + +
+ {regionOptions?.map( + (region) => ( + + + {/* FIXME: use translation */} + {`${region.value} Region`} + + )} + headingLevel={4} + childrenContainerClassName={styles.countryList} + actions={( + } + /> + )} + > + {/* TODO: use List */} + {countriesInSelectedRegion?.map( + ({ id, name }) => ( + + {name} + + ), + )} + + + ), + )} + + )} + + ); +} + +export default CountryDropdown; diff --git a/src/components/Navbar/CountryDropdown/styles.module.css b/app/src/components/Navbar/CountryDropdown/styles.module.css similarity index 100% rename from src/components/Navbar/CountryDropdown/styles.module.css rename to app/src/components/Navbar/CountryDropdown/styles.module.css diff --git a/app/src/components/Navbar/LanguageDropdown/index.tsx b/app/src/components/Navbar/LanguageDropdown/index.tsx new file mode 100644 index 000000000..29ac0e8c1 --- /dev/null +++ b/app/src/components/Navbar/LanguageDropdown/index.tsx @@ -0,0 +1,93 @@ +import { + useCallback, + useContext, + useEffect, +} from 'react'; +import { CheckFillIcon } from '@ifrc-go/icons'; +import { DropdownMenu } from '@ifrc-go/ui'; +import { + type Language, + LanguageContext, +} from '@ifrc-go/ui/contexts'; +import { languageNameMapEn } from '@ifrc-go/ui/utils'; +import { + _cs, + mapToList, +} from '@togglecorp/fujs'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import styles from './styles.module.css'; + +// NOTE: these doesn't need to be translated +const languageNameMap: Record = { + en: 'English', + fr: 'Français', + es: 'Español', + ar: 'عربي', +}; + +const languageList = mapToList( + languageNameMap, + (value, key) => ({ key: key as Language, value }), +); + +function LangaugeDropdown() { + const { + currentLanguage, + setCurrentLanguage, + } = useContext(LanguageContext); + + useEffect( + () => { + if (currentLanguage === 'ar') { + document.body.style.direction = 'rtl'; + document.body.setAttribute('dir', 'rtl'); + } else { + document.body.style.direction = 'ltr'; + document.body.setAttribute('dir', 'ltr'); + } + }, + [currentLanguage], + ); + + const handleLanguageConfirm = useCallback( + (newLanguage: Language) => { + setCurrentLanguage(newLanguage); + window.location.reload(); + }, + [setCurrentLanguage], + ); + + return ( + + {languageList.map( + (language) => ( + + )} + > + {language.value} + + ), + )} + + ); +} + +export default LangaugeDropdown; diff --git a/src/components/Navbar/LanguageDropdown/styles.module.css b/app/src/components/Navbar/LanguageDropdown/styles.module.css similarity index 100% rename from src/components/Navbar/LanguageDropdown/styles.module.css rename to app/src/components/Navbar/LanguageDropdown/styles.module.css diff --git a/src/components/Navbar/i18n.json b/app/src/components/Navbar/i18n.json similarity index 100% rename from src/components/Navbar/i18n.json rename to app/src/components/Navbar/i18n.json diff --git a/app/src/components/Navbar/index.tsx b/app/src/components/Navbar/index.tsx new file mode 100644 index 000000000..550f34e77 --- /dev/null +++ b/app/src/components/Navbar/index.tsx @@ -0,0 +1,505 @@ +import { useState } from 'react'; +import { + DropdownMenu, + NavigationTabList, + PageContainer, + Tab, + TabList, + TabPanel, + Tabs, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import goLogo from '#assets/icons/go-logo-2020.svg'; +import KeywordSearchSelectInput from '#components/domain/KeywordSearchSelectInput'; +import DropdownMenuItem from '#components/DropdownMenuItem'; +import Link from '#components/Link'; +import NavigationTab from '#components/NavigationTab'; +import { environment } from '#config'; +import useAuth from '#hooks/domain/useAuth'; + +import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import CountryDropdown from './CountryDropdown'; +import LangaugeDropdown from './LanguageDropdown'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + className?: string; +} + +function Navbar(props: Props) { + const { + className, + } = props; + + const { isAuthenticated } = useAuth(); + const strings = useTranslation(i18n); + + type PrepareOptionKey = 'risk-analysis' | 'per' | 'global-3w-projects'; + const [activePrepareOption, setActivePrepareOption] = useState('risk-analysis'); + + type RespondOptionKey = 'emergencies' | 'early-warning' | 'dref-process' | 'surge'; + const [activeRespondOption, setActiveRespondOption] = useState('emergencies'); + + type LearnOptionKey = 'tools' | 'resources'; + const [activeLearnOption, setActiveLearnOption] = useState('tools'); + + return ( + + ); +} + +export default Navbar; diff --git a/src/components/Navbar/styles.module.css b/app/src/components/Navbar/styles.module.css similarity index 100% rename from src/components/Navbar/styles.module.css rename to app/src/components/Navbar/styles.module.css diff --git a/app/src/components/NavigationTab/index.tsx b/app/src/components/NavigationTab/index.tsx new file mode 100644 index 000000000..e1c72e8e3 --- /dev/null +++ b/app/src/components/NavigationTab/index.tsx @@ -0,0 +1,199 @@ +import { + useCallback, + useContext, + useMemo, +} from 'react'; +import { + matchPath, + NavLink, + useLocation, +} from 'react-router-dom'; +import { CheckFillIcon } from '@ifrc-go/icons'; +import { NavigationTabContext } from '@ifrc-go/ui/contexts'; +import { + _cs, + isFalsyString, + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import { + type UrlParams, + useLink, +} from '#components/Link'; +import RouteContext from '#contexts/route'; + +import { type WrappedRoutes } from '../../App/routes'; + +import styles from './styles.module.css'; + +interface Props { + className?: string; + children?: React.ReactNode; + stepCompleted?: boolean; + title?: string; + disabled?: boolean; + + parentRoute?: boolean; + matchParam?: string; + + to: keyof WrappedRoutes | undefined | null; + + urlParams?: UrlParams; + urlSearch?: string; + urlHash?: string; +} + +function NavigationTab(props: Props) { + const { + children, + to, + urlParams, + urlSearch, + urlHash, + className, + title, + stepCompleted, + disabled: disabledFromProps, + parentRoute = false, + matchParam, + } = props; + + const { + variant, + className: classNameFromContext, + } = useContext(NavigationTabContext); + + const location = useLocation(); + + const routes = useContext(RouteContext); + + const { + disabled: disabledLink, + to: toLink, + } = useLink({ + to, + external: false, + urlParams, + }); + + const disabled = disabledLink || disabledFromProps; + + const handleClick = useCallback(( + event: React.MouseEvent | undefined, + ) => { + if (disabled) { + event?.preventDefault(); + } + }, [disabled]); + + const matchParamValue = isTruthyString(matchParam) + ? urlParams?.[matchParam] + : undefined; + + const isActive = useMemo( + () => { + if (isNotDefined(to)) { + return false; + } + + const match = matchPath( + { + // eslint-disable-next-line react/destructuring-assignment + path: routes[to].absolutePath, + end: !parentRoute, + }, + location.pathname, + ); + + if (isNotDefined(match)) { + return false; + } + + if (isTruthyString(matchParam)) { + const paramValue = match.params[matchParam]; + + if (isFalsyString(paramValue)) { + return false; + } + + return matchParamValue === paramValue; + } + + return true; + }, + [to, routes, location.pathname, matchParam, parentRoute, matchParamValue], + ); + + const linkClassName = useMemo( + () => { + const defaultClassName = _cs( + styles.navigationTab, + variant === 'primary' && styles.primary, + variant === 'secondary' && styles.secondary, + variant === 'tertiary' && styles.tertiary, + variant === 'step' && styles.step, + variant === 'vertical' && styles.vertical, + stepCompleted && styles.completed, + classNameFromContext, + className, + disabled && styles.disabled, + ); + + if (!isActive) { + return defaultClassName; + } + + return _cs( + styles.active, + defaultClassName, + ); + }, + [ + className, + classNameFromContext, + stepCompleted, + variant, + isActive, + disabled, + ], + ); + + return ( + + {variant === 'step' && ( +
+
+
+
+ {stepCompleted && ( + + )} +
+
+
+
+ )} + {variant === 'primary' && ( +
+ )} +
+ {children} +
+ {variant === 'primary' && ( +
+ )} + + ); +} + +export default NavigationTab; diff --git a/src/components/NavigationTab/styles.module.css b/app/src/components/NavigationTab/styles.module.css similarity index 100% rename from src/components/NavigationTab/styles.module.css rename to app/src/components/NavigationTab/styles.module.css diff --git a/src/components/NonFieldError/i18n.json b/app/src/components/NonFieldError/i18n.json similarity index 100% rename from src/components/NonFieldError/i18n.json rename to app/src/components/NonFieldError/i18n.json diff --git a/app/src/components/NonFieldError/index.tsx b/app/src/components/NonFieldError/index.tsx new file mode 100644 index 000000000..8602c6932 --- /dev/null +++ b/app/src/components/NonFieldError/index.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { AlertLineIcon } from '@ifrc-go/icons'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isFalsyString, + isNotDefined, +} from '@togglecorp/fujs'; +import { + analyzeErrors, + Error, + getErrorObject, + nonFieldError, +} from '@togglecorp/toggle-form'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +export interface Props { + className?: string; + error?: Error; + withFallbackError?: boolean; +} + +function NonFieldError(props: Props) { + const { + className, + error, + withFallbackError, + } = props; + + const strings = useTranslation(i18n); + const errorObject = useMemo(() => getErrorObject(error), [error]); + + if (isNotDefined(errorObject)) { + return null; + } + + const hasError = analyzeErrors(errorObject); + if (!hasError) { + return null; + } + + const stringError = errorObject?.[nonFieldError] || ( + withFallbackError ? strings.fallbackMessage : undefined); + + if (isFalsyString(stringError)) { + return null; + } + + return ( +
+ +
+ {stringError} +
+
+ ); +} + +export default NonFieldError; diff --git a/src/components/NonFieldError/styles.module.css b/app/src/components/NonFieldError/styles.module.css similarity index 100% rename from src/components/NonFieldError/styles.module.css rename to app/src/components/NonFieldError/styles.module.css diff --git a/src/components/Page/i18n.json b/app/src/components/Page/i18n.json similarity index 100% rename from src/components/Page/i18n.json rename to app/src/components/Page/i18n.json diff --git a/app/src/components/Page/index.tsx b/app/src/components/Page/index.tsx new file mode 100644 index 000000000..377c653ec --- /dev/null +++ b/app/src/components/Page/index.tsx @@ -0,0 +1,151 @@ +import { + ElementRef, + RefObject, + useEffect, +} from 'react'; +import { + PageContainer, + PageHeader, +} from '@ifrc-go/ui'; +import { type Language } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + languageNameMapEn, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { components } from '#generated/types'; +import useCurrentLanguage from '#hooks/domain/useCurrentLanguage'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type TranslationModuleOriginalLanguageEnum = components<'read'>['schemas']['TranslationModuleOriginalLanguageEnum']; + +interface Props { + className?: string; + title?: string; + actions?: React.ReactNode; + heading?: React.ReactNode; + description?: React.ReactNode; + descriptionContainerClassName?: string; + mainSectionContainerClassName?: string; + breadCrumbs?: React.ReactNode; + info?: React.ReactNode; + children?: React.ReactNode; + mainSectionClassName?: string; + infoContainerClassName?: string; + wikiLink?: React.ReactNode; + withBackgroundColorInMainSection?: boolean; + elementRef?: RefObject>; + blockingContent?: React.ReactNode; + contentOriginalLanguage?: TranslationModuleOriginalLanguageEnum; + beforeHeaderContent?: React.ReactNode; +} + +function Page(props: Props) { + const { + className, + title, + actions, + heading, + description, + descriptionContainerClassName, + breadCrumbs, + info, + children, + mainSectionContainerClassName, + mainSectionClassName, + infoContainerClassName, + wikiLink, + withBackgroundColorInMainSection, + elementRef, + blockingContent, + contentOriginalLanguage, + beforeHeaderContent, + } = props; + + const currentLanguage = useCurrentLanguage(); + const strings = useTranslation(i18n); + + useEffect(() => { + if (isDefined(title)) { + document.title = title; + } + }, [title]); + + const showMachineTranslationWarning = isDefined(contentOriginalLanguage) + && contentOriginalLanguage !== currentLanguage; + + const showPageContainer = !!breadCrumbs + || !!heading + || !!description + || !!info + || !!actions + || !!wikiLink; + + return ( +
+ {isNotDefined(blockingContent) + && showMachineTranslationWarning + && ( +
+ {resolveToString( + strings.machineTranslatedContentWarning, + // eslint-disable-next-line max-len + { contentOriginalLanguage: languageNameMapEn[contentOriginalLanguage as Language] }, + )} +
+ )} + {beforeHeaderContent && ( + + {beforeHeaderContent} + + )} + {isNotDefined(blockingContent) && showPageContainer && ( + + )} + {isNotDefined(blockingContent) && ( + + { children } + + )} +
+ ); +} + +export default Page; diff --git a/src/components/Page/styles.module.css b/app/src/components/Page/styles.module.css similarity index 100% rename from src/components/Page/styles.module.css rename to app/src/components/Page/styles.module.css diff --git a/app/src/components/RichTextArea/index.tsx b/app/src/components/RichTextArea/index.tsx new file mode 100644 index 000000000..8ed2e84cd --- /dev/null +++ b/app/src/components/RichTextArea/index.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { + InputContainer, + InputContainerProps, +} from '@ifrc-go/ui'; +import { + Editor, + IAllProps, +} from '@tinymce/tinymce-react'; +import { _cs } from '@togglecorp/fujs'; + +import { tinyApiKey } from '#config'; + +import styles from './styles.module.css'; + +type RawEditorOptions = NonNullable; + +const editorOptions: Omit = { + menubar: false, // https://www.tiny.cloud/docs/advanced/available-toolbar-buttons + statusbar: false, + plugins: ['advlist autolink code help link lists preview'], + toolbar: 'formatselect | bold italic superscript link | ' + + 'alignleft aligncenter alignright alignjustify | ' + + 'bullist numlist outdent indent | code removeformat preview | help', + contextmenu: 'formats link', + // https://www.tiny.cloud/docs/configure/content-filtering/#invalid_styles + invalid_styles: { '*': 'opacity' }, +}; + +type InheritedProps = Omit & { + value: string | undefined; + name: T; + onChange?: ( + value: string | undefined, + name: T, + ) => void; +} +export interface Props extends InheritedProps { + inputElementRef?: React.RefObject; + inputClassName?: string; + placeholder?: string; +} + +function RichTextArea(props: Props) { + const { + className, + actions, + icons, + error, + label, + labelClassName, + disabled, + readOnly, + name, + value, + onChange, + inputSectionClassName, + hint, + withAsterisk, + errorOnTooltip, + required, + variant, + ...otherInputProps + } = props; + + const handleChange = React.useCallback((newValue: string | undefined) => { + if (readOnly || disabled || !onChange) { + return; + } + if (newValue === '') { + onChange(undefined, name); + } else { + onChange(newValue, name); + } + }, [ + onChange, + name, + readOnly, + disabled, + ]); + + // eslint-disable-next-line react/destructuring-assignment + if (props.placeholder !== undefined) { + // eslint-disable-next-line react/destructuring-assignment + editorOptions.placeholder = props.placeholder; + } + + return ( + + )} + /> + ); +} + +export default RichTextArea; diff --git a/src/components/RichTextArea/styles.module.css b/app/src/components/RichTextArea/styles.module.css similarity index 100% rename from src/components/RichTextArea/styles.module.css rename to app/src/components/RichTextArea/styles.module.css diff --git a/src/components/WikiLink/i18n.json b/app/src/components/WikiLink/i18n.json similarity index 100% rename from src/components/WikiLink/i18n.json rename to app/src/components/WikiLink/i18n.json diff --git a/app/src/components/WikiLink/index.tsx b/app/src/components/WikiLink/index.tsx new file mode 100644 index 000000000..e3def2c4f --- /dev/null +++ b/app/src/components/WikiLink/index.tsx @@ -0,0 +1,44 @@ +import { WikiHelpSectionLineIcon } from '@ifrc-go/icons'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import type { Props as LinkProps } from '#components/Link'; +import Link from '#components/Link'; +import useCurrentLanguage from '#hooks/domain/useCurrentLanguage'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type Props = Omit & { + icons?: React.ReactNode; + href: string | undefined | null; + className?: string; +} + +function WikiLink(props: Props) { + const { + icons, + href, + className, + ...otherProps + } = props; + + const strings = useTranslation(i18n); + const lang = useCurrentLanguage(); + + return ( + + + + ); +} + +export default WikiLink; diff --git a/src/components/WikiLink/styles.module.css b/app/src/components/WikiLink/styles.module.css similarity index 100% rename from src/components/WikiLink/styles.module.css rename to app/src/components/WikiLink/styles.module.css diff --git a/src/components/domain/ActiveOperationMap/i18n.json b/app/src/components/domain/ActiveOperationMap/i18n.json similarity index 100% rename from src/components/domain/ActiveOperationMap/i18n.json rename to app/src/components/domain/ActiveOperationMap/i18n.json diff --git a/app/src/components/domain/ActiveOperationMap/index.tsx b/app/src/components/domain/ActiveOperationMap/index.tsx new file mode 100644 index 000000000..f334bd6f9 --- /dev/null +++ b/app/src/components/domain/ActiveOperationMap/index.tsx @@ -0,0 +1,581 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { ArtboardLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + DateInput, + LegendItem, + RadioInput, + SelectInput, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + hasSomeDefinedValue, + resolveToComponent, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + listToGroupList, + mapToMap, + unique, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import type { LngLatBoundsLike } from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; +import Link from '#components/Link'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import useCountryRaw from '#hooks/domain/useCountryRaw'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useFilterState from '#hooks/useFilterState'; +import useInputState from '#hooks/useInputState'; +import { + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; +import { adminFillLayerOptions } from '#utils/map'; +import type { + GoApiResponse, + GoApiUrlQuery, +} from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import { + APPEAL_TYPE_DREF, + APPEAL_TYPE_EAP, + APPEAL_TYPE_EMERGENCY, + APPEAL_TYPE_MULTIPLE, + basePointLayerOptions, + COLOR_DREF, + COLOR_EAP, + COLOR_EMERGENCY_APPEAL, + COLOR_MULTIPLE_TYPES, + optionKeySelector, + optionLabelSelector, + outerCircleLayerOptionsForFinancialRequirements, + outerCircleLayerOptionsForPeopleTargeted, + ScaleOption, +} from './utils'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type AppealQueryParams = GoApiUrlQuery<'/api/v2/appeal/'>; +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; +type AppealTypeOption = NonNullable[number]; + +const appealTypeKeySelector = (option: AppealTypeOption) => option.key; +const appealTypeLabelSelector = (option: AppealTypeOption) => option.value; + +const now = new Date().toISOString(); +const sourceOptions: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +interface CountryProperties { + country_id: number; + disputed: boolean; + fdrs: string; + independent: boolean; + is_deprecated: boolean; + iso: string; + iso3: string; + name: string; + name_ar: string; + name_es: string; + name_fr: string; + record_type: number; + region_id: number; +} + +interface ClickedPoint { + feature: GeoJSON.Feature; + lngLat: mapboxgl.LngLatLike; +} + +type BaseProps = { + className?: string; + onPresentationModeButtonClick?: () => void; + presentationMode?: boolean; + bbox: LngLatBoundsLike | undefined; +} + +type CountryProps = { + variant: 'country'; + countryId: number; +} +type RegionProps = { + variant: 'region'; + regionId: number; +} + +type GlobalProps = { + variant: 'global'; +} + +type Props = BaseProps & (RegionProps | GlobalProps | CountryProps); + +function ActiveOperationMap(props: Props) { + const { + className, + variant, + onPresentationModeButtonClick, + presentationMode = false, + bbox, + } = props; + + const { + filter, + filtered, + limit, + rawFilter, + setFilter, + setFilterField, + } = useFilterState<{ + appeal?: AppealTypeOption['key'], + district?: number[], + displacement?: number, + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + pageSize: 9999, + }); + + // eslint-disable-next-line react/destructuring-assignment + const regionId = variant === 'region' ? props.regionId : undefined; + // eslint-disable-next-line react/destructuring-assignment + const countryId = variant === 'country' ? props.countryId : undefined; + // eslint-disable-next-line react/destructuring-assignment + + const query = useMemo( + () => { + const baseQuery: AppealQueryParams = { + atype: filter.appeal, + dtype: filter.displacement, + district: hasSomeDefinedValue(filter.district) ? filter.district : undefined, + end_date__gt: now, + start_date__gte: filter.startDateAfter, + start_date__lte: filter.startDateBefore, + limit, + }; + + if (variant === 'global') { + return baseQuery; + } + + return { + ...baseQuery, + region: regionId ? [regionId] : undefined, + country: countryId ? [countryId] : undefined, + }; + }, + [variant, regionId, filter, limit, countryId], + ); + + const [ + clickedPointProperties, + setClickedPointProperties, + ] = useState(); + + const [scaleBy, setScaleBy] = useInputState('peopleTargeted'); + const strings = useTranslation(i18n); + const { api_appeal_type: appealTypeOptions } = useGlobalEnums(); + const { + response: appealResponse, + } = useRequest({ + url: '/api/v2/appeal/', + query, + }); + + const countryResponse = useCountryRaw(); + + const scaleOptions: ScaleOption[] = useMemo(() => ([ + { value: 'peopleTargeted', label: strings.explanationBubblePopulationLabel }, + { value: 'financialRequirements', label: strings.explanationBubbleAmountLabel }, + ]), [ + strings.explanationBubblePopulationLabel, + strings.explanationBubbleAmountLabel, + ]); + + const legendOptions = useMemo(() => ([ + { + value: APPEAL_TYPE_EMERGENCY, + label: strings.explanationBubbleEmergencyAppeal, + color: COLOR_EMERGENCY_APPEAL, + }, + { + value: APPEAL_TYPE_DREF, + label: strings.explanationBubbleDref, + color: COLOR_DREF, + }, + { + value: APPEAL_TYPE_EAP, + label: strings.explanationBubbleEAP, + color: COLOR_EAP, + }, + { + value: APPEAL_TYPE_MULTIPLE, + label: strings.explanationBubbleMultiple, + color: COLOR_MULTIPLE_TYPES, + }, + ]), [ + strings.explanationBubbleEmergencyAppeal, + strings.explanationBubbleDref, + strings.explanationBubbleEAP, + strings.explanationBubbleMultiple, + ]); + + const countryGroupedAppeal = useMemo(() => ( + listToGroupList( + appealResponse?.results ?? [], + (appeal) => appeal.country.iso3 ?? '', + ) + ), [appealResponse]); + + const countryCentroidGeoJson = useMemo( + (): GeoJSON.FeatureCollection => { + const countryToOperationTypeMap = mapToMap( + countryGroupedAppeal, + (key) => key, + (appealList) => { + const uniqueAppealList = unique( + appealList.map((appeal) => appeal.atype), + ); + + const peopleTargeted = sumSafe( + appealList.map((appeal) => appeal.num_beneficiaries), + ); + const financialRequirements = sumSafe( + appealList.map((appeal) => appeal.amount_requested), + ); + + if (uniqueAppealList.length > 1) { + // multiple types + return { + appealType: APPEAL_TYPE_MULTIPLE, + peopleTargeted, + financialRequirements, + }; + } + + return { + appealType: uniqueAppealList[0], + peopleTargeted, + financialRequirements, + }; + }, + ); + + return { + type: 'FeatureCollection' as const, + features: countryResponse + ?.map((country) => { + if ( + (!country.independent && isNotDefined(country.record_type)) + || isNotDefined(country.centroid) + || isNotDefined(country.iso3) + ) { + return undefined; + } + + const operation = countryToOperationTypeMap[country.iso3]; + if (isNotDefined(operation)) { + return undefined; + } + + return { + type: 'Feature' as const, + geometry: country.centroid as { + type: 'Point', + coordinates: [number, number], + }, + properties: { + id: country.iso3, + appealType: operation.appealType, + peopleTargeted: operation.peopleTargeted, + financialRequirements: operation.financialRequirements, + }, + }; + }).filter(isDefined) ?? [], + }; + }, + [countryResponse, countryGroupedAppeal], + ); + + const allAppealsType = useMemo(() => { + if (isDefined(countryId)) { + return { + searchParam: `country=${countryId}`, + title: strings.operationMapViewAllInCountry, + }; + } + if (isDefined(regionId)) { + return { + searchParam: `region=${regionId}`, + title: strings.operationMapViewAllInRegion, + }; + } + return { + searchParam: undefined, + title: strings.operationMapViewAll, + }; + }, [ + countryId, + regionId, + strings.operationMapViewAllInCountry, + strings.operationMapViewAllInRegion, + strings.operationMapViewAll, + ]); + + const heading = resolveToComponent( + strings.activeOperationsTitle, + { numAppeals: appealResponse?.count ?? '--' }, + ); + + const handleCountryClick = useCallback(( + feature: mapboxgl.MapboxGeoJSONFeature, + lngLat: mapboxgl.LngLatLike, + ) => { + setClickedPointProperties({ + feature: feature as unknown as ClickedPoint['feature'], + lngLat, + }); + return false; + }, []); + + const handlePointClose = useCallback( + () => { + setClickedPointProperties(undefined); + }, + [setClickedPointProperties], + ); + + const handleClearFiltersButtonclick = useCallback(() => { + setFilter({}); + }, [setFilter]); + + const popupDetails = clickedPointProperties + ? countryGroupedAppeal[clickedPointProperties.feature.properties.iso3] + : undefined; + + const [districtOptions, setDistrictOptions] = useState(); + + return ( + + + + {variant === 'country' && ( + + )} + + +
+ +
+ + )} + actions={!presentationMode && ( + + {allAppealsType.title} + + )} + > + + )} + > + + +
+ {legendOptions.map((legendItem) => ( + + ))} +
+
+ )} + /> + + + + + {clickedPointProperties?.lngLat && ( + + {clickedPointProperties.feature.properties.name} + + )} + childrenContainerClassName={styles.popupContent} + > + {popupDetails?.map( + (appeal) => ( + + + + + + ), + )} + {(isNotDefined(popupDetails) || popupDetails.length === 0) && ( +
+ {strings.operationPopoverEmpty} +
+ )} +
+ )} + {isDefined(bbox) && ( + + )} + + {onPresentationModeButtonClick && !presentationMode && ( + + )} + + ); +} + +export default ActiveOperationMap; diff --git a/src/components/domain/ActiveOperationMap/styles.module.css b/app/src/components/domain/ActiveOperationMap/styles.module.css similarity index 100% rename from src/components/domain/ActiveOperationMap/styles.module.css rename to app/src/components/domain/ActiveOperationMap/styles.module.css diff --git a/app/src/components/domain/ActiveOperationMap/utils.ts b/app/src/components/domain/ActiveOperationMap/utils.ts new file mode 100644 index 000000000..a8bbeeee8 --- /dev/null +++ b/app/src/components/domain/ActiveOperationMap/utils.ts @@ -0,0 +1,111 @@ +import type { + CircleLayer, + CirclePaint, +} from 'mapbox-gl'; + +import { + COLOR_BLACK, + COLOR_BLUE, + COLOR_ORANGE, + COLOR_RED, + COLOR_YELLOW, +} from '#utils/constants'; + +export const COLOR_EMERGENCY_APPEAL = COLOR_RED; +export const COLOR_DREF = COLOR_YELLOW; +export const COLOR_EAP = COLOR_BLUE; +export const COLOR_MULTIPLE_TYPES = COLOR_ORANGE; + +// FIXME: these must be a constant defined somewhere else +export const APPEAL_TYPE_DREF = 0; +export const APPEAL_TYPE_EMERGENCY = 1; +// const APPEAL_TYPE_INTERNATIONAL = 2; // TODO: we are not showing this? +export const APPEAL_TYPE_EAP = 3; +export const APPEAL_TYPE_MULTIPLE = -1; + +const circleColor: CirclePaint['circle-color'] = [ + 'match', + ['get', 'appealType'], + APPEAL_TYPE_DREF, + COLOR_DREF, + APPEAL_TYPE_EMERGENCY, + COLOR_EMERGENCY_APPEAL, + APPEAL_TYPE_EAP, + COLOR_EAP, + APPEAL_TYPE_MULTIPLE, + COLOR_MULTIPLE_TYPES, + COLOR_BLACK, +]; + +const basePointPaint: CirclePaint = { + 'circle-radius': 5, + 'circle-color': circleColor, + 'circle-opacity': 0.8, +}; + +export const basePointLayerOptions: Omit = { + type: 'circle', + paint: basePointPaint, +}; + +const baseOuterCirclePaint: CirclePaint = { + 'circle-color': circleColor, + 'circle-opacity': 0.4, +}; + +const outerCirclePaintForFinancialRequirements: CirclePaint = { + ...baseOuterCirclePaint, + 'circle-radius': [ + 'interpolate', + ['linear', 1], + ['get', 'financialRequirements'], + 1000, + 7, + 10000, + 9, + 100000, + 11, + 1000000, + 15, + ], +}; + +const outerCirclePaintForPeopleTargeted: CirclePaint = { + ...baseOuterCirclePaint, + 'circle-radius': [ + 'interpolate', + ['linear', 1], + ['get', 'peopleTargeted'], + 1000, + 7, + 10000, + 9, + 100000, + 11, + 1000000, + 15, + ], +}; + +export const outerCircleLayerOptionsForFinancialRequirements: Omit = { + type: 'circle', + paint: outerCirclePaintForFinancialRequirements, +}; + +export const outerCircleLayerOptionsForPeopleTargeted: Omit = { + type: 'circle', + paint: outerCirclePaintForPeopleTargeted, +}; + +export interface ScaleOption { + label: string; + value: 'financialRequirements' | 'peopleTargeted'; +} + +export function optionKeySelector(option: ScaleOption) { + return option.value; +} + +export function optionLabelSelector(option: ScaleOption) { + return option.label; +} diff --git a/src/components/domain/ActivityEventSearchSelectInput.tsx b/app/src/components/domain/ActivityEventSearchSelectInput.tsx similarity index 91% rename from src/components/domain/ActivityEventSearchSelectInput.tsx rename to app/src/components/domain/ActivityEventSearchSelectInput.tsx index bc4ee75c3..36ebc61f7 100644 --- a/src/components/domain/ActivityEventSearchSelectInput.tsx +++ b/app/src/components/domain/ActivityEventSearchSelectInput.tsx @@ -1,10 +1,15 @@ import { useState } from 'react'; +import { + SearchSelectInput, + SearchSelectInputProps, +} from '@ifrc-go/ui'; -import SearchSelectInput, { - Props as SearchSelectInputProps, -} from '#components/SearchSelectInput'; -import { useRequest, type GoApiResponse, type GoApiUrlQuery } from '#utils/restRequest'; import useDebouncedValue from '#hooks/useDebouncedValue'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useRequest, +} from '#utils/restRequest'; type GetEventParams = GoApiUrlQuery<'/api/v2/event/response-activity/'>; type GetEventResponse = GoApiResponse<'/api/v2/event/response-activity/'>; diff --git a/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json similarity index 100% rename from src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json rename to app/src/components/domain/AppealsOverYearsChart/MonthlyChart/i18n.json diff --git a/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx new file mode 100644 index 000000000..d9499fdef --- /dev/null +++ b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/index.tsx @@ -0,0 +1,218 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + BlockLoading, + Button, + Container, + TimeSeriesChart, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getDatesSeparatedByMonths, + getFormattedDateKey, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + encodeDate, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import { useRequest } from '#utils/restRequest'; + +import PointDetails from '../PointDetails'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// FIXME: these must be a constant defined somewhere else +// with satisfies +const APPEAL_TYPE_DREF = 0; +const APPEAL_TYPE_EMERGENCY = 1; + +type DATA_KEY = 'dref' | 'emergencyAppeal'; + +const dataKeys: DATA_KEY[] = [ + 'dref', + 'emergencyAppeal', +]; + +const dataKeyToClassNameMap = { + dref: styles.dref, + emergencyAppeal: styles.emergencyAppeal, +}; +const classNameSelector = (dataKey: DATA_KEY) => dataKeyToClassNameMap[dataKey]; +const xAxisFormatter = (date: Date) => date.toLocaleString( + navigator.language, + { month: 'short' }, +); + +const dateFormatter = new Intl.DateTimeFormat( + navigator.language, + { month: 'long' }, +); + +const currentDate = new Date(); + +interface Props { + regionId?: number; + year: number; + onBackButtonClick: (year: undefined) => void; +} + +function MonthlyChart(props: Props) { + const { + year, + regionId, + onBackButtonClick, + } = props; + + const strings = useTranslation(i18n); + const dateList = useMemo( + () => { + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); + return getDatesSeparatedByMonths(startDate, endDate); + }, + [year], + ); + + const [activePointKey, setActivePointKey] = useState( + () => getFormattedDateKey(dateList[0]), + ); + + const query = { + model_type: 'appeal', + start_date: encodeDate(new Date(year, 0, 1)), + end_date: encodeDate(new Date(year, 11, 31)), + sum_amount_funded: 'amount_funded', + sum_beneficiaries: 'num_beneficiaries', + unit: 'month', + region: regionId, + }; + + const { + pending: monthlyEmergencyAppealPending, + response: monthlyEmergencyAppealResponse, + } = useRequest({ + url: '/api/v1/aggregate/', + query: { + filter_atype: APPEAL_TYPE_EMERGENCY, + ...query, + // FIXME: need to fix typing in server (low priority) + } as never, + }); + + const { + response: monthlyDrefResponse, + pending: monthlyDrefPending, + } = useRequest({ + url: '/api/v1/aggregate/', + query: { + filter_atype: APPEAL_TYPE_DREF, + ...query, + // FIXME: need to fix the typing in server (low priority) + } as never, + }); + + const pending = monthlyEmergencyAppealPending || monthlyDrefPending; + + const combinedData = useMemo( + () => { + if (isNotDefined(monthlyDrefResponse) || isNotDefined(monthlyEmergencyAppealResponse)) { + return undefined; + } + + const drefData = listToMap( + monthlyDrefResponse, + (appeal) => getFormattedDateKey(appeal.timespan), + ); + + const emergencyAppealData = listToMap( + monthlyEmergencyAppealResponse, + (appeal) => getFormattedDateKey(appeal.timespan), + ); + + const data = { + dref: drefData, + emergencyAppeal: emergencyAppealData, + }; + + return data; + }, + [monthlyEmergencyAppealResponse, monthlyDrefResponse], + ); + + const dateListWithData = listToMap( + dateList, + (date) => getFormattedDateKey(date), + (date, key) => ({ + date, + dref: combinedData?.dref?.[key], + emergencyAppeal: combinedData?.emergencyAppeal?.[key], + }), + ); + + const activePointData = activePointKey ? dateListWithData[activePointKey] : undefined; + const heading = resolveToComponent( + strings.homeMonthlyChartTitle, + { year: year ?? '--' }, + ); + const chartValueSelector = useCallback( + (dataKey: DATA_KEY, date: Date) => { + const value = combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count; + // NOTE: if there are missing values for a given month or year + // less then the current date we assume the value to be 0 + // FIXME: This could be done in the aggregation logic of the server itself + if (isNotDefined(value) && date < currentDate) { + return 0; + } + + return combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count; + }, + [combinedData], + ); + + return ( + + {pending && } + {!pending && ( + <> + + + {strings.homeMonthlyChartBackButtonLabel} + + )} + /> + + )} + + ); +} + +export default MonthlyChart; diff --git a/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css similarity index 100% rename from src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css rename to app/src/components/domain/AppealsOverYearsChart/MonthlyChart/styles.module.css diff --git a/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json b/app/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json similarity index 100% rename from src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json rename to app/src/components/domain/AppealsOverYearsChart/PointDetails/i18n.json diff --git a/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx b/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx new file mode 100644 index 000000000..9d0b54d60 --- /dev/null +++ b/app/src/components/domain/AppealsOverYearsChart/PointDetails/index.tsx @@ -0,0 +1,98 @@ +import { + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type AggregateResponse = GoApiResponse<'/api/v1/aggregate/'>; +type AggregateItem = AggregateResponse[number]; + +interface Props { + data?: { + date: Date; + dref?: AggregateItem; + emergencyAppeal?: AggregateItem; + } + action?: React.ReactNode; + className?: string; + heading?: React.ReactNode; +} + +function PointDetails(props: Props) { + const { + className, + data, + action, + heading, + } = props; + const strings = useTranslation(i18n); + + return ( + + {data && ( + <> +
+ } + label={strings.timelineChartEmergencyAppealLabel} + value={data.emergencyAppeal?.count ?? 0} + valueType="number" + strongValue + strongLabel + /> + + +
+
+ } + label={strings.timelineChartDrefLabel} + value={data.dref?.count ?? 0} + valueType="number" + strongValue + strongLabel + /> + + +
+ + )} +
+ ); +} + +export default PointDetails; diff --git a/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css similarity index 100% rename from src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css rename to app/src/components/domain/AppealsOverYearsChart/PointDetails/styles.module.css diff --git a/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json similarity index 100% rename from src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json rename to app/src/components/domain/AppealsOverYearsChart/YearlyChart/i18n.json diff --git a/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx new file mode 100644 index 000000000..ae56b84eb --- /dev/null +++ b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/index.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + BlockLoading, + Button, + Container, + Message, + TimeSeriesChart, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getDatesSeparatedByYear, + getFormattedDateKey, +} from '@ifrc-go/ui/utils'; +import { + encodeDate, + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import { useRequest } from '#utils/restRequest'; + +import PointDetails from '../PointDetails'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const APPEAL_TYPE_EMERGENCY = 1; +const APPEAL_TYPE_DREF = 0; + +type DATA_KEY = 'dref' | 'emergencyAppeal'; + +const dataKeys: DATA_KEY[] = [ + 'dref', + 'emergencyAppeal', +]; + +// FIXME: use a separate utility +const now = new Date(); +const startDate = new Date(now.getFullYear() - 10, 0, 1); +const endDate = new Date(now.getFullYear(), 11, 31); +const dateList = getDatesSeparatedByYear(startDate, endDate); + +const dataKeyToClassNameMap = { + dref: styles.dref, + emergencyAppeal: styles.emergencyAppeal, +}; +const classNameSelector = (dataKey: DATA_KEY) => dataKeyToClassNameMap[dataKey]; +const xAxisFormatter = (date: Date) => date.toLocaleString( + navigator.language, + { year: 'numeric' }, +); + +interface Props { + onYearClick: (year: number) => void; + regionId?: number; +} + +function YearlyChart(props: Props) { + const { + onYearClick, + regionId, + } = props; + const strings = useTranslation(i18n); + + const [activePointKey, setActivePointKey] = useState( + () => getFormattedDateKey(dateList[dateList.length - 1]), + ); + + const queryParams = { + model_type: 'appeal', + start_date: encodeDate(startDate), + end_date: encodeDate(endDate), + sum_amount_funded: 'amount_funded', + sum_beneficiaries: 'num_beneficiaries', + unit: 'year', + region: regionId, + }; + + const { + pending: monthlyEmergencyAppealPending, + response: monthlyEmergencyAppealResponse, + error: appealResponseError, + } = useRequest({ + url: '/api/v1/aggregate/', + query: { + filter_atype: APPEAL_TYPE_EMERGENCY, + ...queryParams, + // FIXME: fix typing in server (low priority) + } as never, + }); + + const { + response: monthlyDrefResponse, + pending: monthlyDrefPending, + } = useRequest({ + url: '/api/v1/aggregate/', + query: { + filter_atype: APPEAL_TYPE_DREF, + ...queryParams, + // FIXME: fix typing in server (low priority) + } as never, + }); + + const pending = monthlyEmergencyAppealPending || monthlyDrefPending; + + const combinedData = useMemo( + () => { + if (isNotDefined(monthlyDrefResponse) || isNotDefined(monthlyEmergencyAppealResponse)) { + return undefined; + } + + const drefData = listToMap( + monthlyDrefResponse, + (appeal) => getFormattedDateKey(appeal.timespan), + ); + + const emergencyAppealData = listToMap( + monthlyEmergencyAppealResponse, + (appeal) => getFormattedDateKey(appeal.timespan), + ); + + const data = { + dref: drefData, + emergencyAppeal: emergencyAppealData, + }; + + return data; + }, + [monthlyEmergencyAppealResponse, monthlyDrefResponse], + ); + + const dateListWithData = listToMap( + dateList, + (date) => getFormattedDateKey(date), + (date, key) => ({ + date, + dref: combinedData?.dref?.[key], + emergencyAppeal: combinedData?.emergencyAppeal?.[key], + }), + ); + + const activePointData = activePointKey ? dateListWithData[activePointKey] : undefined; + const chartValueSelector = useCallback( + (dataKey: DATA_KEY, date: Date) => ( + combinedData?.[dataKey]?.[getFormattedDateKey(date)]?.count + ), + [combinedData], + ); + + const shouldHideChart = pending && isDefined(appealResponseError); + + return ( + + {pending && } + {isDefined(appealResponseError) && ( + + )} + {!shouldHideChart && !appealResponseError && ( + <> + + + {strings.yearlyAppealChartViewMonthlyLabel} + + )} + /> + + )} + + ); +} + +export default YearlyChart; diff --git a/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css b/app/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css similarity index 100% rename from src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css rename to app/src/components/domain/AppealsOverYearsChart/YearlyChart/styles.module.css diff --git a/app/src/components/domain/AppealsOverYearsChart/index.tsx b/app/src/components/domain/AppealsOverYearsChart/index.tsx new file mode 100644 index 000000000..0cd06ae09 --- /dev/null +++ b/app/src/components/domain/AppealsOverYearsChart/index.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { isNotDefined } from '@togglecorp/fujs'; + +import MonthlyChart from './MonthlyChart'; +import YearlyChart from './YearlyChart'; + +interface Props { + regionId?: number; +} + +function AppealsOverYearsChart(props: Props) { + const { regionId } = props; + const [year, setYear] = useState(); + + if (isNotDefined(year)) { + return ( + + ); + } + + return ( + + ); +} + +export default AppealsOverYearsChart; diff --git a/src/components/domain/AppealsTable/i18n.json b/app/src/components/domain/AppealsTable/i18n.json similarity index 100% rename from src/components/domain/AppealsTable/i18n.json rename to app/src/components/domain/AppealsTable/i18n.json diff --git a/app/src/components/domain/AppealsTable/index.tsx b/app/src/components/domain/AppealsTable/index.tsx new file mode 100644 index 000000000..6e26c63a2 --- /dev/null +++ b/app/src/components/domain/AppealsTable/index.tsx @@ -0,0 +1,333 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Button, + Container, + DateInput, + Pager, + SelectInput, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createNumberColumn, + createProgressColumn, + createStringColumn, + getPercentage, + hasSomeDefinedValue, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; + +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import DistrictSearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictSearchMultiSelectInput'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useFilterState from '#hooks/useFilterState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import type { + GoApiResponse, + GoApiUrlQuery, +} from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type AppealQueryParams = GoApiUrlQuery<'/api/v2/appeal/'>; +type AppealResponse = GoApiResponse<'/api/v2/appeal/'>; +type AppealListItem = NonNullable[number]; + +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; +type AppealTypeOption = NonNullable[number]; + +const appealKeySelector = (option: AppealListItem) => option.id; +const appealTypeKeySelector = (option: AppealTypeOption) => option.key; +const appealTypeLabelSelector = (option: AppealTypeOption) => option.value; + +const now = new Date().toISOString(); +type BaseProps = { + className?: string; +} +type CountryProps = { + variant: 'country'; + countryId: number; +} + +type RegionProps = { + variant: 'region'; + regionId: number; +} + +type GlobalProps = { + variant: 'global'; +} + +type Props = BaseProps & (RegionProps | GlobalProps | CountryProps); + +function AppealsTable(props: Props) { + const { + className, + variant, + } = props; + + const { + filter, + filtered, + limit, + offset, + ordering, + page, + rawFilter, + setFilter, + setFilterField, + setPage, + sortState, + } = useFilterState<{ + appeal?: AppealTypeOption['key'], + district?: number[], + displacement?: number, + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + pageSize: 5, + }); + + const strings = useTranslation(i18n); + const { api_appeal_type: appealTypeOptions } = useGlobalEnums(); + + const handleClearFiltersButtonclick = useCallback(() => { + setFilter({}); + }, [setFilter]); + + // eslint-disable-next-line react/destructuring-assignment + const regionId = variant === 'region' ? props.regionId : undefined; + // eslint-disable-next-line react/destructuring-assignment + const countryId = variant === 'country' ? props.countryId : undefined; + + const [districtOptions, setDistrictOptions] = useState(); + + const columns = useMemo( + () => ([ + createDateColumn( + 'start_date', + strings.appealsTableStartDate, + (item) => item.start_date, + { + sortable: true, + columnClassName: styles.startDate, + }, + ), + createStringColumn( + 'atype', + strings.appealsTableType, + (item) => item.atype_display, + { + sortable: true, + columnClassName: styles.appealType, + }, + ), + createStringColumn( + 'code', + strings.appealsTableCode, + (item) => item.code, + { + columnClassName: styles.code, + }, + ), + createLinkColumn( + 'operation', + strings.appealsTableOperation, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.event }, + }), + ), + createStringColumn( + 'dtype', + strings.appealsTableDisastertype, + (item) => item.dtype?.name, + { sortable: true }, + ), + createNumberColumn( + 'amount_requested', + strings.appealsTableRequestedAmount, + (item) => item.amount_requested, + { + sortable: true, + suffix: ' CHF', + }, + ), + createProgressColumn( + 'amount_funded', + strings.appealsTableFundedAmount, + // FIXME: use progress function + (item) => ( + getPercentage( + item.amount_funded, + item.amount_requested, + ) + ), + { sortable: true }, + ), + variant !== 'country' + ? createLinkColumn( + 'country', + strings.appealsTableCountry, + (item) => item.country?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { countryId: item.country.id }, + }), + ) : undefined, + ].filter(isDefined)), + [ + variant, + strings.appealsTableStartDate, + strings.appealsTableType, + strings.appealsTableCode, + strings.appealsTableOperation, + strings.appealsTableDisastertype, + strings.appealsTableRequestedAmount, + strings.appealsTableFundedAmount, + strings.appealsTableCountry, + ], + ); + + const query = useMemo( + () => { + const baseQuery: AppealQueryParams = { + limit, + offset, + ordering, + atype: filter.appeal, + dtype: filter.displacement, + district: hasSomeDefinedValue(filter.district) ? filter.district : undefined, + end_date__gt: now, + start_date__gte: filter.startDateAfter, + start_date__lte: filter.startDateBefore, + }; + + if (variant === 'global') { + return baseQuery; + } + + return { + ...baseQuery, + country: countryId ? [countryId] : undefined, + region: regionId ? [regionId] : undefined, + }; + }, + [ + variant, + countryId, + regionId, + ordering, + filter, + limit, + offset, + ], + ); + + const { + pending: appealsPending, + response: appealsResponse, + } = useRequest({ + url: '/api/v2/appeal/', + preserveResponse: true, + query, + }); + + return ( + + + + {variant === 'country' && ( + + )} + + +
+ +
+ + )} + footerActions={( + + )} + contentViewType="vertical" + > + + + + + ); +} + +export default AppealsTable; diff --git a/src/components/domain/AppealsTable/styles.module.css b/app/src/components/domain/AppealsTable/styles.module.css similarity index 100% rename from src/components/domain/AppealsTable/styles.module.css rename to app/src/components/domain/AppealsTable/styles.module.css diff --git a/app/src/components/domain/BaseMap/index.tsx b/app/src/components/domain/BaseMap/index.tsx new file mode 100644 index 000000000..47abb62bb --- /dev/null +++ b/app/src/components/domain/BaseMap/index.tsx @@ -0,0 +1,143 @@ +import { useMemo } from 'react'; +import { + isDefined, + isFalsyString, + isNotDefined, +} from '@togglecorp/fujs'; +import Map, { + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import { type SymbolLayer } from 'mapbox-gl'; + +import useCountry from '#hooks/domain/useCountry'; +import { + adminLabelLayerOptions, + defaultMapOptions, + defaultMapStyle, + defaultNavControlOptions, + defaultNavControlPosition, +} from '#utils/map'; + +type MapProps = Parameters[0]; + +type overrides = 'mapStyle' | 'mapOptions' | 'navControlShown' | 'navControlPosition' | 'navControlOptions' | 'scaleControlShown'; + +type BaseMapProps = Omit & { + baseLayers?: React.ReactNode; + withDisclaimer?: boolean; +} & Partial>; + +const sourceOptions: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +const adminLabelOverrideOptions: Omit = { + type: 'symbol', + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Poppins Regular', 'Arial Unicode MS Regular'], + 'text-letter-spacing': 0.15, + 'text-line-height': 1.2, + 'text-max-width': 8, + 'text-justify': 'center', + 'text-anchor': 'top', + 'text-padding': 2, + 'text-size': [ + 'interpolate', ['linear', 1], ['zoom'], + 0, 6, + 6, 16, + ], + }, + paint: { + 'text-color': '#000000', + 'text-halo-color': '#555555', + 'text-halo-width': 0.2, + }, +}; + +function BaseMap(props: BaseMapProps) { + const { + baseLayers, + mapStyle, + mapOptions, + navControlShown, + navControlPosition, + navControlOptions, + scaleControlShown, + children, + ...otherProps + } = props; + + const countries = useCountry(); + + const countryCentroidGeoJson = useMemo( + (): GeoJSON.FeatureCollection => ({ + type: 'FeatureCollection' as const, + features: countries + ?.map((country) => { + if (isFalsyString(country.name) || isNotDefined(country.centroid)) { + return undefined; + } + + return { + type: 'Feature' as const, + geometry: country.centroid as { + type: 'Point', + coordinates: [number, number], + }, + properties: { + id: country.id, + name: country.name, + }, + }; + }).filter(isDefined) ?? [], + }), + [countries], + ); + + return ( + + + + + + {baseLayers} + + + + + {children} + + ); +} + +export default BaseMap; diff --git a/src/components/domain/CountryMultiSelectInput.tsx b/app/src/components/domain/CountryMultiSelectInput.tsx similarity index 84% rename from src/components/domain/CountryMultiSelectInput.tsx rename to app/src/components/domain/CountryMultiSelectInput.tsx index 20f90d776..dad4662cc 100644 --- a/src/components/domain/CountryMultiSelectInput.tsx +++ b/app/src/components/domain/CountryMultiSelectInput.tsx @@ -1,6 +1,12 @@ -import type { MultiSelectInputProps } from '#components/MultiSelectInput'; -import MultiSelectInput from '#components/MultiSelectInput'; -import { numericIdSelector, stringNameSelector } from '#utils/selectors'; +import { + MultiSelectInput, + MultiSelectInputProps, +} from '@ifrc-go/ui'; +import { + numericIdSelector, + stringNameSelector, +} from '@ifrc-go/ui/utils'; + import useCountry, { Country } from '#hooks/domain/useCountry'; export type CountryOption = Country; diff --git a/src/components/domain/CountrySelectInput.tsx b/app/src/components/domain/CountrySelectInput.tsx similarity index 84% rename from src/components/domain/CountrySelectInput.tsx rename to app/src/components/domain/CountrySelectInput.tsx index effbdcd2a..3ffbc1173 100644 --- a/src/components/domain/CountrySelectInput.tsx +++ b/app/src/components/domain/CountrySelectInput.tsx @@ -1,6 +1,12 @@ -import type { Props as SelectInputProps } from '#components/SelectInput'; -import SelectInput from '#components/SelectInput'; -import { numericIdSelector, stringNameSelector } from '#utils/selectors'; +import { + SelectInput, + SelectInputProps, +} from '@ifrc-go/ui'; +import { + numericIdSelector, + stringNameSelector, +} from '@ifrc-go/ui/utils'; + import useCountry, { Country } from '#hooks/domain/useCountry'; export type CountryOption = Country; diff --git a/src/components/domain/DetailsFailedToLoadMessage/i18n.json b/app/src/components/domain/DetailsFailedToLoadMessage/i18n.json similarity index 100% rename from src/components/domain/DetailsFailedToLoadMessage/i18n.json rename to app/src/components/domain/DetailsFailedToLoadMessage/i18n.json diff --git a/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx b/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx new file mode 100644 index 000000000..610ea229b --- /dev/null +++ b/app/src/components/domain/DetailsFailedToLoadMessage/index.tsx @@ -0,0 +1,31 @@ +import { Message } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import i18n from './i18n.json'; + +interface Props { + title?: React.ReactNode; + description: React.ReactNode; + helpText?: React.ReactNode; +} + +function DetailsFailedToLoadMessage(props: Props) { + const strings = useTranslation(i18n); + + const { + title = strings.detailsFailedToLoadErrorTitle, + description, + helpText = strings.detailsFailedToLoadErrorHelpText, + } = props; + + return ( + + ); +} + +export default DetailsFailedToLoadMessage; diff --git a/src/components/domain/DisasterTypeSelectInput.tsx b/app/src/components/domain/DisasterTypeSelectInput.tsx similarity index 92% rename from src/components/domain/DisasterTypeSelectInput.tsx rename to app/src/components/domain/DisasterTypeSelectInput.tsx index 28cce2e29..14fe14d43 100644 --- a/src/components/domain/DisasterTypeSelectInput.tsx +++ b/app/src/components/domain/DisasterTypeSelectInput.tsx @@ -1,7 +1,10 @@ -import type { Props as SelectInputProps } from '#components/SelectInput'; -import SelectInput from '#components/SelectInput'; -import useDisasterType from '#hooks/domain/useDisasterType'; +import { + SelectInput, + SelectInputProps, +} from '@ifrc-go/ui'; + import { DisasterTypes } from '#contexts/domain'; +import useDisasterType from '#hooks/domain/useDisasterType'; export type DisasterTypeItem = NonNullable[number]; diff --git a/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx b/app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx similarity index 97% rename from src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx rename to app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx index 9805d11bb..df399aa36 100644 --- a/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx +++ b/app/src/components/domain/DistrictMultiCountrySearchMultiSelectInput.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; -import { isNotDefined } from '@togglecorp/fujs'; - -import SearchMultiSelectInput, { +import { + SearchMultiSelectInput, SearchMultiSelectInputProps, -} from '#components/SearchMultiSelectInput'; +} from '@ifrc-go/ui'; +import { isNotDefined } from '@togglecorp/fujs'; -import { useRequest } from '#utils/restRequest'; -import useDebouncedValue from '#hooks/useDebouncedValue'; import { paths } from '#generated/types'; +import useDebouncedValue from '#hooks/useDebouncedValue'; +import { useRequest } from '#utils/restRequest'; type GetDistrict = paths['/api/v2/district/']['get']; type GetDistrictParams = GetDistrict['parameters']['query']; diff --git a/src/components/domain/DistrictSearchMultiSelectInput/i18n.json b/app/src/components/domain/DistrictSearchMultiSelectInput/i18n.json similarity index 100% rename from src/components/domain/DistrictSearchMultiSelectInput/i18n.json rename to app/src/components/domain/DistrictSearchMultiSelectInput/i18n.json diff --git a/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx b/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx new file mode 100644 index 000000000..5cc27b880 --- /dev/null +++ b/app/src/components/domain/DistrictSearchMultiSelectInput/index.tsx @@ -0,0 +1,149 @@ +import { + useCallback, + useState, +} from 'react'; +import { CheckDoubleFillIcon } from '@ifrc-go/icons'; +import { + Button, + SearchMultiSelectInput, + SearchMultiSelectInputProps, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isNotDefined, + unique, +} from '@togglecorp/fujs'; + +import useDebouncedValue from '#hooks/useDebouncedValue'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useLazyRequest, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetDistrictParams = GoApiUrlQuery<'/api/v2/district/'>; +type GetDistrictResponse = GoApiResponse<'/api/v2/district/'>; + +export type DistrictItem = Pick[number], 'id' | 'name'>; + +const keySelector = (d: DistrictItem) => d.id; +const labelSelector = (d: DistrictItem) => d.name; + +type Def = { containerClassName?: string;} +type DistrictMultiSelectInputProps = SearchMultiSelectInputProps< + number, + NAME, + DistrictItem, + Def, + 'onSearchValueChange' | 'searchOptions' | 'optionsPending' + | 'keySelector' | 'labelSelector' | 'totalOptionsCount' | 'onShowDropdownChange' + | 'selectedOnTop' +> & { + countryId?: number; +}; + +function DistrictSearchMultiSelectInput( + props: DistrictMultiSelectInputProps, +) { + const { + className, + countryId, + onChange, + onOptionsChange, + name, + disabled, + readOnly, + ...otherProps + } = props; + + const strings = useTranslation(i18n); + + const [opened, setOpened] = useState(false); + const [searchText, setSearchText] = useState(''); + const debouncedSearchText = useDebouncedValue(searchText); + + const query: GetDistrictParams = { + country: countryId, + search: debouncedSearchText, + limit: 20, + }; + + const { + pending, + response, + } = useRequest({ + skip: isNotDefined(countryId) || !opened, + url: '/api/v2/district/', + query, + preserveResponse: true, + }); + + const { + pending: pendingSelectAll, + trigger, + } = useLazyRequest({ + method: 'GET', + url: '/api/v2/district/', + query: (ctx: GetDistrictParams) => ctx, + onSuccess: (allDistricts) => { + const allDistrictsKeys = allDistricts.results?.map((d) => d.id); + if (allDistrictsKeys && allDistrictsKeys.length > 0) { + onChange(allDistrictsKeys, name); + if (onOptionsChange) { + onOptionsChange(((existingOptions) => { + const safeOptions = existingOptions ?? []; + return unique( + [...safeOptions, ...(allDistricts.results ?? [])], + keySelector, + ); + })); + } + } + }, + }); + + const handleSelectAllClick = useCallback(() => { + trigger({ + country: countryId, + limit: 9999, + }); + }, [trigger, countryId]); + + return ( + + + + )} + selectedOnTop + /> + ); +} + +export default DistrictSearchMultiSelectInput; diff --git a/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css b/app/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css similarity index 100% rename from src/components/domain/DistrictSearchMultiSelectInput/styles.module.css rename to app/src/components/domain/DistrictSearchMultiSelectInput/styles.module.css diff --git a/src/components/domain/DrefExportModal/i18n.json b/app/src/components/domain/DrefExportModal/i18n.json similarity index 100% rename from src/components/domain/DrefExportModal/i18n.json rename to app/src/components/domain/DrefExportModal/i18n.json diff --git a/app/src/components/domain/DrefExportModal/index.tsx b/app/src/components/domain/DrefExportModal/index.tsx new file mode 100644 index 000000000..fd7acf67d --- /dev/null +++ b/app/src/components/domain/DrefExportModal/index.tsx @@ -0,0 +1,147 @@ +import { + useMemo, + useState, +} from 'react'; +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 { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type ExportTypeEnum = components<'read'>['schemas']['ExportTypeEnum']; +type ExportStatusEnum = components<'read'>['schemas']['Status1d2Enum']; + +const EXPORT_STATUS_PENDING = 0 satisfies ExportStatusEnum; +const EXPORT_STATUS_COMPLETED = 1 satisfies ExportStatusEnum; +const EXPORT_STATUS_ERRORED = 2 satisfies ExportStatusEnum; + +interface Props { + id: number; + onCancel: () => void; + applicationType: 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT'; +} + +function DrefExportModal(props: Props) { + const { + id, + onCancel, + applicationType, + } = props; + + const strings = useTranslation(i18n); + + const [exportId, setExportId] = useState(); + + const exportTriggerBody = useMemo( + () => { + let type: ExportTypeEnum; + if (applicationType === 'OPS_UPDATE') { + type = 'dref-operational-updates'; + } else if (applicationType === 'FINAL_REPORT') { + type = 'dref-final-reports'; + } else { + type = 'dref-applications'; + } + + return { + export_id: id, + export_type: type, + selector: '#pdf-preview-ready', + }; + }, + [id, applicationType], + ); + + const { + pending: pendingExportTrigger, + error: exportTriggerError, + } = useRequest({ + skip: isDefined(exportId) || isNotDefined(id), + method: 'POST', + useCurrentLanguageForMutation: true, + url: '/api/v2/pdf-export/', + body: exportTriggerBody, + onSuccess: (response) => { + if (isDefined(response.id)) { + setExportId(response.id); + } + }, + }); + + const { + pending: pendingExportStatus, + 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; + }, + }); + + return ( + + {pendingExportTrigger && ( + + )} + {(pendingExportStatus || exportStatusResponse?.status === EXPORT_STATUS_PENDING) && ( + + )} + {(exportStatusResponse?.status === EXPORT_STATUS_ERRORED + || isDefined(exportTriggerError) + || isDefined(exportStatusError) + ) && ( + + )} + {isDefined(exportStatusResponse) + && exportStatusResponse.status === EXPORT_STATUS_COMPLETED + && isDefined(exportStatusResponse.pdf_file) && ( + + {strings.drefDownloadPDF} + + )} + /> + )} + + ); +} + +export default DrefExportModal; diff --git a/src/components/domain/DrefShareModal/UserItem/i18n.json b/app/src/components/domain/DrefShareModal/UserItem/i18n.json similarity index 100% rename from src/components/domain/DrefShareModal/UserItem/i18n.json rename to app/src/components/domain/DrefShareModal/UserItem/i18n.json diff --git a/app/src/components/domain/DrefShareModal/UserItem/index.tsx b/app/src/components/domain/DrefShareModal/UserItem/index.tsx new file mode 100644 index 000000000..5e8f93668 --- /dev/null +++ b/app/src/components/domain/DrefShareModal/UserItem/index.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { DeleteBinFillIcon } from '@ifrc-go/icons'; +import { Button } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; + +import { type User } from '#components/domain/UserSearchMultiSelectInput'; +import { getUserName } from '#utils/domain/user'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + className?: string; + userId: number; + user: User; + onUserRemove?: (item: number) => void; +} + +function UserItem(props: Props) { + const { + className, + userId, + user, + onUserRemove, + } = props; + + const strings = useTranslation(i18n); + + const userName = useMemo( + () => getUserName(user), + [user], + ); + + return ( +
+
+ {userName} +
+ {isDefined(onUserRemove) && ( + + )} +
+ ); +} + +export default UserItem; diff --git a/src/components/domain/DrefShareModal/UserItem/styles.module.css b/app/src/components/domain/DrefShareModal/UserItem/styles.module.css similarity index 100% rename from src/components/domain/DrefShareModal/UserItem/styles.module.css rename to app/src/components/domain/DrefShareModal/UserItem/styles.module.css diff --git a/src/components/domain/DrefShareModal/i18n.json b/app/src/components/domain/DrefShareModal/i18n.json similarity index 100% rename from src/components/domain/DrefShareModal/i18n.json rename to app/src/components/domain/DrefShareModal/i18n.json diff --git a/app/src/components/domain/DrefShareModal/index.tsx b/app/src/components/domain/DrefShareModal/index.tsx new file mode 100644 index 000000000..ee35cb26b --- /dev/null +++ b/app/src/components/domain/DrefShareModal/index.tsx @@ -0,0 +1,147 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Button, + List, + Modal, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, +} 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 UserItem from './UserItem'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + drefId: number; + onCancel: () => void; + onSuccess: () => void; +} + +const userKeySelector = (item: User) => item.id; + +function DrefShareModal(props: Props) { + const { + drefId, + 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/dref-share/', + body: () => ({ + dref: drefId, + users, + }), + onSuccess: () => { + alert.show( + strings.drefShareSuccessfully, + { variant: 'success' }, + ); + onSuccess(); + }, + }); + + const { + pending: getPending, + // response: usersResponse, + } = useRequest({ + skip: isNotDefined(drefId), + url: '/api/v2/dref-share-user/{id}/', + pathVariables: { id: drefId }, + onSuccess: (response) => { + if (isDefined(response.users)) { + setUsers(response.users); + } + + setUserOptions(response.users_details); + }, + }); + + const handleUserRemove = useCallback((userId: number) => { + setUsers((oldVal = []) => ( + oldVal.filter((item) => item !== userId) + )); + }, [setUsers]); + + const selectedUsers = useMemo(() => ( + userOptions?.filter((user) => users.includes(user.id)) + ), [userOptions, users]); + + const userRendererParams = useCallback((userId: number, user: User) => ({ + userId, + user, + onUserRemove: handleUserRemove, + }), [ + handleUserRemove, + ]); + + return ( + + {strings.drefShareUpdate} + + )} + size="md" + childrenContainerClassName={styles.content} + > + + + + ); +} + +export default DrefShareModal; diff --git a/src/components/domain/DrefShareModal/styles.module.css b/app/src/components/domain/DrefShareModal/styles.module.css similarity index 100% rename from src/components/domain/DrefShareModal/styles.module.css rename to app/src/components/domain/DrefShareModal/styles.module.css diff --git a/src/components/domain/EventSearchSelectInput.tsx b/app/src/components/domain/EventSearchSelectInput.tsx similarity index 95% rename from src/components/domain/EventSearchSelectInput.tsx rename to app/src/components/domain/EventSearchSelectInput.tsx index e1a2d58f3..155ae7bd7 100644 --- a/src/components/domain/EventSearchSelectInput.tsx +++ b/app/src/components/domain/EventSearchSelectInput.tsx @@ -1,15 +1,15 @@ import { useState } from 'react'; +import { + SearchSelectInput, + SearchSelectInputProps, +} from '@ifrc-go/ui'; -import SearchSelectInput, { - Props as SearchSelectInputProps, -} from '#components/SearchSelectInput'; - +import useDebouncedValue from '#hooks/useDebouncedValue'; import { - useRequest, - type GoApiUrlQuery, type GoApiResponse, + type GoApiUrlQuery, + useRequest, } from '#utils/restRequest'; -import useDebouncedValue from '#hooks/useDebouncedValue'; type GetEventParams = GoApiUrlQuery<'/api/v2/event/mini/'>; type GetEventResponse = GoApiResponse<'/api/v2/event/mini/'>; diff --git a/src/components/domain/ExportButton/i18n.json b/app/src/components/domain/ExportButton/i18n.json similarity index 100% rename from src/components/domain/ExportButton/i18n.json rename to app/src/components/domain/ExportButton/i18n.json diff --git a/app/src/components/domain/ExportButton/index.tsx b/app/src/components/domain/ExportButton/index.tsx new file mode 100644 index 000000000..87d0e7444 --- /dev/null +++ b/app/src/components/domain/ExportButton/index.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { DownloadTwoLineIcon } from '@ifrc-go/icons'; +import { + Button, + NumberOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToComponent } from '@ifrc-go/ui/utils'; + +import i18n from './i18n.json'; + +interface Props { + onClick: () => void; + disabled?: boolean; + progress: number; + pendingExport: boolean; + totalCount: number | undefined; +} + +function ExportButton(props: Props) { + const { + onClick, + disabled, + progress, + pendingExport, + totalCount = 0, + } = props; + const strings = useTranslation(i18n); + + const exportButtonLabel = useMemo(() => { + if (!pendingExport) { + return strings.exportTableButtonLabel; + } + return resolveToComponent( + strings.exportTableDownloadingButtonLabel, + { + progress: ( + + ), + }, + ); + }, [ + strings.exportTableButtonLabel, + strings.exportTableDownloadingButtonLabel, + progress, + pendingExport, + ]); + + return ( + + ); +} + +export default ExportButton; diff --git a/src/components/domain/FieldReportSearchSelectInput.tsx b/app/src/components/domain/FieldReportSearchSelectInput.tsx similarity index 93% rename from src/components/domain/FieldReportSearchSelectInput.tsx rename to app/src/components/domain/FieldReportSearchSelectInput.tsx index 63da28e3e..07e5889df 100644 --- a/src/components/domain/FieldReportSearchSelectInput.tsx +++ b/app/src/components/domain/FieldReportSearchSelectInput.tsx @@ -1,15 +1,15 @@ import { useState } from 'react'; +import { + SearchSelectInput, + SearchSelectInputProps, +} from '@ifrc-go/ui'; -import SearchSelectInput, { - Props, -} from '#components/SearchSelectInput'; - +import useDebouncedValue from '#hooks/useDebouncedValue'; import { - useRequest, - type GoApiUrlQuery, type GoApiResponse, + type GoApiUrlQuery, + useRequest, } from '#utils/restRequest'; -import useDebouncedValue from '#hooks/useDebouncedValue'; type GetFieldReportParams = GoApiUrlQuery<'/api/v2/field-report/'>; type GetFieldReportResponse = GoApiResponse<'/api/v2/field-report/'>; @@ -19,7 +19,7 @@ const keySelector = (d: FieldReportItem) => d.id; const labelSelector = (d: FieldReportItem) => d.summary || '???'; type Def = { containerClassName?: string;} -type FieldReportSelectInputProps = Props< +type FieldReportSelectInputProps = SearchSelectInputProps< number, NAME, FieldReportItem, diff --git a/src/components/domain/FormFailedToLoadMessage/i18n.json b/app/src/components/domain/FormFailedToLoadMessage/i18n.json similarity index 100% rename from src/components/domain/FormFailedToLoadMessage/i18n.json rename to app/src/components/domain/FormFailedToLoadMessage/i18n.json diff --git a/app/src/components/domain/FormFailedToLoadMessage/index.tsx b/app/src/components/domain/FormFailedToLoadMessage/index.tsx new file mode 100644 index 000000000..92b6d8052 --- /dev/null +++ b/app/src/components/domain/FormFailedToLoadMessage/index.tsx @@ -0,0 +1,31 @@ +import { Message } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import i18n from './i18n.json'; + +interface Props { + title?: React.ReactNode; + description: React.ReactNode; + helpText?: React.ReactNode; +} + +function FormFailedToLoadMessage(props: Props) { + const strings = useTranslation(i18n); + + const { + title = strings.formFailedToLoadErrorTitle, + description, + helpText = strings.formFailedToLoadErrorHelpText, + } = props; + + return ( + + ); +} + +export default FormFailedToLoadMessage; diff --git a/src/components/domain/GoMultiFileInput/i18n.json b/app/src/components/domain/GoMultiFileInput/i18n.json similarity index 100% rename from src/components/domain/GoMultiFileInput/i18n.json rename to app/src/components/domain/GoMultiFileInput/i18n.json diff --git a/app/src/components/domain/GoMultiFileInput/index.tsx b/app/src/components/domain/GoMultiFileInput/index.tsx new file mode 100644 index 000000000..3adeee262 --- /dev/null +++ b/app/src/components/domain/GoMultiFileInput/index.tsx @@ -0,0 +1,236 @@ +import React, { + useCallback, + useRef, +} from 'react'; +import { DeleteBinFillIcon } from '@ifrc-go/icons'; +import type { ButtonVariant } from '@ifrc-go/ui'; +import { + Button, + InputError, + NameType, + RawFileInput, + RawFileInputProps, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { nonFieldError } from '@togglecorp/toggle-form'; + +import Link from '#components/Link'; +import useAlert from '#hooks/useAlert'; +import { useLazyRequest } from '#utils/restRequest'; +import { transformObjectError } from '#utils/restRequest/error'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +export type SupportedPaths = '/api/v2/per-file/multiple/' | '/api/v2/dref-files/multiple/' | '/api/v2/flash-update-file/multiple/'; + +interface FileUploadResult { + id: number; + file: string; +} + +const keySelector = (d: FileUploadResult) => d.id; +const valueSelector = (d: FileUploadResult) => d.file; + +function getFileNameFromUrl(urlString: string) { + const url = new URL(urlString); + const splits = url.pathname.split('/'); + return splits[splits.length - 1]; +} + +export type Props = Omit, 'multiple' | 'value' | 'onChange' | 'children' | 'inputRef'> & { + actions?: React.ReactNode; + children?: React.ReactNode; + className?: string; + clearable?: boolean; + icons?: React.ReactNode; + onChange: (value: number[] | undefined, name: T) => void; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch>>; + url: SupportedPaths; + value: number[] | undefined | null; + variant?: ButtonVariant; + withoutPreview?: boolean; + error?: React.ReactNode; + description?: React.ReactNode; +} + +function GoMultiFileInput(props: Props) { + const { + accept, + actions: actionsFromProps, + children, + className, + clearable, + disabled: disabledFromProps, + icons, + inputProps, + name, + onChange, + readOnly, + fileIdToUrlMap, + setFileIdToUrlMap, + url, + value, + variant = 'secondary', + withoutPreview, + error, + description, + } = props; + + const strings = useTranslation(i18n); + const alert = useAlert(); + + const { + pending, + trigger: triggerFileUpload, + } = useLazyRequest({ + formData: true, + url, + method: 'POST', + body: (body: { files: File[] }) => { + const formData = new FormData(); + + body.files.forEach((file) => { + formData.append('file', file); + }); + + // FIXME: fix typing in server (low priority) + // the server generated type for response and body is the same + return formData.getAll('file') as never; + }, + onSuccess: (responseUnsafe) => { + // FIXME: fix typing in server (medium priority) + const response = responseUnsafe as unknown as FileUploadResult[]; + + const ids = response.map((val) => keySelector(val)); + + if (setFileIdToUrlMap) { + setFileIdToUrlMap((oldMap) => { + const newMap = { + ...oldMap, + }; + + response.forEach((val) => { + newMap[keySelector(val)] = valueSelector(val); + }); + + return newMap; + }); + } + onChange([...(value ?? []), ...ids], name); + }, + onFailure: ({ + value: { + formErrors, + }, + }) => { + const err = transformObjectError(formErrors, () => undefined); + // NOTE: could not use getErrorObject + const serverErrorMessage = err?.[nonFieldError] || ( + typeof err?.file === 'object' + ? err[nonFieldError] + : err?.file + ); + alert.show( + strings.goMultiFailedUploadMessage, + { + variant: 'danger', + description: serverErrorMessage, + }, + ); + }, + }); + + const inputRef = useRef(null); + + const handleChange = useCallback((files: File[] | undefined) => { + if (files) { + triggerFileUpload({ files }); + } + }, [triggerFileUpload]); + + const disabled = disabledFromProps || pending || readOnly; + const actions = (clearable && value && !readOnly && !disabled ? actionsFromProps : null); + const valueUrls = isDefined(value) ? ( + value.map((fileId) => ({ id: fileId, url: fileIdToUrlMap?.[fileId] })) + ) : undefined; + + const handleFileRemove = useCallback( + (id: number) => { + if (isNotDefined(value)) { + return; + } + + const fileIndex = value.findIndex((fileId) => fileId === id); + if (fileIndex !== -1) { + const newValue = [...value]; + newValue.splice(fileIndex, 1); + onChange(newValue, name); + } + }, + [value, onChange, name], + ); + + return ( +
+ + {children} + + {!withoutPreview && isDefined(valueUrls) && valueUrls.length > 0 && ( +
+ {valueUrls.map( + (valueUrl) => ( +
+ + {getFileNameFromUrl(valueUrl.url)} + + +
+ ), + )} +
+ )} + {description && ( +
+ {description} +
+ )} + + {error} + +
+ ); +} +export default GoMultiFileInput; diff --git a/src/components/domain/GoMultiFileInput/styles.module.css b/app/src/components/domain/GoMultiFileInput/styles.module.css similarity index 100% rename from src/components/domain/GoMultiFileInput/styles.module.css rename to app/src/components/domain/GoMultiFileInput/styles.module.css diff --git a/src/components/domain/GoSingleFileInput/i18n.json b/app/src/components/domain/GoSingleFileInput/i18n.json similarity index 100% rename from src/components/domain/GoSingleFileInput/i18n.json rename to app/src/components/domain/GoSingleFileInput/i18n.json diff --git a/app/src/components/domain/GoSingleFileInput/index.tsx b/app/src/components/domain/GoSingleFileInput/index.tsx new file mode 100644 index 000000000..45a555e31 --- /dev/null +++ b/app/src/components/domain/GoSingleFileInput/index.tsx @@ -0,0 +1,186 @@ +import React, { useCallback } from 'react'; +import { DeleteBinLineIcon } from '@ifrc-go/icons'; +import { + ButtonVariant, + IconButton, + InputError, + type NameType, + RawFileInput, + type RawFileInputProps, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; +import { nonFieldError } from '@togglecorp/toggle-form'; + +import Link from '#components/Link'; +import useAlert from '#hooks/useAlert'; +import { useLazyRequest } from '#utils/restRequest'; +import { transformObjectError } from '#utils/restRequest/error'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +export type SupportedPaths = '/api/v2/per-file/' | '/api/v2/dref-files/' | '/api/v2/flash-update-file/'; + +export type Props = Omit, 'multiple' | 'value' | 'onChange' | 'children'| 'inputRef'> & { + actions?: React.ReactNode; + children?: React.ReactNode; + className?: string; + clearable?: boolean; + icons?: React.ReactNode; + onChange: (value: number | undefined, name: T) => void; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch>>; + url: SupportedPaths; + value: number | undefined | null; + variant?: ButtonVariant; + withoutPreview?: boolean; + error?: React.ReactNode; + description?: React.ReactNode; +} + +function GoSingleFileInput(props: Props) { + const { + accept, + actions: actionsFromProps, + children, + className, + clearable, + disabled: disabledFromProps, + icons, + inputProps, + name, + onChange, + readOnly, + fileIdToUrlMap, + setFileIdToUrlMap, + url, + value, + variant = 'secondary', + withoutPreview, + error, + description, + } = props; + + const strings = useTranslation(i18n); + const alert = useAlert(); + + const { + pending, + trigger: triggerFileUpload, + } = useLazyRequest({ + formData: true, + url, + method: 'POST', + // FIXME: fix typing in server (low priority) + // the server generated type for response and body is the same + body: (body: { file: File }) => body as never, + onSuccess: (response) => { + const { id, file } = response; + onChange(id, name); + + if (isDefined(file) && setFileIdToUrlMap) { + setFileIdToUrlMap((oldMap) => { + const newMap = { + ...oldMap, + }; + newMap[id] = file; + return newMap; + }); + } + }, + onFailure: ({ + value: { + formErrors, + }, + }) => { + const err = transformObjectError(formErrors, () => undefined); + // NOTE: could not use getErrorObject + const serverErrorMessage = err?.[nonFieldError] || ( + typeof err?.file === 'object' + ? err[nonFieldError] + : err?.file + ); + alert.show( + strings.failedUploadMessage, + { + variant: 'danger', + description: serverErrorMessage, + }, + ); + }, + }); + + const handleChange = useCallback((file: File | undefined) => { + if (file) { + triggerFileUpload({ file }); + } + }, [triggerFileUpload]); + + const disabled = disabledFromProps || pending || readOnly; + const actions = (!readOnly && !disabled ? actionsFromProps : null); + const selectedFileUrl = isDefined(value) ? fileIdToUrlMap?.[value] : undefined; + + const handleClearButtonClick = useCallback(() => { + onChange(undefined, name); + }, [onChange, name]); + + return ( +
+
+ + {children} + + {clearable && value && ( + + + + )} +
+ {!withoutPreview && isDefined(selectedFileUrl) ? ( + + {selectedFileUrl.split('/').pop()} + + ) : ( +
+ {strings.noFileSelected} +
+ )} + {description && ( +
+ {description} +
+ )} + + {error} + +
+ ); +} + +export default GoSingleFileInput; diff --git a/src/components/domain/GoSingleFileInput/styles.module.css b/app/src/components/domain/GoSingleFileInput/styles.module.css similarity index 100% rename from src/components/domain/GoSingleFileInput/styles.module.css rename to app/src/components/domain/GoSingleFileInput/styles.module.css diff --git a/src/components/domain/HighlightedOperations/OperationCard/i18n.json b/app/src/components/domain/HighlightedOperations/OperationCard/i18n.json similarity index 100% rename from src/components/domain/HighlightedOperations/OperationCard/i18n.json rename to app/src/components/domain/HighlightedOperations/OperationCard/i18n.json diff --git a/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx new file mode 100644 index 000000000..e25252c38 --- /dev/null +++ b/app/src/components/domain/HighlightedOperations/OperationCard/index.tsx @@ -0,0 +1,217 @@ +import { useContext } from 'react'; +import { FocusLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + KeyFigure, + NumberOutput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + resolveToComponent, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isNotDefined, +} from '@togglecorp/fujs'; + +import SeverityIndicator from '#components/domain/SeverityIndicator'; +import Link from '#components/Link'; +import DomainContext from '#contexts/domain'; +import useAuth from '#hooks/domain/useAuth'; +import { + type GoApiResponse, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type EventResponse = GoApiResponse<'/api/v2/event/'>; +type EventListItem = NonNullable[number]; + +// FIXME: move this to utils +function getPercent(value: number | undefined, total: number | undefined) { + if (isNotDefined(value) || isNotDefined(total)) { + return undefined; + } + return (value / total) * 100; +} + +interface Props { + className?: string; + data: EventListItem; + isSubscribed: boolean; +} + +function OperationCard(props: Props) { + const { + className, + data: { + id, + name, + ifrc_severity_level, + ifrc_severity_level_display, + updated_at, + appeals, + countries = [], + }, + isSubscribed = false, + } = props; + + const { invalidate } = useContext(DomainContext); + const { isAuthenticated } = useAuth(); + + const { + pending: addSubscriptionPending, + trigger: triggerAddSubscription, + } = useLazyRequest({ + url: '/api/v2/add_subscription/', + method: 'POST', + body: (eventId: number) => ([{ + type: 'followedEvent', + value: eventId, + }]), + onSuccess: () => { + invalidate('user-me'); + }, + }); + + const { + pending: removeSubscriptionPending, + trigger: triggerRemoveSubscription, + } = useLazyRequest({ + url: '/api/v2/del_subscription/', + method: 'POST', + body: (eventId: number) => ([{ + value: eventId, + }]), + onSuccess: () => { + invalidate('user-me'); + }, + }); + + const subscriptionPending = addSubscriptionPending || removeSubscriptionPending; + + const strings = useTranslation(i18n); + const targetedPopulation = sumSafe(appeals.map((appeal) => appeal.num_beneficiaries)); + const amountRequested = sumSafe(appeals.map((appeal) => appeal.amount_requested)); + const amountFunded = sumSafe(appeals.map((appeal) => appeal.amount_funded)); + + const coverage = getPercent(amountFunded, amountRequested); + + const fundingCoverageDescription = resolveToComponent( + strings.operationCardFundingCoverage, + { coverage: }, + ); + + let countriesInfoDisplay = strings.operationCardNoCountryInvolved; + if (countries.length === 1) { + countriesInfoDisplay = countries[0].name ?? '?'; + } else if (countries.length > 1) { + countriesInfoDisplay = strings.operationCardInvolvesMultipleCountries; + } + + return ( + + {name} + + )} + headingLevel={4} + withInternalPadding + withHeaderBorder + withoutWrapInHeading + icons={ifrc_severity_level ? ( + <> + + + )} + value={ifrc_severity_level_display} + withoutLabelColon + /> + } + value={countriesInfoDisplay} + withoutLabelColon + /> + + )} + /> + + + ) : undefined} + actions={isAuthenticated && ( + + )} + headerDescription={( + + )} + childrenContainerClassName={styles.figures} + > + + {strings.operationCardTargetedPopulation} + + )} + compactValue + /> +
+ {/* FIXME This keyFigure should route to emergencies/id/report */} + + {strings.operationCardFunding} + + )} + compactValue + progress={coverage} + progressDescription={fundingCoverageDescription} + /> + + ); +} + +export default OperationCard; diff --git a/src/components/domain/HighlightedOperations/OperationCard/styles.module.css b/app/src/components/domain/HighlightedOperations/OperationCard/styles.module.css similarity index 100% rename from src/components/domain/HighlightedOperations/OperationCard/styles.module.css rename to app/src/components/domain/HighlightedOperations/OperationCard/styles.module.css diff --git a/src/components/domain/HighlightedOperations/i18n.json b/app/src/components/domain/HighlightedOperations/i18n.json similarity index 100% rename from src/components/domain/HighlightedOperations/i18n.json rename to app/src/components/domain/HighlightedOperations/i18n.json diff --git a/app/src/components/domain/HighlightedOperations/index.tsx b/app/src/components/domain/HighlightedOperations/index.tsx new file mode 100644 index 000000000..2dd585364 --- /dev/null +++ b/app/src/components/domain/HighlightedOperations/index.tsx @@ -0,0 +1,187 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + Grid, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + listToMap, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useUserMe from '#hooks/domain/useUserMe'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useRequest, +} from '#utils/restRequest'; + +import OperationCard from './OperationCard'; + +import i18n from './i18n.json'; + +type EventQueryParams = GoApiUrlQuery<'/api/v2/event/'>; +type EventResponse = GoApiResponse<'/api/v2/event/'>; +type EventListItem = NonNullable[number]; + +const keySelector = (event: EventListItem) => event.id; + +type BaseProps = { + className?: string; +} + +type CountryProps = { + variant: 'country'; + countryId: number; +} + +type RegionProps = { + variant: 'region'; + regionId: number; +} + +type GlobalProps = { + variant: 'global'; +} + +type Props = BaseProps & (CountryProps | RegionProps | GlobalProps); + +function HighlightedOperations(props: Props) { + const { + className, + variant, + } = props; + + const strings = useTranslation(i18n); + + // eslint-disable-next-line react/destructuring-assignment + const regionId = variant === 'region' ? props.regionId : undefined; + + // eslint-disable-next-line react/destructuring-assignment + const countryId = variant === 'country' ? props.countryId : undefined; + + const query = useMemo( + () => { + if (variant === 'global') { + return { is_featured: true }; + } + + if (variant === 'country') { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + thirtyDaysAgo.setHours(0, 0, 0, 0); + + return { + countries__in: countryId, + disaster_start_date__gte: thirtyDaysAgo.toISOString(), + ordering: '-disaster_start_date', + }; + } + + return { + is_featured_region: true, + regions__in: regionId, + }; + }, + [variant, regionId, countryId], + ); + + const { + error: featuredEmergencyResponseError, + pending: featuredEmergencyPending, + response: featuredEmergencyResponse, + } = useRequest({ + url: '/api/v2/event/', + query, + }); + + const meResponse = useUserMe(); + + // FIXME: the subscription information should be sent from the server on + // the emergency + const subscriptionMap = listToMap( + meResponse?.subscription?.filter( + (sub) => isDefined(sub.event), + ) ?? [], + (sub) => sub.event ?? 'unknown', + () => true, + ); + + const rendererParams = useCallback( + (_: number, emergency: EventListItem) => { + const isSubscribed = subscriptionMap[emergency.id] ?? false; + return { + data: emergency, + isSubscribed, + }; + }, + [subscriptionMap], + ); + + const featuredEmergencies = featuredEmergencyResponse?.results; + + const urlSearch = useMemo( + () => { + if (variant === 'country') { + return `country=${countryId}`; + } + + if (variant === 'region') { + return `region=${regionId}`; + } + + return undefined; + }, + [regionId, countryId, variant], + ); + + const viewAllLabel = useMemo( + () => { + if (variant === 'country') { + return strings.highlightedOperationsViewAllInCountry; + } + + if (variant === 'region') { + return strings.highlightedOperationsViewAllInRegion; + } + + return strings.highlightedOperationsViewAll; + }, + [variant, strings], + ); + + return ( + + {viewAllLabel} + + )} + > + + + ); +} + +export default HighlightedOperations; diff --git a/src/components/domain/HistoricalDataChart/i18n.json b/app/src/components/domain/HistoricalDataChart/i18n.json similarity index 100% rename from src/components/domain/HistoricalDataChart/i18n.json rename to app/src/components/domain/HistoricalDataChart/i18n.json diff --git a/app/src/components/domain/HistoricalDataChart/index.tsx b/app/src/components/domain/HistoricalDataChart/index.tsx new file mode 100644 index 000000000..362d15689 --- /dev/null +++ b/app/src/components/domain/HistoricalDataChart/index.tsx @@ -0,0 +1,355 @@ +import { useRef } from 'react'; +import { + CycloneIcon, + DroughtIcon, + FloodIcon, + FoodSecurityIcon, +} from '@ifrc-go/icons'; +import { + ChartAxes, + Container, + SelectInput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getPercentage, + numericIdSelector, + stringNameSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, + isNotDefined, + unique, +} from '@togglecorp/fujs'; + +import useChartData from '#hooks/useChartData'; +import useInputState from '#hooks/useInputState'; +import { + COLOR_HAZARD_CYCLONE, + COLOR_HAZARD_DROUGHT, + COLOR_HAZARD_FLOOD, + COLOR_HAZARD_FOOD_INSECURITY, + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GoHistoricalResponse = GoApiResponse<'/api/v2/go-historical/'>; +type EventItem = NonNullable[number]; +type PartialDisasterType = EventItem['dtype']; + +type DisasterType = Omit & { + name: string; +} + +// FIXME: how can we guarantee that these disaster type ids do not change +// TODO: Add a flag in database to mark these disaster as risk hazards +const DISASTER_FLOOD = 12; +const DISASTER_FLASH_FLOOD = 27; +const DISASTER_CYCLONE = 4; +const DISASTER_FOOD_INSECURITY = 21; +const DISASTER_DROUGHT = 20; + +const validDisastersForChart: Record = { + [DISASTER_FLOOD]: true, + [DISASTER_FLASH_FLOOD]: true, + [DISASTER_CYCLONE]: true, + [DISASTER_FOOD_INSECURITY]: true, + [DISASTER_DROUGHT]: true, +}; + +const X_AXIS_HEIGHT = 32; +const Y_AXIS_WIDTH = 64; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 0, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +const currentYear = new Date().getFullYear(); +const firstDayOfYear = new Date(currentYear, 0, 1); +const lastDayOfYear = new Date(currentYear, 11, 31); + +function isValidDisaster( + disaster: PartialDisasterType | null | undefined, +): disaster is DisasterType { + if (isNotDefined(disaster)) { + return false; + } + if (isFalsyString(disaster.name)) { + return false; + } + return validDisastersForChart[disaster.id]; +} + +const hazardIdToColorMap: Record = { + [DISASTER_FLOOD]: COLOR_HAZARD_FLOOD, + [DISASTER_FLASH_FLOOD]: COLOR_HAZARD_FLOOD, + [DISASTER_CYCLONE]: COLOR_HAZARD_CYCLONE, + [DISASTER_FOOD_INSECURITY]: COLOR_HAZARD_FOOD_INSECURITY, + [DISASTER_DROUGHT]: COLOR_HAZARD_DROUGHT, +}; + +const hazardIdToIconMap: Record = { + [DISASTER_FLOOD]: , + [DISASTER_FLASH_FLOOD]: , + [DISASTER_CYCLONE]: , + [DISASTER_FOOD_INSECURITY]: , + [DISASTER_DROUGHT]: , +}; + +function getNumAffected(event: EventItem) { + if (isDefined(event.num_affected)) { + return event.num_affected; + } + + return sumSafe( + event.appeals.map( + (appeal) => appeal.num_beneficiaries, + ), + ); +} + +type RegionProps = { + variant: 'region'; + regionId: number; + countryId?: never; +} + +type CountryProps = { + variant: 'country'; + countryId: number; + regionId?: never; +} + +type Props = RegionProps | CountryProps; + +function HistoricalDataChart(props: Props) { + const { + countryId, + regionId, + variant, + } = props; + const strings = useTranslation(i18n); + + const [disasterFilter, setDisasterFilter] = useInputState(undefined); + const chartContainerRef = useRef(null); + + const { response: historicalDataResponse } = useRequest({ + skip: variant === 'country' ? isNotDefined(countryId) : isNotDefined(regionId), + url: '/api/v2/go-historical/', + query: variant === 'country' ? { + countries: [countryId], + } : { region: regionId }, + }); + + const disasterOptions = unique( + historicalDataResponse?.results?.map( + (event) => { + if (!isValidDisaster(event.dtype)) { + return undefined; + } + + return { + id: event.dtype.id, + name: event.dtype.name, + }; + }, + ).filter(isDefined) ?? [], + (option) => option.id, + ); + + const filteredEvents = historicalDataResponse?.results?.map( + (event) => { + if (!isValidDisaster(event.dtype)) { + return undefined; + } + + if (isDefined(disasterFilter) && disasterFilter !== event.dtype.id) { + return undefined; + } + + const numAffected = getNumAffected(event); + + if (isNotDefined(numAffected)) { + return undefined; + } + + return { + ...event, + dtype: event.dtype, + num_affected: numAffected, + }; + }, + ).filter(isDefined); + + const { + dataPoints, + xAxisTicks, + yAxisTicks, + chartSize, + } = useChartData( + filteredEvents, + { + containerRef: chartContainerRef, + chartOffset, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + keySelector: (datum) => datum.id, + xValueSelector: (datum) => { + const date = new Date(datum.disaster_start_date); + date.setFullYear(currentYear); + return date.getTime(); + }, + type: 'temporal', + xAxisLabelSelector: (timestamp) => ( + new Date(timestamp).toLocaleString( + navigator.language, + { month: 'short' }, + ) + ), + yValueSelector: (datum) => datum.num_affected, + xDomain: { + min: firstDayOfYear.getTime(), + max: lastDayOfYear.getTime(), + }, + }, + ); + + return ( + + )} + > +
+ + + + {dataPoints?.map( + (point) => { + const funded = sumSafe( + point.originalData.appeals.map( + (appeal) => appeal.amount_funded, + ), + ) ?? 0; + const requested = sumSafe( + point.originalData.appeals.map( + (appeal) => appeal.amount_requested, + ), + ) ?? 0; + + const coverage = requested === 0 + ? undefined + : getPercentage(funded, requested); + + return ( +
+ {hazardIdToIconMap[point.originalData.dtype.id]} + + + + + + + )} + /> +
+ ); + }, + )} +
+
+ {disasterOptions.map( + (disaster) => ( +
+
+ {hazardIdToIconMap[disaster.id]} +
+
+ {disaster.name} +
+
+ ), + )} +
+
+ ); +} + +export default HistoricalDataChart; diff --git a/src/components/domain/HistoricalDataChart/styles.module.css b/app/src/components/domain/HistoricalDataChart/styles.module.css similarity index 100% rename from src/components/domain/HistoricalDataChart/styles.module.css rename to app/src/components/domain/HistoricalDataChart/styles.module.css diff --git a/src/components/domain/ImageWithCaptionInput/i18n.json b/app/src/components/domain/ImageWithCaptionInput/i18n.json similarity index 100% rename from src/components/domain/ImageWithCaptionInput/i18n.json rename to app/src/components/domain/ImageWithCaptionInput/i18n.json diff --git a/app/src/components/domain/ImageWithCaptionInput/index.tsx b/app/src/components/domain/ImageWithCaptionInput/index.tsx new file mode 100644 index 000000000..d55aa5e2d --- /dev/null +++ b/app/src/components/domain/ImageWithCaptionInput/index.tsx @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; +import { TextInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, + randomString, +} from '@togglecorp/fujs'; +import { + getErrorObject, + type ObjectError, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import GoSingleFileInput, { type SupportedPaths } from '#components/domain/GoSingleFileInput'; +import NonFieldError from '#components/NonFieldError'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type Value = { + id?: number | undefined; + client_id: string; + caption?: string | undefined; +}; + +interface Props { + className?: string; + name: N; + url: SupportedPaths; + value: Value | null | undefined; + onChange: (value: SetValueArg | undefined, name: N) => void; + error: ObjectError | undefined; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch>>; + label: React.ReactNode; + icons?: React.ReactNode; + actions?: React.ReactNode; + disabled?: boolean; +} + +// FIXME: Move this to components +function ImageWithCaptionInput(props: Props) { + const { + className, + name, + value, + url, + fileIdToUrlMap, + setFileIdToUrlMap, + onChange, + error: formError, + label, + icons, + actions, + disabled, + } = props; + + const strings = useTranslation(i18n); + + const setFieldValue = useFormObject( + name, + onChange, + () => ({ client_id: randomString() }), + ); + + const error = getErrorObject(formError); + + const fileUrl = isDefined(value) && isDefined(value.id) + ? fileIdToUrlMap[value.id] + : undefined; + + const handleFileInputChange = useCallback((newFileId: number | undefined) => { + if (!newFileId) { + onChange(undefined, name); + } else { + setFieldValue(newFileId, 'id'); + } + }, [ + setFieldValue, + onChange, + name, + ]); + + return ( +
+ + + ) : undefined} + clearable + > + {label} + + {value?.id && ( + + )} +
+ ); +} + +export default ImageWithCaptionInput; diff --git a/src/components/domain/ImageWithCaptionInput/styles.module.css b/app/src/components/domain/ImageWithCaptionInput/styles.module.css similarity index 100% rename from src/components/domain/ImageWithCaptionInput/styles.module.css rename to app/src/components/domain/ImageWithCaptionInput/styles.module.css diff --git a/src/components/domain/KeywordSearchSelectInput/i18n.json b/app/src/components/domain/KeywordSearchSelectInput/i18n.json similarity index 100% rename from src/components/domain/KeywordSearchSelectInput/i18n.json rename to app/src/components/domain/KeywordSearchSelectInput/i18n.json diff --git a/app/src/components/domain/KeywordSearchSelectInput/index.tsx b/app/src/components/domain/KeywordSearchSelectInput/index.tsx new file mode 100644 index 000000000..b6d47b61a --- /dev/null +++ b/app/src/components/domain/KeywordSearchSelectInput/index.tsx @@ -0,0 +1,323 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { SearchSelectInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { sumSafe } from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, + mapToList, +} from '@togglecorp/fujs'; + +import useDebouncedValue from '#hooks/useDebouncedValue'; +import useRouting from '#hooks/useRouting'; +import { defaultRanking } from '#utils/common'; +import { KEY_URL_SEARCH } from '#utils/constants'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useRequest, +} from '#utils/restRequest'; + +import { type WrappedRoutes } from '../../../App/routes'; + +import i18n from './i18n.json'; + +type GetSearchParams = GoApiUrlQuery<'/api/v1/search/'>; +type SearchResponse = GoApiResponse<'/api/v1/search/'>; +type SearchResponseKeys = NonNullable; + +type SearchItem = { + id: number; + name: string; + type: SearchResponseKeys; + score: number; + pk: string; +} + +interface Route { + route: keyof WrappedRoutes, + routeParams: string; +} + +function keySelector(d: SearchItem) { + return d.pk; +} + +function labelSelector(d: SearchItem) { + return d.name; +} + +const searchTypeToRouteMap: Record = { + regions: { + route: 'regionsLayout', + routeParams: 'regionId', + }, + countries: { + route: 'countriesLayout', + routeParams: 'countryId', + }, + district_province_response: { + route: 'countriesLayout', + routeParams: 'countryId', + }, + emergencies: { + route: 'emergenciesLayout', + routeParams: 'emergencyId', + }, + reports: { + route: 'fieldReportDetails', + routeParams: 'fieldReportId', + }, + projects: { + route: 'threeWProjectDetail', + routeParams: 'projectId', + }, + rapid_response_deployments: { + route: 'emergenciesLayout', + routeParams: 'emergencyId', + }, + surge_alerts: { + route: 'emergenciesLayout', + routeParams: 'emergencyId', + }, + surge_deployments: { + route: 'emergenciesLayout', + routeParams: 'emergencyId', + }, +}; + +function KeywordSearchSelectInput() { + const [opened, setOpened] = useState(false); + const [searchText, setSearchText] = useState(undefined); + const debouncedSearchText = useDebouncedValue(searchText); + const { navigate } = useRouting(); + const strings = useTranslation(i18n); + + const searchTypeToLabelMap: Record = useMemo(() => ({ + countries: strings.country, + district_province_response: strings.district, + regions: strings.region, + surge_deployments: strings.surgeDeployment, + surge_alerts: strings.surgeAlert, + rapid_response_deployments: strings.rrDeployment, + emergencies: strings.emergency, + projects: strings.project, + reports: strings.report, + }), [ + strings.country, + strings.district, + strings.region, + strings.surgeDeployment, + strings.surgeAlert, + strings.rrDeployment, + strings.emergency, + strings.project, + strings.report, + ]); + + const descriptionSelector = useCallback((d: SearchItem) => ( + searchTypeToLabelMap[d.type] + ), [searchTypeToLabelMap]); + + const trimmedSearchText = debouncedSearchText?.trim(); + + const query: GetSearchParams | undefined = trimmedSearchText ? { + keyword: trimmedSearchText, + } : undefined; + + const { + pending, + response, + } = useRequest({ + skip: !opened || isNotDefined(trimmedSearchText) || trimmedSearchText.length === 0, + url: '/api/v1/search/', + query, + preserveResponse: true, + }); + + const rankedSearchResponseKeys = useMemo( + () => { + const searchResponseKeys = Object.keys(response ?? {}) as SearchResponseKeys[]; + + function getAverageScore( + results: { score: number | null | undefined }[] | undefined | null, + ) { + const scoreList = results?.map((result) => result.score); + if (isNotDefined(scoreList) || scoreList.length === 0) { + return 0; + } + + const totalScore = sumSafe(scoreList) ?? 0; + return totalScore / scoreList.length; + } + + function compareStaticKey(a: string, b: string, direction?: number) { + const staticKeyRanking: Record = { + regions: 1, + countries: 2, + district_province_response: 3, + }; + + return compareNumber( + staticKeyRanking[a] ?? 4, + staticKeyRanking[b] ?? 4, + direction, + ); + } + + searchResponseKeys.sort( + (a, b) => { + const aScore = getAverageScore(response?.[a]) ?? 0; + const bScore = getAverageScore(response?.[b]) ?? 0; + + const aDefaultRank = defaultRanking[a]; + const bDefaultRank = defaultRanking[b]; + + return compareStaticKey(a, b) + || compareNumber(aScore, bScore, -1) + || compareNumber(aDefaultRank, bDefaultRank, -1); + }, + ); + return searchResponseKeys; + }, + [response], + ); + + const sortByRankedKeys = useCallback((a: SearchItem, b: SearchItem) => { + const indexA = rankedSearchResponseKeys.indexOf(a.type); + const indexB = rankedSearchResponseKeys.indexOf(b.type); + + return compareNumber(indexA, indexB); + }, [rankedSearchResponseKeys]); + + const options = useMemo( + (): SearchItem[] => { + if (isNotDefined(response)) { + return []; + } + const { + surge_alerts, + surge_deployments, + rapid_response_deployments, + district_province_response, + ...others + } = response; + + const results = mapToList( + (others), + (value, key) => ( + value?.map((val) => ({ + id: val.id, + name: val.name, + type: key as keyof SearchResponse, + pk: `${val.id}-${key}`, + score: val.score, + })) + ), + )?.flat().filter(isDefined); + + const surgeResults = mapToList( + { + surge_alerts, + surge_deployments, + rapid_response_deployments, + }, + (value, key) => ( + value?.map((val) => ({ + id: val.event_id, + name: val.event_name, + type: key as keyof SearchResponse, + pk: `${val.id}-${key}`, + score: val.score, + })) + ), + )?.flat().filter(isDefined); + + const districtProvinceResults = mapToList( + { + district_province_response, + }, + (value, key) => ( + value?.map((val) => ({ + id: val.country_id, + name: val.name, + type: key as keyof SearchResponse, + pk: `${val.id}-${key}`, + score: val.score, + })) + ), + )?.flat().filter(isDefined); + + return [ + ...(results ?? []), + ...(surgeResults ?? []), + ...(districtProvinceResults ?? []), + ].sort(sortByRankedKeys); + }, + [response, sortByRankedKeys], + ); + + const handleOptionSelect = useCallback(( + _: string | undefined, + __: string, + option: SearchItem | undefined, + ) => { + if (!option) { + return; + } + + const route = searchTypeToRouteMap[option.type]; + navigate( + route.route, + { + params: { + [route.routeParams]: option.id, + }, + }, + ); + }, [navigate]); + + const handleSearchInputEnter = useCallback((text: string | undefined) => { + // NOTE: We are not deliberately not using debouncedSearchText here + const searchStringSafe = text?.trim() ?? ''; + if (searchStringSafe.length > 0) { + navigate( + 'search', + { search: `${KEY_URL_SEARCH}=${text}` }, + ); + } + }, [ + navigate, + ]); + + return ( + } + selectedOnTop={false} + onEnterWithoutOption={handleSearchInputEnter} + /> + ); +} + +export default KeywordSearchSelectInput; diff --git a/src/components/domain/LanguageMismatchMessage/i18n.json b/app/src/components/domain/LanguageMismatchMessage/i18n.json similarity index 100% rename from src/components/domain/LanguageMismatchMessage/i18n.json rename to app/src/components/domain/LanguageMismatchMessage/i18n.json diff --git a/app/src/components/domain/LanguageMismatchMessage/index.tsx b/app/src/components/domain/LanguageMismatchMessage/index.tsx new file mode 100644 index 000000000..bdb95ec9d --- /dev/null +++ b/app/src/components/domain/LanguageMismatchMessage/index.tsx @@ -0,0 +1,49 @@ +import { Message } from '@ifrc-go/ui'; +import { Language } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + languageNameMapEn, + resolveToString, +} from '@ifrc-go/ui/utils'; + +import i18n from './i18n.json'; + +interface Props { + title?: React.ReactNode; + + // FIXME: typings should be fixed in the server + // this should be of type Language + originalLanguage: string; +} + +function LanguageMismatchMessage(props: Props) { + const strings = useTranslation(i18n); + + const { + title = strings.languageMismatchErrorTitle, + originalLanguage, + } = props; + + return ( + + ); +} + +export default LanguageMismatchMessage; diff --git a/src/components/domain/MultiImageWithCaptionInput/i18n.json b/app/src/components/domain/MultiImageWithCaptionInput/i18n.json similarity index 100% rename from src/components/domain/MultiImageWithCaptionInput/i18n.json rename to app/src/components/domain/MultiImageWithCaptionInput/i18n.json diff --git a/app/src/components/domain/MultiImageWithCaptionInput/index.tsx b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx new file mode 100644 index 000000000..e40517878 --- /dev/null +++ b/app/src/components/domain/MultiImageWithCaptionInput/index.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { DeleteBinLineIcon } from '@ifrc-go/icons'; +import { + IconButton, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, + isNotDefined, + randomString, +} from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type SetValueArg, + useFormArray, +} from '@togglecorp/toggle-form'; + +import GoMultiFileInput, { type SupportedPaths } from '#components/domain/GoMultiFileInput'; +import NonFieldError from '#components/NonFieldError'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type Value = { + client_id: string; + id?: number; + caption?: string; +}; + +interface Props { + className?: string; + name: N; + url: SupportedPaths; + value: Value[] | null | undefined; + onChange: (value: SetValueArg, name: N) => void; + error: ArrayError | undefined; + fileIdToUrlMap: Record; + setFileIdToUrlMap?: React.Dispatch>>; + label: React.ReactNode; + icons?: React.ReactNode; + actions?: React.ReactNode; + disabled?: boolean; +} + +// FIXME: Move this to components +function MultiImageWithCaptionInput(props: Props) { + const { + className, + name, + value, + url, + fileIdToUrlMap, + setFileIdToUrlMap, + onChange, + error: formError, + label, + icons, + actions, + disabled, + } = props; + + const strings = useTranslation(i18n); + + const error = getErrorObject(formError); + + const { + setValue: setFieldValue, + removeValue, + } = useFormArray(name, onChange); + + const handleFileInputChange = useCallback( + (newValue: number[] | undefined) => { + if (isDefined(newValue)) { + newValue.forEach( + (fileId: number, index: number) => { + const oldValue = value?.[index]; + + if (isNotDefined(oldValue)) { + setFieldValue( + { + client_id: String(fileId), + id: fileId, + }, + index, + ); + } + }, + ); + } + }, + [value, setFieldValue], + ); + + const handleCaptionChange = useCallback( + (newValue: string | undefined, index: number) => { + setFieldValue( + (prevValue) => { + if (isNotDefined(prevValue)) { + return { + client_id: randomString(), + caption: newValue, + }; + } + + return { + ...prevValue, + caption: newValue, + }; + }, + index, + ); + }, + [setFieldValue], + ); + + const fileInputValue = useMemo(() => ( + value + ?.map((fileValue) => fileValue.id) + .filter(isDefined) + ), [value]); + + return ( +
+ + + {label} + + {value && value.length > 0 && ( +
+ {value?.map((fileValue, index) => { + // NOTE: Not sure why this is here, need to + // talk with @frozenhelium + if (isNotDefined(fileValue.id)) { + return null; + } + + const imageError = getErrorObject(error?.[fileValue.client_id]); + + return ( +
+ + + + + {strings.imagePreviewAlt} + +
+ ); + })} +
+ )} +
+ ); +} + +export default MultiImageWithCaptionInput; diff --git a/src/components/domain/MultiImageWithCaptionInput/styles.module.css b/app/src/components/domain/MultiImageWithCaptionInput/styles.module.css similarity index 100% rename from src/components/domain/MultiImageWithCaptionInput/styles.module.css rename to app/src/components/domain/MultiImageWithCaptionInput/styles.module.css diff --git a/src/components/domain/NationalSocietyMultiSelectInput.tsx b/app/src/components/domain/NationalSocietyMultiSelectInput.tsx similarity index 86% rename from src/components/domain/NationalSocietyMultiSelectInput.tsx rename to app/src/components/domain/NationalSocietyMultiSelectInput.tsx index 94bec889f..6fac81eab 100644 --- a/src/components/domain/NationalSocietyMultiSelectInput.tsx +++ b/app/src/components/domain/NationalSocietyMultiSelectInput.tsx @@ -1,6 +1,9 @@ -import type { MultiSelectInputProps } from '#components/MultiSelectInput'; -import MultiSelectInput from '#components/MultiSelectInput'; -import { numericIdSelector } from '#utils/selectors'; +import { + MultiSelectInput, + MultiSelectInputProps, +} from '@ifrc-go/ui'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; + import useNationalSociety, { NationalSociety } from '#hooks/domain/useNationalSociety'; function countrySocietyNameSelector(country: NationalSociety) { diff --git a/src/components/domain/NationalSocietySelectInput.tsx b/app/src/components/domain/NationalSocietySelectInput.tsx similarity index 90% rename from src/components/domain/NationalSocietySelectInput.tsx rename to app/src/components/domain/NationalSocietySelectInput.tsx index faf8c61e9..74a54ab73 100644 --- a/src/components/domain/NationalSocietySelectInput.tsx +++ b/app/src/components/domain/NationalSocietySelectInput.tsx @@ -1,8 +1,10 @@ +import { + SelectInput, + SelectInputProps, +} from '@ifrc-go/ui'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; import { isDefined } from '@togglecorp/fujs'; -import type { Props as SelectInputProps } from '#components/SelectInput'; -import SelectInput from '#components/SelectInput'; -import { numericIdSelector } from '#utils/selectors'; import useNationalSociety, { NationalSociety } from '#hooks/domain/useNationalSociety'; function countrySocietyNameSelector(country: NationalSociety) { diff --git a/src/components/domain/NonEnglishFormCreationMessage/i18n.json b/app/src/components/domain/NonEnglishFormCreationMessage/i18n.json similarity index 100% rename from src/components/domain/NonEnglishFormCreationMessage/i18n.json rename to app/src/components/domain/NonEnglishFormCreationMessage/i18n.json diff --git a/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx b/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx new file mode 100644 index 000000000..2ddb6fd7d --- /dev/null +++ b/app/src/components/domain/NonEnglishFormCreationMessage/index.tsx @@ -0,0 +1,24 @@ +import { Message } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import i18n from './i18n.json'; + +interface Props { + title?: React.ReactNode; +} + +function NonEnglishFormCreationMessage(props: Props) { + const strings = useTranslation(i18n); + const { title = strings.formNonEnglishErrorTitle } = props; + + return ( + + ); +} + +export default NonEnglishFormCreationMessage; diff --git a/src/components/domain/OperationListItem/i18n.json b/app/src/components/domain/OperationListItem/i18n.json similarity index 100% rename from src/components/domain/OperationListItem/i18n.json rename to app/src/components/domain/OperationListItem/i18n.json diff --git a/app/src/components/domain/OperationListItem/index.tsx b/app/src/components/domain/OperationListItem/index.tsx new file mode 100644 index 000000000..e9937ce2c --- /dev/null +++ b/app/src/components/domain/OperationListItem/index.tsx @@ -0,0 +1,86 @@ +import { + Button, + Header, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { + type GoApiResponse, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type EventGet = GoApiResponse<'/api/v2/event/'>; +type EventResponseItem = NonNullable[number]; + +export interface Props { + className?: string; + eventItem: EventResponseItem; + updateSubscibedEvents: () => void; + isLastItem: boolean; +} + +function OperationInfoCard(props: Props) { + const { + className, + eventItem: { + id, + name, + updated_at, + }, + updateSubscibedEvents: updateSubscribedEvents, + isLastItem, + } = props; + + const strings = useTranslation(i18n); + + const { + pending: removeSubscriptionPending, + trigger: triggerRemoveSubscription, + } = useLazyRequest({ + method: 'POST', + body: (eventId: number) => ([{ + value: eventId, + }]), + url: '/api/v2/del_subscription/', + onSuccess: updateSubscribedEvents, + }); + + const subscriptionPending = removeSubscriptionPending; + + return ( + <> +
+ {strings.operationUnfollowButtonLabel} + + )} + > + +
+ {!isLastItem && ( +
+ )} + + ); +} + +export default OperationInfoCard; diff --git a/src/components/domain/OperationListItem/styles.module.css b/app/src/components/domain/OperationListItem/styles.module.css similarity index 100% rename from src/components/domain/OperationListItem/styles.module.css rename to app/src/components/domain/OperationListItem/styles.module.css diff --git a/src/components/domain/PerAssessmentSummary/i18n.json b/app/src/components/domain/PerAssessmentSummary/i18n.json similarity index 100% rename from src/components/domain/PerAssessmentSummary/i18n.json rename to app/src/components/domain/PerAssessmentSummary/i18n.json diff --git a/app/src/components/domain/PerAssessmentSummary/index.tsx b/app/src/components/domain/PerAssessmentSummary/index.tsx new file mode 100644 index 000000000..6ed063d14 --- /dev/null +++ b/app/src/components/domain/PerAssessmentSummary/index.tsx @@ -0,0 +1,274 @@ +import { + ExpandableContainer, + NumberOutput, + ProgressBar, + StackedProgressBar, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getPercentage, + resolveToString, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + _cs, + compareNumber, + isDefined, + isNotDefined, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; +import { PartialForm } from '@togglecorp/toggle-form'; + +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type PerOptionsResponse = GoApiResponse<'/api/v2/per-options/'>; +type AssessmentRequestBody = GoApiResponse<'/api/v2/per-assessment/{id}/', 'PATCH'>; + +export type PartialAssessment = PartialForm< + AssessmentRequestBody, + 'area' | 'component' | 'question' +>; + +interface Props { + className?: string; + perOptionsResponse: PerOptionsResponse | undefined; + areaResponses: PartialAssessment['area_responses'] | undefined; + totalQuestionCount: number | undefined; + areaIdToTitleMap: Record; +} + +const colors = [ + 'var(--go-ui-color-red-90)', + 'var(--go-ui-color-red-70)', + 'var(--go-ui-color-red-50)', + 'var(--go-ui-color-red-40)', + 'var(--go-ui-color-red-20)', + 'var(--go-ui-color-red-10)', +]; + +function PerAssessmentSummary(props: Props) { + const { + className, + perOptionsResponse, + areaResponses, + totalQuestionCount, + areaIdToTitleMap, + } = props; + + const strings = useTranslation(i18n); + + const ratingIdToTitleMap = listToMap( + perOptionsResponse?.componentratings, + (rating) => rating.id, + (rating) => rating.title, + ); + + const ratingIdToValueMap = listToMap( + perOptionsResponse?.componentratings, + (rating) => rating.id, + (rating) => rating.value, + ); + + const answerIdToLabelMap = listToMap( + perOptionsResponse?.answers, + (answer) => answer.id, + (answer) => answer.text, + ); + + const allAnsweredResponses = areaResponses?.map( + (areaResponse) => areaResponse?.component_responses?.map( + (componentResponse) => componentResponse.question_responses, + ), + ).flat(2).filter( + (questionResponse) => isDefined(questionResponse?.answer), + ); + + const groupedResponses = listToGroupList( + allAnsweredResponses ?? [], + (response) => response?.answer ?? -1, + ); + + const groupedResponseList = mapToList( + groupedResponses, + (responses, answer) => { + if (isNotDefined(answer)) { + return undefined; + } + + const numericAnswer = Number(answer); + + if (numericAnswer === -1) { + return undefined; + } + + return { + answer: numericAnswer, + responses, + answerDisplay: answerIdToLabelMap?.[Number(answer)], + }; + }, + ).filter(isDefined); + + const allAnsweredComponents = areaResponses?.map( + (areaResponse) => areaResponse?.component_responses, + ).flat().filter((component) => isDefined(component?.rating)); + + const ratingGroupedComponents = listToGroupList( + allAnsweredComponents, + (component) => component?.rating ?? 0, + ); + + const statusGroupedComponentList = mapToList( + ratingGroupedComponents, + (components, rating) => ({ + ratingId: Number(rating), + ratingValue: ratingIdToValueMap?.[Number(rating)], + ratingDisplay: ratingIdToTitleMap?.[Number(rating)], + components, + }), + )?.sort( + (a, b) => compareNumber(a.ratingValue, b.ratingValue, -1), + ); + + const averageRatingByAreaMap = listToMap( + areaResponses ?? [], + (areaResponse) => areaResponse?.area ?? -1, + (areaResponse) => { + // NOTE: do we take the average of only rated components or of all the + // components? + const filteredComponents = areaResponse?.component_responses?.filter( + (component) => isDefined(component?.rating_details) + && isDefined(component.rating_details.value) + && component.rating_details?.value !== 0, + ) ?? []; + + if (filteredComponents.length === 0) { + return 0; + } + + const ratings = filteredComponents.map( + (component) => ( + isDefined(component.rating) + ? ratingIdToValueMap?.[component.rating] + : 0 + ), + ); + + const ratingSum = sumSafe(ratings) ?? 0; + const avgRating = ratingSum / filteredComponents.length; + + return avgRating; + }, + ); + + const averageRatingByAreaList = mapToList( + areaIdToTitleMap, + (_, areaId) => ({ + areaId, + rating: averageRatingByAreaMap[Number(areaId)] ?? 0, + areaDisplay: resolveToString( + strings.multiImageArea, + { areaId }, + ), + }), + ); + + const description = isDefined(allAnsweredResponses) && isDefined(totalQuestionCount) + ? `${allAnsweredResponses?.length ?? 0} / ${resolveToString( + strings.benchmarksAssessed, + { totalQuestionCount }, + )}` + : undefined; + + // NOTE: We need to discuss UI of this component + return ( + +
+ + {groupedResponseList.map( + (groupedResponse) => ( + + ), + )} +
+ )} + /> +
+ ( + statusGroupedComponent.components.length + ) + } + // FIXME: don't use inline selectors + colorSelector={(_, i) => colors[i]} + // FIXME: don't use inline selectors + labelSelector={ + (statusGroupedComponent) => `${statusGroupedComponent.ratingValue}-${statusGroupedComponent.ratingDisplay}` + } + /> +
+ {averageRatingByAreaList.map( + (rating) => ( +
+ +
+
+
+
+ {rating.areaDisplay} +
+
+ ), + )} +
+ + ); +} + +export default PerAssessmentSummary; diff --git a/src/components/domain/PerAssessmentSummary/styles.module.css b/app/src/components/domain/PerAssessmentSummary/styles.module.css similarity index 100% rename from src/components/domain/PerAssessmentSummary/styles.module.css rename to app/src/components/domain/PerAssessmentSummary/styles.module.css diff --git a/src/components/domain/ProjectActions/i18n.json b/app/src/components/domain/ProjectActions/i18n.json similarity index 100% rename from src/components/domain/ProjectActions/i18n.json rename to app/src/components/domain/ProjectActions/i18n.json diff --git a/app/src/components/domain/ProjectActions/index.tsx b/app/src/components/domain/ProjectActions/index.tsx new file mode 100644 index 000000000..d1a323c0d --- /dev/null +++ b/app/src/components/domain/ProjectActions/index.tsx @@ -0,0 +1,125 @@ +import { + CopyLineIcon, + DeleteBinLineIcon, + HistoryLineIcon, + MoreFillIcon, + PencilFillIcon, + SearchLineIcon, +} from '@ifrc-go/icons'; +import { + BlockLoading, + ConfirmButton, + DropdownMenu, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import { adminUrl } from '#config'; +import useAlert from '#hooks/useAlert'; +import { resolveUrl } from '#utils/resolveUrl'; +import { + type GoApiResponse, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type Project = NonNullable['results']>[number]; + +export interface Props { + className?: string; + onProjectDeletionSuccess: () => void; + project: Project; +} + +function ProjectActions(props: Props) { + const strings = useTranslation(i18n); + const alert = useAlert(); + + const { + className, + project, + onProjectDeletionSuccess, + } = props; + + const { + pending: projectDeletionPending, + trigger: requestProjectDeletion, + } = useLazyRequest({ + url: '/api/v2/project/{id}/', + method: 'DELETE', + pathVariables: { id: project.id }, + onSuccess: onProjectDeletionSuccess, + onFailure: ({ value }) => { + alert.show( + resolveToString(strings.projectDeleteFailureMessage, { + message: value.messageForNotification, + }), + { variant: 'danger' }, + ); + }, + }); + + return ( + <> + {/* FIXME: this BlockLoading doesn't look good */} + {projectDeletionPending && } + + } + persistent + > + } + > + {strings.projectViewDetails} + + } + > + {strings.actionDropdownEditLabel} + + } + > + {strings.projectDuplicate} + + } + href={resolveUrl(adminUrl, `deployments/project/${project.id}/history/`)} + external + > + {strings.projectHistory} + + } + > + {strings.deleteProject} + + + + ); +} + +export default ProjectActions; diff --git a/src/components/domain/ProjectActions/styles.module.css b/app/src/components/domain/ProjectActions/styles.module.css similarity index 100% rename from src/components/domain/ProjectActions/styles.module.css rename to app/src/components/domain/ProjectActions/styles.module.css diff --git a/src/components/domain/RegionKeyFigures/i18n.json b/app/src/components/domain/RegionKeyFigures/i18n.json similarity index 100% rename from src/components/domain/RegionKeyFigures/i18n.json rename to app/src/components/domain/RegionKeyFigures/i18n.json diff --git a/app/src/components/domain/RegionKeyFigures/index.tsx b/app/src/components/domain/RegionKeyFigures/index.tsx new file mode 100644 index 000000000..b790588a5 --- /dev/null +++ b/app/src/components/domain/RegionKeyFigures/index.tsx @@ -0,0 +1,115 @@ +import { + AppealsIcon, + AppealsTwoIcon, + DrefIcon, + FundingCoverageIcon, + FundingIcon, + TargetedPopulationIcon, +} from '@ifrc-go/icons'; +import { + InfoPopup, + KeyFigure, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { getPercentage } from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; + +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type RegionResponse = GoApiResponse<'/api/v2/region/{id}/'>; + +interface Props { + regionId: string; + regionResponse: RegionResponse | undefined; +} + +function RegionKeyFigures(props: Props) { + const { + regionId, + regionResponse, + } = props; + + const strings = useTranslation(i18n); + + const { + pending: aggregatedAppealPending, + response: aggregatedAppealResponse, + } = useRequest({ + skip: isNotDefined(regionId), + url: '/api/v2/appeal/aggregated', + query: { region: Number(regionId) }, + }); + + const pending = aggregatedAppealPending; + + if (pending || !aggregatedAppealResponse || !regionResponse) { + return null; + } + + return ( + <> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.active_drefs} + info={( + + )} + label={strings.regionKeyFiguresActiveDrefs} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.active_appeals} + info={( + + )} + label={strings.regionKeyFiguresActiveAppeals} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.amount_requested_dref_included} + compactValue + label={strings.regionKeyFiguresBudget} + /> + } + className={styles.keyFigure} + value={getPercentage( + aggregatedAppealResponse?.amount_funded, + aggregatedAppealResponse?.amount_requested, + )} + suffix="%" + compactValue + label={strings.regionKeyFiguresAppealsFunding} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.target_population} + compactValue + label={strings.regionKeyFiguresTargetPop} + /> + } + className={styles.keyFigure} + value={regionResponse.country_plan_count} + compactValue + label={strings.regionKeyFiguresCountryPlan} + /> + + ); +} + +export default RegionKeyFigures; diff --git a/src/components/domain/RegionKeyFigures/styles.module.css b/app/src/components/domain/RegionKeyFigures/styles.module.css similarity index 100% rename from src/components/domain/RegionKeyFigures/styles.module.css rename to app/src/components/domain/RegionKeyFigures/styles.module.css diff --git a/src/components/domain/RegionSelectInput.tsx b/app/src/components/domain/RegionSelectInput.tsx similarity index 88% rename from src/components/domain/RegionSelectInput.tsx rename to app/src/components/domain/RegionSelectInput.tsx index c4048d100..5d845a8ce 100644 --- a/src/components/domain/RegionSelectInput.tsx +++ b/app/src/components/domain/RegionSelectInput.tsx @@ -1,8 +1,11 @@ -import type { Props as SelectInputProps } from '#components/SelectInput'; -import SelectInput from '#components/SelectInput'; -import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { + SelectInput, + SelectInputProps, +} from '@ifrc-go/ui'; +import { stringValueSelector } from '@ifrc-go/ui/utils'; + import { type components } from '#generated/types'; -import { stringValueSelector } from '#utils/selectors'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; export type RegionOption = components<'read'>['schemas']['ApiRegionNameEnum']; function regionKeySelector(option: RegionOption) { diff --git a/src/components/domain/RiskImminentEventMap/i18n.json b/app/src/components/domain/RiskImminentEventMap/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEventMap/i18n.json rename to app/src/components/domain/RiskImminentEventMap/i18n.json diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx new file mode 100644 index 000000000..42173d99b --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/index.tsx @@ -0,0 +1,367 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { ChevronLeftLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + List, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + mapToList, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapImage, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import getBbox from '@turf/bbox'; +import getBuffer from '@turf/buffer'; +import type { + LngLatBoundsLike, + SymbolLayer, +} from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import { type components } from '#generated/riskTypes'; +import useDebouncedValue from '#hooks/useDebouncedValue'; +import { + COLOR_WHITE, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; + +import { + exposureFillLayer, + geojsonSourceOptions, + hazardKeyToIconmap, + hazardPointIconLayout, + hazardPointLayer, + invisibleLayout, + trackArrowLayer, + trackOutlineLayer, + trackPointLayer, +} from './mapStyles'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const mapImageOption = { + sdf: true, +}; + +type HazardType = components<'read'>['schemas']['HazardTypeEnum']; + +const hazardKeys = Object.keys(hazardKeyToIconmap) as HazardType[]; + +const mapIcons = mapToList( + hazardKeyToIconmap, + (icon, key) => (icon ? ({ key, icon }) : undefined), +).filter(isDefined); + +type EventPointProperties = { + id: string | number, + hazard_type: HazardType, +} + +export type EventPointFeature = GeoJSON.Feature; + +interface EventItemProps { + data: EVENT; + onExpandClick: (eventId: number | string) => void; +} + +interface EventDetailProps { + data: EVENT; + exposure: EXPOSURE | undefined; + pending: boolean; +} + +type Footprint = GeoJSON.FeatureCollection | undefined; + +interface Props { + events: EVENT[] | undefined; + keySelector: (event: EVENT) => KEY; + pointFeatureSelector: (event: EVENT) => EventPointFeature | undefined; + footprintSelector: (activeEventExposure: EXPOSURE | undefined) => Footprint | undefined; + activeEventExposure: EXPOSURE | undefined; + listItemRenderer: React.ComponentType>; + detailRenderer: React.ComponentType>; + pending: boolean; + sidePanelHeading: React.ReactNode; + bbox: LngLatBoundsLike | undefined; + onActiveEventChange: (eventId: KEY | undefined) => void; + activeEventExposurePending: boolean; +} + +function RiskImminentEventMap< + EVENT, + EXPOSURE, + KEY extends string | number +>(props: Props) { + const { + events, + pointFeatureSelector, + keySelector, + listItemRenderer, + detailRenderer, + pending, + activeEventExposure, + footprintSelector, + sidePanelHeading, + bbox, + onActiveEventChange, + activeEventExposurePending, + } = props; + + const strings = useTranslation(i18n); + + const [activeEventId, setActiveEventId] = useState(undefined); + const activeEvent = useMemo( + () => { + if (isNotDefined(activeEventId)) { + return undefined; + } + + return events?.find( + (event) => keySelector(event) === activeEventId, + ); + }, + [activeEventId, keySelector, events], + ); + + const activeEventFootprint = useMemo( + () => { + if (isNotDefined(activeEventId) || activeEventExposurePending) { + return undefined; + } + + return footprintSelector(activeEventExposure); + }, + [activeEventId, activeEventExposure, activeEventExposurePending, footprintSelector], + ); + + const bounds = useMemo( + () => { + if (isNotDefined(activeEvent) || activeEventExposurePending) { + return bbox; + } + + const activePoint = pointFeatureSelector(activeEvent); + if (isNotDefined(activePoint)) { + return bbox; + } + const bufferedPoint = getBuffer(activePoint, 10); + + if (activeEventFootprint) { + return getBbox({ + ...activeEventFootprint, + features: [ + ...activeEventFootprint.features, + bufferedPoint, + ], + }); + } + + return getBbox(bufferedPoint); + }, + [activeEvent, activeEventFootprint, pointFeatureSelector, bbox, activeEventExposurePending], + ); + + // Avoid abrupt zooming + const boundsSafe = useDebouncedValue(bounds); + + const pointFeatureCollection = useMemo< + GeoJSON.FeatureCollection + >( + () => ({ + type: 'FeatureCollection' as const, + features: events?.map( + pointFeatureSelector, + ).filter(isDefined) ?? [], + }), + [events, pointFeatureSelector], + ); + + const setActiveEventIdSafe = useCallback( + (eventId: string | number | undefined) => { + const eventIdSafe = eventId as KEY | undefined; + + setActiveEventId(eventIdSafe); + onActiveEventChange(eventIdSafe); + }, + [onActiveEventChange], + ); + + const handlePointClick = useCallback( + (e: mapboxgl.MapboxGeoJSONFeature) => { + const pointProperties = e.properties as EventPointProperties; + setActiveEventIdSafe(pointProperties.id as KEY | undefined); + return undefined; + }, + [setActiveEventIdSafe], + ); + + const eventListRendererParams = useCallback( + (_: string | number, event: EVENT): EventItemProps => ({ + data: event, + onExpandClick: setActiveEventIdSafe, + }), + [setActiveEventIdSafe], + ); + + const DetailComponent = detailRenderer; + + const [loadedIcons, setLoadedIcons] = useState>({}); + + const handleIconLoad = useCallback( + (loaded: boolean, key: HazardType) => { + setLoadedIcons((prevValue) => ({ + ...prevValue, + [key]: loaded, + })); + }, + [], + ); + + const allIconsLoaded = useMemo( + () => ( + Object.values(loadedIcons) + .filter(Boolean).length === mapIcons.length + ), + [loadedIcons], + ); + + const hazardPointIconLayer = useMemo>( + () => ({ + type: 'symbol', + paint: { 'icon-color': COLOR_WHITE }, + layout: allIconsLoaded ? hazardPointIconLayout : invisibleLayout, + }), + [allIconsLoaded], + ); + + return ( +
+ + + {hazardKeys.map((key) => { + const url = hazardKeyToIconmap[key]; + + if (isNotDefined(url)) { + return null; + } + + return ( + + ); + })} + {/* FIXME: footprint layer should always be the bottom layer */} + {activeEventFootprint && ( + + + + + + + )} + + + + + {boundsSafe && ( + + )} + + + )} + > + {strings.backToEventsLabel} + + )} + > + {isNotDefined(activeEventId) && ( + + )} + {isDefined(activeEvent) && ( + + )} + +
+ ); +} + +export default RiskImminentEventMap; diff --git a/src/components/domain/RiskImminentEventMap/mapStyles.ts b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts similarity index 95% rename from src/components/domain/RiskImminentEventMap/mapStyles.ts rename to app/src/components/domain/RiskImminentEventMap/mapStyles.ts index ef1334d51..206d758d7 100644 --- a/src/components/domain/RiskImminentEventMap/mapStyles.ts +++ b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts @@ -1,23 +1,28 @@ +import { + isDefined, + mapToList, +} from '@togglecorp/fujs'; import type { - SymbolLayout, + CircleLayer, CirclePaint, FillLayer, - LineLayer, - CircleLayer, Layout, + LineLayer, SymbolLayer, + SymbolLayout, } from 'mapbox-gl'; -import { hazardTypeToColorMap } from '#utils/domain/risk'; -import { COLOR_BLACK, COLOR_PRIMARY_BLUE } from '#utils/constants'; -import { isDefined, mapToList } from '@togglecorp/fujs'; -import earthquakeIcon from '#assets/icons/risk/earthquake.png'; -import floodIcon from '#assets/icons/risk/flood.png'; import cycloneIcon from '#assets/icons/risk/cyclone.png'; import droughtIcon from '#assets/icons/risk/drought.png'; +import earthquakeIcon from '#assets/icons/risk/earthquake.png'; +import floodIcon from '#assets/icons/risk/flood.png'; import wildfireIcon from '#assets/icons/risk/wildfire.png'; - import { type components } from '#generated/riskTypes'; +import { + COLOR_BLACK, + COLOR_PRIMARY_BLUE, +} from '#utils/constants'; +import { hazardTypeToColorMap } from '#utils/domain/risk'; type HazardType = components<'read'>['schemas']['HazardTypeEnum']; diff --git a/src/components/domain/RiskImminentEventMap/styles.module.css b/app/src/components/domain/RiskImminentEventMap/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEventMap/styles.module.css rename to app/src/components/domain/RiskImminentEventMap/styles.module.css diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx new file mode 100644 index 000000000..aa6906e02 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx @@ -0,0 +1,180 @@ +import { + BlockLoading, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GdacsResponse = RiskApiResponse<'/api/v1/gdacs/'>; +type GdacsItem = NonNullable[number]; +type GdacsExposure = RiskApiResponse<'/api/v1/gdacs/{id}/exposure/'>; + +interface GdacsEventDetails { + Class?: string; + affectedcountries?: { + iso3: string; + countryname: string; + }[]; + alertlevel?: string; + alertscore?: number; + country?: string; + countryonland?: string; + description?: string; + episodealertlevel?: string; + episodealertscore?: number; + episodeid?: number; + eventid?: number; + eventname?: string; + eventtype?: string; + fromdate?: string; + glide?: string; + htmldescription?: string; + icon?: string; + iconoverall?: null, + iscurrent?: string; + iso3?: string; + istemporary?: string; + name?: string; + polygonlabel?: string; + todate?: string; + severitydata?: { + severity?: number; + severitytext?: string; + severityunit?: string; + }, + source?: string; + sourceid?: string; + url?: { + report?: string; + details?: string; + geometry?: string; + }, +} +interface GdacsPopulationExposure { + death?: number; + displaced?: number; + exposed_population?: string; + people_affected?: string; + impact?: string; +} + +interface Props { + data: GdacsItem; + exposure: GdacsExposure | undefined; + pending: boolean; +} + +function EventDetails(props: Props) { + const { + data: { + hazard_name, + start_date, + event_details, + }, + exposure, + pending, + } = props; + + const strings = useTranslation(i18n); + + const populationExposure = exposure?.population_exposure as GdacsPopulationExposure | undefined; + const eventDetails = event_details as GdacsEventDetails | undefined; + + return ( + + )} + > + {pending && } +
+ {isDefined(eventDetails?.source) && ( + + )} + {isDefined(populationExposure?.death) && ( + + )} + {isDefined(populationExposure?.displaced) && ( + + )} + {isDefined(populationExposure?.exposed_population) && ( + + )} + {isDefined(populationExposure?.people_affected) && ( + + )} + {isDefined(populationExposure?.impact) && ( + + )} + {isDefined(eventDetails?.severitydata) + && (isDefined(eventDetails) && (eventDetails?.eventtype) && !(eventDetails.eventtype === 'FL')) && ( + + )} + {isDefined(eventDetails?.alertlevel) && ( + + )} +
+ {isDefined(eventDetails) + && isDefined(eventDetails.url) + && isDefined(eventDetails.url.report) + && ( + + {strings.eventMoreDetailsLink} + + )} +
+ ); +} + +export default EventDetails; diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx new file mode 100644 index 000000000..0a4f257dd --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx @@ -0,0 +1,60 @@ +import { ChevronRightLineIcon } from '@ifrc-go/icons'; +import { + Button, + Header, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/gdacs/'>; +type EventItem = NonNullable[number]; + +interface Props { + data: EventItem; + onExpandClick: (eventId: string | number) => void; +} + +function EventListItem(props: Props) { + const { + data: { + id, + hazard_name, + start_date, + }, + onExpandClick, + } = props; + + const strings = useTranslation(i18n); + + return ( +
+ + + )} + spacing="condensed" + > + +
+ ); +} + +export default EventListItem; diff --git a/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css rename to app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/styles.module.css diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx new file mode 100644 index 000000000..c3eb3ad32 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx @@ -0,0 +1,185 @@ +import { useCallback } from 'react'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { type LngLatBoundsLike } from 'mapbox-gl'; + +import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { isValidFeatureCollection } from '#utils/domain/risk'; +import { + RiskApiResponse, + useRiskLazyRequest, + useRiskRequest, +} from '#utils/restRequest'; + +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/gdacs/'>; +type EventItem = NonNullable[number]; + +function getLayerType(geometryType: GeoJSON.Geometry['type']) { + if (geometryType === 'Point' || geometryType === 'MultiPoint') { + return 'track-point'; + } + + if (geometryType === 'LineString' || geometryType === 'MultiLineString') { + return 'track'; + } + + return 'exposure'; +} + +type BaseProps = { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; +} + +type Props = BaseProps & ({ + variant: 'global'; +} | { + variant: 'region'; + regionId: number; +} | { + variant: 'country'; + iso3: string; +}) + +function Gdacs(props: Props) { + const { + title, + bbox, + variant, + } = props; + + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + // eslint-disable-next-line react/destructuring-assignment + skip: (variant === 'region' && isNotDefined(props.regionId)) + // eslint-disable-next-line react/destructuring-assignment + || (variant === 'country' && isNotDefined(props.iso3)), + url: '/api/v1/gdacs/', + query: { + limit: 9999, + // eslint-disable-next-line react/destructuring-assignment + iso3: variant === 'country' ? props.iso3 : undefined, + // eslint-disable-next-line react/destructuring-assignment + region: variant === 'region' ? [props.regionId] : undefined, + }, + }); + + const { + response: exposureResponse, + pending: exposureResponsePending, + trigger: getFootprint, + } = useRiskLazyRequest<'/api/v1/gdacs/{id}/exposure/', { + eventId: number | string, + }>({ + apiType: 'risk', + url: '/api/v1/gdacs/{id}/exposure/', + pathVariables: ({ eventId }) => ({ id: Number(eventId) }), + }); + + const pointFeatureSelector = useCallback( + (event: EventItem): EventPointFeature | undefined => { + const { + id, + latitude, + longitude, + hazard_type, + } = event; + + if ( + isNotDefined(latitude) + || isNotDefined(longitude) + || isNotDefined(hazard_type) + ) { + return undefined; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude], + }, + properties: { + id, + hazard_type, + }, + }; + }, + [], + ); + + const footprintSelector = useCallback( + (exposure: RiskApiResponse<'/api/v1/gdacs/{id}/exposure/'> | undefined) => { + if (isNotDefined(exposure)) { + return undefined; + } + + const { footprint_geojson } = exposure; + + if (isNotDefined(footprint_geojson)) { + return undefined; + } + + const footprint = isValidFeatureCollection(footprint_geojson) + ? footprint_geojson + : undefined; + + const geoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection' as const, + features: [ + ...footprint?.features?.map( + (feature) => ({ + ...feature, + properties: { + ...feature.properties, + type: getLayerType(feature.geometry.type), + }, + }), + ) ?? [], + ].filter(isDefined), + }; + + return geoJson; + }, + [], + ); + + const handleActiveEventChange = useCallback( + (eventId: number | undefined) => { + if (isDefined(eventId)) { + getFootprint({ eventId }); + } else { + getFootprint(undefined); + } + }, + [getFootprint], + ); + + return ( + + ); +} + +export default Gdacs; diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx new file mode 100644 index 000000000..dd1b8bf17 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/index.tsx @@ -0,0 +1,304 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + BlockLoading, + Container, + DateOutput, + NumberOutput, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + resolveToComponent, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + compareString, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type MeteoSwissResponse = RiskApiResponse<'/api/v1/meteoswiss/'>; +type MeteoSwissItem = NonNullable[number]; +// type MeteoSwissExposure = RiskApiResponse<'/api/v1/meteoswiss/{id}/exposure/'>; + +const UPDATED_AT_FORMAT = 'yyyy-MM-dd, hh:mm'; + +interface Props { + data: MeteoSwissItem; + // exposure: MeteoSwissExposure | undefined; + pending: boolean; +} + +function EventDetails(props: Props) { + const { + data: { + // id, + hazard_type_display, + country_details, + start_date, + updated_at, + hazard_name, + model_name, + event_details, + }, + pending, + } = props; + + const strings = useTranslation(i18n); + + const hazardName = resolveToString( + strings.meteoSwissHazardName, + { + hazardType: hazard_type_display, + hazardName: country_details?.name ?? hazard_name, + }, + ); + + const getSaffirSimpsonScaleDescription = useCallback((windspeed: number) => { + if (windspeed < 33) { + return strings.tropicalStormDescription; + } + if (windspeed < 43) { + return strings.categoryOneDescription; + } + if (windspeed < 50) { + return strings.categoryTwoDescription; + } + if (windspeed < 59) { + return strings.categoryThreeDescription; + } + if (windspeed < 71) { + return strings.categoryFourDescription; + } + return strings.categoryFiveDescription; + }, [ + strings.tropicalStormDescription, + strings.categoryOneDescription, + strings.categoryTwoDescription, + strings.categoryThreeDescription, + strings.categoryFourDescription, + strings.categoryFiveDescription, + ]); + + const impactList = useMemo( + () => ( + // FIXME: typings should be fixed in the server + (event_details as unknown as unknown[])?.map((event: unknown, i: number) => { + if ( + typeof event !== 'object' + || isNotDefined(event) + || !('mean' in event) + || !('impact_type' in event) + || !('five_perc' in event) + || !('ninety_five_perc' in event) + ) { + return undefined; + } + + const { + impact_type, + five_perc, + ninety_five_perc, + mean, + } = event; + + const valueSafe = typeof mean === 'number' ? Math.round(mean) : undefined; + const fivePercentValue = typeof five_perc === 'number' ? Math.round(five_perc) : undefined; + const ninetyFivePercentValue = typeof ninety_five_perc === 'number' ? Math.round(ninety_five_perc) : undefined; + if (isNotDefined(valueSafe) || valueSafe === 0) { + return undefined; + } + + if (typeof impact_type !== 'string') { + return undefined; + } + + if (impact_type === 'direct_economic_damage') { + return { + key: i, + type: 'economic', + value: valueSafe, + fivePercentValue, + ninetyFivePercentValue, + label: strings.meteoSwissEconomicLossLabel, + unit: strings.usd, + }; + } + + if (impact_type.startsWith('exposed_population_')) { + const windspeed = Number.parseInt( + impact_type.split('exposed_population_')[1], + 10, + ); + + if (isNotDefined(windspeed)) { + return undefined; + } + + return { + key: i, + type: 'exposure', + value: valueSafe, + fivePercentValue, + ninetyFivePercentValue, + label: resolveToString( + strings.meteoSwissExposureLabel, + { + windspeed, + saffirSimpsonScale: getSaffirSimpsonScaleDescription(windspeed), + }, + ), + unit: strings.people, + }; + } + + return undefined; + }).filter(isDefined).sort((a, b) => compareString(b.type, a.type)) + ), + [ + event_details, + strings.meteoSwissEconomicLossLabel, + strings.usd, + strings.meteoSwissExposureLabel, + strings.people, + getSaffirSimpsonScaleDescription, + ], + ); + + // TODO: add exposure details + return ( + + + + + )} + contentViewType="vertical" + > + {pending && } + {!pending && ( + <> + + {impactList.map((impact) => ( + {strings.beta}, + }, + )} + valueClassName={styles.impactValue} + value={resolveToComponent( + strings.meteoSwissImpactValue, + { + value: ( + + ), + fivePercent: ( + + ), + ninetyFivePercent: ( + + ), + unit: impact.unit, + }, + )} + strongValue + /> + ))} +
+ {strings.meteoSwissEstimatesNote} +
+
+
+ {resolveToComponent( + strings.meteoSwissEventDescription, + { + model: model_name ?? '--', + updatedAt: ( + + ), + eventName: hazard_name, + countryName: country_details?.name ?? '--', + eventDate: , + }, + )} +
+
+ {resolveToComponent( + strings.meteoSwissAuthoritativeMessage, + { + link: ( + + {strings.meteoSwissAuthoritativeLinkLabel} + + ), + classificationLink: ( + + {strings.meteoSwissTropicalStorm} + + ), + }, + )} +
+ + )} +
+ ); +} + +export default EventDetails; diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventDetails/styles.module.css diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx new file mode 100644 index 000000000..f06a13dc1 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx @@ -0,0 +1,64 @@ +import { ChevronRightLineIcon } from '@ifrc-go/icons'; +import { + Button, + Header, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/meteoswiss/'>; +type EventItem = NonNullable[number]; + +interface Props { + data: EventItem; + onExpandClick: (eventId: string | number) => void; +} + +function EventListItem(props: Props) { + const { + data: { + id, + hazard_type_display, + country_details, + start_date, + hazard_name, + }, + onExpandClick, + } = props; + + const strings = useTranslation(i18n); + + const hazardName = `${hazard_type_display} - ${country_details?.name ?? hazard_name}`; + + return ( +
+ + + )} + spacing="condensed" + > + +
+ ); +} + +export default EventListItem; diff --git a/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css rename to app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/styles.module.css diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx new file mode 100644 index 000000000..a10d9abdb --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx @@ -0,0 +1,198 @@ +import { useCallback } from 'react'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import type { LngLatBoundsLike } from 'mapbox-gl'; + +import type { EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import RiskImminentEventMap from '#components/domain/RiskImminentEventMap'; +import { isValidFeatureCollection } from '#utils/domain/risk'; +import { + type RiskApiResponse, + useRiskLazyRequest, + useRiskRequest, +} from '#utils/restRequest'; + +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/meteoswiss/'>; +type EventItem = NonNullable[number]; + +function getLayerType(geometryType: GeoJSON.Geometry['type']) { + if (geometryType === 'Point' || geometryType === 'MultiPoint') { + return 'track-point'; + } + + if (geometryType === 'LineString' || geometryType === 'MultiLineString') { + return 'track'; + } + + return 'exposure'; +} + +type BaseProps = { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; +} + +type Props = BaseProps & ({ + variant: 'global'; +} | { + variant: 'region'; + regionId: number; +} | { + variant: 'country'; + iso3: string; +}) + +function MeteoSwiss(props: Props) { + const { + title, + bbox, + variant, + } = props; + + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + // eslint-disable-next-line react/destructuring-assignment + skip: (variant === 'region' && isNotDefined(props.regionId)) + // eslint-disable-next-line react/destructuring-assignment + || (variant === 'country' && isNotDefined(props.iso3)), + url: '/api/v1/meteoswiss/', + query: { + limit: 9999, + // eslint-disable-next-line react/destructuring-assignment + iso3: variant === 'country' ? props.iso3 : undefined, + // eslint-disable-next-line react/destructuring-assignment + region: variant === 'region' ? [props.regionId] : undefined, + }, + }); + + const { + response: exposureResponse, + pending: exposureResponsePending, + trigger: getFootprint, + } = useRiskLazyRequest<'/api/v1/meteoswiss/{id}/exposure/', { + eventId: number | string, + }>({ + apiType: 'risk', + url: '/api/v1/meteoswiss/{id}/exposure/', + pathVariables: ({ eventId }) => ({ id: Number(eventId) }), + }); + + const pointFeatureSelector = useCallback( + (event: EventItem): EventPointFeature | undefined => { + const { + id, + latitude, + longitude, + hazard_type, + } = event; + + if ( + isNotDefined(latitude) + || isNotDefined(longitude) + || isNotDefined(hazard_type) + ) { + return undefined; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude], + }, + properties: { + id, + hazard_type, + }, + }; + }, + [], + ); + + const footprintSelector = useCallback( + (exposure: RiskApiResponse<'/api/v1/meteoswiss/{id}/exposure/'> | undefined) => { + if (isNotDefined(exposure)) { + return undefined; + } + + // FIXME: fix typing in server (low priority) + const footprint_geojson = exposure?.footprint_geojson?.footprint_geojson; + + if (isNotDefined(footprint_geojson)) { + return undefined; + } + + const footprint = isValidFeatureCollection(footprint_geojson) + ? footprint_geojson + : undefined; + + const geoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection' as const, + features: [ + ...footprint?.features?.map( + (feature) => { + if (isNotDefined(feature)) { + return undefined; + } + + const { geometry } = feature; + if (isNotDefined(geometry)) { + return undefined; + } + + return { + ...feature, + properties: { + ...feature.properties, + type: getLayerType(feature.geometry.type), + }, + }; + }, + ) ?? [], + ].filter(isDefined), + }; + + return geoJson; + }, + [], + ); + + const handleActiveEventChange = useCallback( + (eventId: number | undefined) => { + if (isDefined(eventId)) { + getFootprint({ eventId }); + } else { + getFootprint(undefined); + } + }, + [getFootprint], + ); + + return ( + + ); +} + +export default MeteoSwiss; diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json rename to app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx new file mode 100644 index 000000000..475a2770f --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx @@ -0,0 +1,132 @@ +import { + BlockLoading, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type PdcResponse = RiskApiResponse<'/api/v1/pdc/'>; +type PdcEventItem = NonNullable[number]; +type PdcExposure = RiskApiResponse<'/api/v1/pdc/{id}/exposure/'>; + +interface Props { + data: PdcEventItem; + exposure: PdcExposure | undefined; + pending: boolean; +} + +function EventDetails(props: Props) { + const { + data: { + hazard_name, + start_date, + pdc_created_at, + pdc_updated_at, + description, + }, + exposure, + pending, + } = props; + + const strings = useTranslation(i18n); + + interface Exposure { + value?: number | null; + valueFormatted?: string | null; + } + + // NOTE: these are stored as json so we don't have typings for these + const popExposure = exposure?.population_exposure as { + total?: Exposure | null; + households?: Exposure | null; + vulnerable?: Exposure | null; + } | null; + + // NOTE: these are stored as json so we don't have typings for these + const capitalExposure = exposure?.capital_exposure as { + total?: Exposure | null; + school?: Exposure | null; + hospital?: Exposure | null; + } | null; + + return ( + + + + + + )} + > + {pending && } + {!pending && ( + <> +
+ + + + + + +
+
+ {description} +
+ + )} +
+ ); +} + +export default EventDetails; diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css rename to app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json rename to app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx new file mode 100644 index 000000000..a2fcbf897 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx @@ -0,0 +1,60 @@ +import { ChevronRightLineIcon } from '@ifrc-go/icons'; +import { + Button, + Header, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/pdc/'>; +type EventItem = NonNullable[number]; + +interface Props { + data: EventItem; + onExpandClick: (eventId: string | number) => void; +} + +function EventListItem(props: Props) { + const { + data: { + id, + hazard_name, + start_date, + }, + onExpandClick, + } = props; + + const strings = useTranslation(i18n); + + return ( +
+ + + )} + spacing="condensed" + > + +
+ ); +} + +export default EventListItem; diff --git a/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css rename to app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/styles.module.css diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx new file mode 100644 index 000000000..a32409ad0 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx @@ -0,0 +1,207 @@ +import { useCallback } from 'react'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { type LngLatBoundsLike } from 'mapbox-gl'; + +import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { + isValidFeature, + isValidPointFeature, +} from '#utils/domain/risk'; +import { + type RiskApiResponse, + useRiskLazyRequest, + useRiskRequest, +} from '#utils/restRequest'; + +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/pdc/'>; +type EventItem = NonNullable[number]; + +type BaseProps = { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; +} + +type Props = BaseProps & ({ + variant: 'global'; +} | { + variant: 'region'; + regionId: number; +} | { + variant: 'country'; + iso3: string; +}) + +function Pdc(props: Props) { + const { + title, + bbox, + variant, + } = props; + + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + // eslint-disable-next-line react/destructuring-assignment + skip: (variant === 'region' && isNotDefined(props.regionId)) + // eslint-disable-next-line react/destructuring-assignment + || (variant === 'country' && isNotDefined(props.iso3)), + url: '/api/v1/pdc/', + query: { + limit: 9999, + // eslint-disable-next-line react/destructuring-assignment + iso3: variant === 'country' ? props.iso3 : undefined, + // eslint-disable-next-line react/destructuring-assignment + region: variant === 'region' ? [props.regionId] : undefined, + }, + }); + + const { + response: exposureResponse, + pending: exposureResponsePending, + trigger: getFootprint, + } = useRiskLazyRequest<'/api/v1/pdc/{id}/exposure/', { + eventId: number | string, + }>({ + apiType: 'risk', + url: '/api/v1/pdc/{id}/exposure/', + pathVariables: ({ eventId }) => ({ id: Number(eventId) }), + }); + + const pointFeatureSelector = useCallback( + (event: EventItem): EventPointFeature | undefined => { + const { + id, + latitude, + longitude, + hazard_type, + } = event; + + if ( + isNotDefined(latitude) + || isNotDefined(longitude) + || isNotDefined(hazard_type) + ) { + return undefined; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude], + }, + properties: { + id, + hazard_type, + }, + }; + }, + [], + ); + + const footprintSelector = useCallback( + (exposure: RiskApiResponse<'/api/v1/pdc/{id}/exposure/'> | undefined) => { + if (isNotDefined(exposure)) { + return undefined; + } + + const { + footprint_geojson, + storm_position_geojson, + } = exposure; + + if (isNotDefined(footprint_geojson) && isNotDefined(storm_position_geojson)) { + return undefined; + } + + const footprint = isValidFeature(footprint_geojson) ? footprint_geojson : undefined; + // FIXME: fix typing in server (low priority) + const stormPositions = (storm_position_geojson as unknown as unknown[] | undefined) + ?.filter(isValidPointFeature); + + // forecast_date_time : "2023 SEP 04, 00:00Z" + // severity : "WARNING" + // storm_name : "HAIKUI" + // track_heading : "WNW" + // wind_speed_mph : 75 + + const geoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection' as const, + features: [ + footprint ? { + ...footprint, + properties: { + ...footprint.properties, + type: 'exposure', + }, + } : undefined, + stormPositions ? { + type: 'Feature' as const, + geometry: { + type: 'LineString' as const, + coordinates: stormPositions.map( + (pointFeature) => ( + pointFeature.geometry.coordinates + ), + ), + }, + properties: { + type: 'track', + }, + } : undefined, + ...stormPositions?.map( + (pointFeature) => ({ + ...pointFeature, + properties: { + ...pointFeature.properties, + type: 'track-point', + }, + }), + ) ?? [], + ].filter(isDefined), + }; + + return geoJson; + }, + [], + ); + + const handleActiveEventChange = useCallback( + (eventId: number | undefined) => { + if (isDefined(eventId)) { + getFootprint({ eventId }); + } else { + getFootprint(undefined); + } + }, + [getFootprint], + ); + + return ( + + ); +} + +export default Pdc; diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx new file mode 100644 index 000000000..40c236513 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx @@ -0,0 +1,394 @@ +import { useMemo } from 'react'; +import { + BlockLoading, + Container, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getPercentage, + maxSafe, + resolveToString, + roundSafe, +} from '@ifrc-go/ui/utils'; +import { + compareDate, + isDefined, + isFalsyString, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { + isValidFeatureCollection, + isValidPointFeature, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type WfpAdamResponse = RiskApiResponse<'/api/v1/adam-exposure/'>; +type WfpAdamItem = NonNullable[number]; +type WfpAdamExposure = RiskApiResponse<'/api/v1/adam-exposure/{id}/exposure/'>; + +interface WfpAdamPopulationExposure { + exposure_60_kmh?: number; + exposure_90_kmh?: number; + exposure_120_kmh?: number; +} + +interface WfpAdamEventDetails { + event_id: string; + mag?: number; + mmni?: number; + url?: { + map?: string; + shakemap?: string; + population?: string; + population_csv?: string; + wind?: string; + rainfall?: string; + shapefile?: string + } + iso3?: string; + depth?: number; + place?: string; + title?: string; + latitude?: number; + longitude?: number; + mag_type?: string; + admin1_name?: string; + published_at?: string; + population_impact?: number; + country?: number | null; + alert_sent?: boolean; + alert_level?: 'Red' | 'Orange' | 'Green' | 'Cones' | null; + from_date?: string; + to_date?: string; + wind_speed?: number; + effective_date?: string; + date_processed?: string; + population?: number; + dashboard_url?: string; + flood_area?: number; + fl_croplnd?: number; + source?: string; + sitrep?: string; +} + +interface Props { + data: WfpAdamItem; + exposure: WfpAdamExposure | undefined; + pending: boolean; +} + +function EventDetails(props: Props) { + const { + data: { + title, + publish_date, + event_details, + }, + exposure, + pending, + } = props; + + const strings = useTranslation(i18n); + + const stormPoints = useMemo( + () => { + if (isNotDefined(exposure)) { + return undefined; + } + + const { storm_position_geojson } = exposure; + + const geoJson = isValidFeatureCollection(storm_position_geojson) + ? storm_position_geojson + : undefined; + + const points = geoJson?.features.map( + (pointFeature) => { + if ( + !isValidPointFeature(pointFeature) + || isNotDefined(pointFeature.properties) + ) { + return undefined; + } + + const { + wind_speed, + track_date, + } = pointFeature.properties; + + if (isNotDefined(wind_speed) || isFalsyString(track_date)) { + return undefined; + } + + const date = new Date(track_date); + + return { + id: date.getTime(), + windSpeed: wind_speed, + date, + }; + }, + ).filter(isDefined).sort( + (a, b) => compareDate(a.date, b.date), + ); + + return points; + }, + [exposure], + ); + + const eventDetails = event_details as WfpAdamEventDetails | undefined; + + // eslint-disable-next-line max-len + const populationExposure = exposure?.population_exposure as WfpAdamPopulationExposure | undefined; + + const dashboardUrl = eventDetails?.dashboard_url ?? eventDetails?.url?.map; + const populationImpact = roundSafe(eventDetails?.population_impact) + ?? roundSafe(eventDetails?.population); + const maxWindSpeed = maxSafe( + stormPoints?.map(({ windSpeed }) => windSpeed), + ); + + // TODO: add exposure details + return ( + + )} + > + {pending && } + {stormPoints && stormPoints.length > 0 && isDefined(maxWindSpeed) && ( + /* TODO: use proper svg charts */ +
+
+ {stormPoints.map( + (point) => ( +
+ +
+
+ ), + )} +
+
+ {strings.wfpChartLabel} +
+
+ )} + {isDefined(eventDetails) + && (isDefined(eventDetails.url) || isDefined(eventDetails.dashboard_url)) && ( + + {isDefined(dashboardUrl) && ( + + {strings.wfpDashboard} + + )} + {isDefined(eventDetails?.url) && ( + <> + {isDefined(eventDetails.url.shakemap) && ( + + {strings.wfpShakemap} + + )} + {isDefined(eventDetails.url.population) && ( + + {strings.wfpPopulationTable} + + )} + {isDefined(eventDetails.url.wind) && ( + + {strings.wfpWind} + + )} + {isDefined(eventDetails.url.rainfall) && ( + + {strings.wfpRainfall} + + )} + {isDefined(eventDetails.url.shapefile) && ( + + {strings.wfpShapefile} + + )} + + )} + + )} +
+ {isDefined(eventDetails?.wind_speed) && ( + + )} + {isDefined(populationImpact) && ( + + )} +
+
+ {isDefined(eventDetails?.source) && ( + + )} + {isDefined(eventDetails?.sitrep) && ( + + )} + {isDefined(eventDetails?.mag) && ( + + )} + {isDefined(eventDetails?.depth) && ( + + )} + {isDefined(eventDetails?.alert_level) && ( + + )} + {isDefined(eventDetails?.effective_date) && ( + + )} + {isDefined(eventDetails?.from_date) && ( + + )} + {isDefined(eventDetails?.to_date) && ( + + )} +
+
+ {isDefined(populationExposure?.exposure_60_kmh) && ( + + )} + {isDefined(populationExposure?.exposure_90_kmh) && ( + + )} + {isDefined(populationExposure?.exposure_120_kmh) && ( + + )} + {isDefined(eventDetails?.flood_area) && ( + + )} + {isDefined(eventDetails?.fl_croplnd) && ( + + )} +
+ + ); +} + +export default EventDetails; diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/styles.module.css diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx new file mode 100644 index 000000000..1596c34de --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx @@ -0,0 +1,60 @@ +import { ChevronRightLineIcon } from '@ifrc-go/icons'; +import { + Button, + Header, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/adam-exposure/'>; +type EventItem = NonNullable[number]; + +interface Props { + data: EventItem; + onExpandClick: (eventId: string | number) => void; +} + +function EventListItem(props: Props) { + const { + data: { + id, + publish_date, + title, + }, + onExpandClick, + } = props; + + const strings = useTranslation(i18n); + + return ( +
+ + + )} + spacing="condensed" + > + +
+ ); +} + +export default EventListItem; diff --git a/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css rename to app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/styles.module.css diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx new file mode 100644 index 000000000..cc84b27d9 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx @@ -0,0 +1,187 @@ +import { useCallback } from 'react'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { type LngLatBoundsLike } from 'mapbox-gl'; + +import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { isValidFeatureCollection } from '#utils/domain/risk'; +import { + type RiskApiResponse, + useRiskLazyRequest, + useRiskRequest, +} from '#utils/restRequest'; + +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; + +type ImminentEventResponse = RiskApiResponse<'/api/v1/adam-exposure/'>; +type EventItem = NonNullable[number]; + +function getLayerType(geometryType: GeoJSON.Geometry['type']) { + if (geometryType === 'Point' || geometryType === 'MultiPoint') { + return 'track-point'; + } + + if (geometryType === 'LineString' || geometryType === 'MultiLineString') { + return 'track'; + } + + return 'exposure'; +} + +type BaseProps = { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; +} + +type Props = BaseProps & ({ + variant: 'global'; +} | { + variant: 'region'; + regionId: number; +} | { + variant: 'country'; + iso3: string; +}) + +function WfpAdam(props: Props) { + const { + title, + bbox, + variant, + } = props; + + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + // eslint-disable-next-line react/destructuring-assignment + skip: (variant === 'region' && isNotDefined(props.regionId)) + // eslint-disable-next-line react/destructuring-assignment + || (variant === 'country' && isNotDefined(props.iso3)), + url: '/api/v1/adam-exposure/', + query: { + limit: 9999, + // eslint-disable-next-line react/destructuring-assignment + iso3: variant === 'country' ? props.iso3 : undefined, + // eslint-disable-next-line react/destructuring-assignment + region: variant === 'region' ? [props.regionId] : undefined, + }, + }); + + const { + response: exposureResponse, + pending: exposureResponsePending, + trigger: getFootprint, + } = useRiskLazyRequest<'/api/v1/adam-exposure/{id}/exposure/', { + eventId: number | string, + }>({ + apiType: 'risk', + url: '/api/v1/adam-exposure/{id}/exposure/', + pathVariables: ({ eventId }) => ({ id: Number(eventId) }), + }); + + const pointFeatureSelector = useCallback( + (event: EventItem): EventPointFeature | undefined => { + const { + id, + event_details, + hazard_type, + } = event; + + const latitude = event_details?.latitude as number | undefined; + const longitude = event_details?.longitude as number | undefined; + + if ( + isNotDefined(latitude) + || isNotDefined(longitude) + || isNotDefined(hazard_type) + ) { + return undefined; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude], + }, + properties: { + id, + hazard_type, + }, + }; + }, + [], + ); + + const footprintSelector = useCallback( + (exposure: RiskApiResponse<'/api/v1/adam-exposure/{id}/exposure/'> | undefined) => { + if (isNotDefined(exposure)) { + return undefined; + } + + const { storm_position_geojson } = exposure; + + if (isNotDefined(storm_position_geojson)) { + return undefined; + } + + const stormPositions = isValidFeatureCollection(storm_position_geojson) + ? storm_position_geojson + : undefined; + + const geoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection' as const, + features: [ + ...stormPositions?.features?.map( + (feature) => ({ + ...feature, + properties: { + ...feature.properties, + type: getLayerType(feature.geometry.type), + }, + }), + ) ?? [], + ].filter(isDefined), + }; + + return geoJson; + }, + [], + ); + + const handleActiveEventChange = useCallback( + (eventId: number | undefined) => { + if (isDefined(eventId)) { + getFootprint({ eventId }); + } else { + getFootprint(undefined); + } + }, + [getFootprint], + ); + + return ( + + ); +} + +export default WfpAdam; diff --git a/src/components/domain/RiskImminentEvents/i18n.json b/app/src/components/domain/RiskImminentEvents/i18n.json similarity index 100% rename from src/components/domain/RiskImminentEvents/i18n.json rename to app/src/components/domain/RiskImminentEvents/i18n.json diff --git a/app/src/components/domain/RiskImminentEvents/index.tsx b/app/src/components/domain/RiskImminentEvents/index.tsx new file mode 100644 index 000000000..d40654022 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/index.tsx @@ -0,0 +1,303 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + CycloneIcon, + DroughtIcon, + EarthquakeIcon, + FloodIcon, + ForestFireIcon, +} from '@ifrc-go/icons'; +import { + Container, + InfoPopup, + Radio, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToComponent } from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; +import type { LngLatBoundsLike } from 'mapbox-gl'; + +import Link from '#components/Link'; +import WikiLink from '#components/WikiLink'; +import { environment } from '#config'; +import { type components } from '#generated/riskTypes'; +import { hazardTypeToColorMap } from '#utils/domain/risk'; + +import Gdacs from './Gdacs'; +import MeteoSwiss from './MeteoSwiss'; +import Pdc from './Pdc'; +import WfpAdam from './WfpAdam'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +export type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss'; +type HazardType = components<'read'>['schemas']['HazardTypeEnum']; + +type BaseProps = { + className?: string; + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; + defaultSource?: ImminentEventSource; +} + +type Props = BaseProps & ({ + variant: 'global'; +} | { + variant: 'region'; + regionId: number; +} | { + variant: 'country'; + iso3: string; +}) + +function RiskImminentEvents(props: Props) { + const { + className, + defaultSource = 'pdc', + ...otherProps + } = props; + const [activeView, setActiveView] = useState(defaultSource); + + const strings = useTranslation(i18n); + + const CurrentView = useMemo( + () => { + if (activeView === 'pdc') { + return Pdc; + } + + if (activeView === 'wfpAdam') { + return WfpAdam; + } + + if (activeView === 'gdacs') { + return Gdacs; + } + + if (activeView === 'meteoSwiss') { + return MeteoSwiss; + } + + return null; + }, + [activeView], + ); + + const handleRadioClick = useCallback((key: ImminentEventSource) => { + setActiveView(key); + }, []); + + const riskHazards: Array<{ + key: HazardType, + label: string, + icon: React.ReactNode, + }> = useMemo( + () => [ + { + key: 'FL', + label: strings.imminentEventsFlood, + icon: , + }, + { + key: 'TC', + label: strings.imminentEventsStorm, + icon: , + }, + { + key: 'EQ', + label: strings.imminentEventsEarthquake, + icon: , + }, + { + key: 'DR', + label: strings.imminentEventsDrought, + icon: , + }, + { + key: 'WF', + label: strings.imminentEventsWildfire, + icon: , + }, + ], + [ + strings.imminentEventsFlood, + strings.imminentEventsStorm, + strings.imminentEventsEarthquake, + strings.imminentEventsDrought, + strings.imminentEventsWildfire, + ], + ); + + return ( + + )} + footerContent={( +
+ {riskHazards.map((hazard) => ( +
+
+ {hazard.icon} +
+
+ {hazard.label} +
+
+ ))} +
+ )} + footerActionsContainerClassName={styles.footerActions} + footerActions={( + <> + + {strings.here} + + ), + }, + )} + /> + )} + /> + + {strings.here} + + ), + }, + )} + /> + )} + /> + + {strings.here} + + ), + }, + )} + /> + )} + /> + {environment !== 'production' && ( + +
+ {strings.meteoSwissDescriptionOne} +
+
+ {resolveToComponent( + strings.meteoSwissDescriptionTwo, + { + here: ( + + {strings.here} + + ), + }, + )} +
+
+ )} + /> + )} + /> + )} + + )} + > + {CurrentView && ( + + )} +
+ ); +} + +export default RiskImminentEvents; diff --git a/src/components/domain/RiskImminentEvents/styles.module.css b/app/src/components/domain/RiskImminentEvents/styles.module.css similarity index 100% rename from src/components/domain/RiskImminentEvents/styles.module.css rename to app/src/components/domain/RiskImminentEvents/styles.module.css diff --git a/src/components/domain/RiskSeasonalMap/Filters/i18n.json b/app/src/components/domain/RiskSeasonalMap/Filters/i18n.json similarity index 100% rename from src/components/domain/RiskSeasonalMap/Filters/i18n.json rename to app/src/components/domain/RiskSeasonalMap/Filters/i18n.json diff --git a/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx b/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx new file mode 100644 index 000000000..a898000a4 --- /dev/null +++ b/app/src/components/domain/RiskSeasonalMap/Filters/index.tsx @@ -0,0 +1,136 @@ +import { useCallback } from 'react'; +import { + Checkbox, + MultiSelectInput, + SelectInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getMonthList, + numericKeySelector, + stringLabelSelector, + stringNameSelector, +} from '@ifrc-go/ui/utils'; +import { EntriesAsList } from '@togglecorp/toggle-form'; + +import useCountry from '#hooks/domain/useCountry'; +import { + type HazardType, + hazardTypeKeySelector, + hazardTypeLabelSelector, + type HazardTypeOption, + type RiskMetric, + riskMetricKeySelector, + type RiskMetricOption, +} from '#utils/domain/risk'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function countryKeySelector(country: { iso3: string }) { + return country.iso3; +} + +const monthList = getMonthList(); + +export interface FilterValue { + countries: string[]; + months: number[]; + hazardTypes: HazardType[]; + riskMetric: RiskMetric; + normalizeByPopulation: boolean; + includeCopingCapacity: boolean; +} + +interface Props { + regionId?: number; + value: FilterValue; + onChange: React.Dispatch>; + riskMetricOptions: RiskMetricOption[]; + hazardTypeOptions: HazardTypeOption[]; +} + +function Filters(props: Props) { + const { + regionId, + value, + onChange, + riskMetricOptions, + hazardTypeOptions, + } = props; + + const strings = useTranslation(i18n); + + const countryList = useCountry({ region: regionId }); + + const handleChange = useCallback( + (...args: EntriesAsList) => { + const [val, key] = args; + onChange((prevValue): FilterValue => ({ + ...prevValue, + [key]: val, + })); + }, + [onChange], + ); + + return ( +
+ + + + +
+ + +
+
+ ); +} + +export default Filters; diff --git a/src/components/domain/RiskSeasonalMap/Filters/styles.module.css b/app/src/components/domain/RiskSeasonalMap/Filters/styles.module.css similarity index 100% rename from src/components/domain/RiskSeasonalMap/Filters/styles.module.css rename to app/src/components/domain/RiskSeasonalMap/Filters/styles.module.css diff --git a/src/components/domain/RiskSeasonalMap/i18n.json b/app/src/components/domain/RiskSeasonalMap/i18n.json similarity index 100% rename from src/components/domain/RiskSeasonalMap/i18n.json rename to app/src/components/domain/RiskSeasonalMap/i18n.json diff --git a/app/src/components/domain/RiskSeasonalMap/index.tsx b/app/src/components/domain/RiskSeasonalMap/index.tsx new file mode 100644 index 000000000..e367ffa4f --- /dev/null +++ b/app/src/components/domain/RiskSeasonalMap/index.tsx @@ -0,0 +1,1076 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + BlockLoading, + Container, + LegendItem, + Message, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + formatNumber, + maxSafe, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + _cs, + compareNumber, + isDefined, + isFalsyString, + isNotDefined, + listToGroupList, + listToMap, + mapToList, + unique, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, +} from '@togglecorp/re-map'; +import { + type FillLayer, + type LngLatBoundsLike, +} from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import Link from '#components/Link'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import WikiLink from '#components/WikiLink'; +import useCountry from '#hooks/domain/useCountry'; +import useInputState from '#hooks/useInputState'; +import { + CATEGORY_RISK_HIGH, + CATEGORY_RISK_LOW, + CATEGORY_RISK_MEDIUM, + CATEGORY_RISK_VERY_HIGH, + CATEGORY_RISK_VERY_LOW, + COLOR_DARK_GREY, + COLOR_LIGHT_BLUE, + COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; +import { + applicableHazardsByRiskMetric, + getDataWithTruthyHazardType, + getDisplacementRiskCategory, + getExposureRiskCategory, + getFiRiskDataItem, + getValueForSelectedMonths, + hasSomeDefinedValue, + type HazardType, + type HazardTypeOption, + hazardTypeToColorMap, + type RiskDataItem, + RiskMetric, + type RiskMetricOption, + riskScoreToCategory, +} from '#utils/domain/risk'; +import { useRiskRequest } from '#utils/restRequest'; + +import Filters, { type FilterValue } from './Filters'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const defaultFilterValue: FilterValue = { + months: [], + countries: [], + riskMetric: 'riskScore', + hazardTypes: [], + normalizeByPopulation: false, + includeCopingCapacity: false, +}; + +interface TooltipContentProps { + selectedRiskMetric: RiskMetric, + valueListByHazard: { + value: number; + riskCategory: number; + hazard_type: HazardType; + hazard_type_display: string; + }[]; +} + +function TooltipContent(props: TooltipContentProps) { + const { + selectedRiskMetric, + valueListByHazard, + } = props; + + const strings = useTranslation(i18n); + const riskCategoryToLabelMap: Record = useMemo( + () => ({ + [CATEGORY_RISK_VERY_LOW]: strings.riskCategoryVeryLow, + [CATEGORY_RISK_LOW]: strings.riskCategoryLow, + [CATEGORY_RISK_MEDIUM]: strings.riskCategoryMedium, + [CATEGORY_RISK_HIGH]: strings.riskCategoryHigh, + [CATEGORY_RISK_VERY_HIGH]: strings.riskCategoryVeryHigh, + }), + [ + strings.riskCategoryVeryLow, + strings.riskCategoryLow, + strings.riskCategoryMedium, + strings.riskCategoryHigh, + strings.riskCategoryVeryHigh, + ], + ); + + const riskMetricLabelMap: Record = { + riskScore: strings.riskScoreOptionLabel, + displacement: strings.peopleAtRiskOptionLabel, + exposure: strings.peopleExposedOptionLabel, + }; + + return valueListByHazard.map( + ({ + hazard_type_display, + hazard_type, + riskCategory, + value, + }) => ( + +
+
+ )} + > + + {selectedRiskMetric !== 'riskScore' && ( + + )} +
+ ), + ); +} + +const RISK_LOW_COLOR = '#c7d3e0'; +const RISK_HIGH_COLOR = '#f5333f'; + +interface ClickedPoint { + feature: GeoJSON.Feature; + lngLat: mapboxgl.LngLatLike; +} + +interface GeoJsonProps { + country_id: number; + disputed: boolean; + fdrs: string; + independent: boolean; + is_deprecated: boolean; + iso: string; + iso3: string; + name: string; + record_type: number; + region_id: number; +} + +type BaseProps = { + className?: string; + bbox: LngLatBoundsLike | undefined; +} + +type Props = BaseProps & ({ + variant: 'global'; + regionId?: never; +} | { + variant: 'region'; + regionId: number; +}); + +function RiskSeasonalMap(props: Props) { + const { + className, + bbox, + regionId, + variant, + } = props; + + const [hazardTypeOptions, setHazardTypeOptions] = useInputState([]); + const [ + clickedPointProperties, + setClickedPointProperties, + ] = useState(); + const strings = useTranslation(i18n); + + const { + response: seasonalResponse, + pending: seasonalResponsePending, + } = useRiskRequest({ + skip: variant === 'region' && isNotDefined(regionId), + apiType: 'risk', + url: '/api/v1/seasonal/', + query: variant === 'region' + ? { region: regionId } + : undefined, + }); + + const countryList = useCountry( + variant === 'region' + ? { region: regionId } + : {}, + ); + + const countryIso3ToIdMap = useMemo( + () => ( + listToMap( + countryList, + ({ iso3 }) => iso3.toLowerCase(), + ({ id }) => id, + ) + ), + [countryList], + ); + + const { + response: riskScoreResponse, + pending: riskScoreResponsePending, + } = useRiskRequest({ + skip: variant === 'region' && isNotDefined(regionId), + apiType: 'risk', + url: '/api/v1/risk-score/', + query: variant === 'region' + ? { + region: regionId, + limit: 9999, + } : { limit: 9999 }, + }); + + // NOTE: We get single element as array in response + const seasonalRiskData = seasonalResponse?.[0]; + const dataPending = riskScoreResponsePending || seasonalResponsePending; + + const data = useMemo( + () => { + if (isNotDefined(seasonalRiskData)) { + return undefined; + } + + const { + idmc, + ipc_displacement_data, + raster_displacement_data, + gwis_seasonal, + } = seasonalRiskData; + + const displacement = idmc?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ).filter(isDefined) ?? []; + + const groupedIpc = Object.values( + listToGroupList( + ipc_displacement_data ?? [], + (ipcDataItem) => ipcDataItem.country, + ), + ); + + const exposure = [ + ...raster_displacement_data?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...groupedIpc.map(getFiRiskDataItem), + ].filter(isDefined); + + const riskScore = unique( + [ + ...riskScoreResponse?.results?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...gwis_seasonal?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ].filter(isDefined), + (item) => `${item.country_details.iso3}-${item.hazard_type}`, + ); + + return { + displacement, + exposure, + riskScore, + }; + }, + [seasonalRiskData, riskScoreResponse], + ); + + const riskMetricOptions: RiskMetricOption[] = useMemo( + () => ([ + { + key: 'exposure', + label: strings.peopleExposedOptionLabel, + applicableHazards: applicableHazardsByRiskMetric.exposure, + }, + { + key: 'displacement', + label: strings.peopleAtRiskOptionLabel, + applicableHazards: applicableHazardsByRiskMetric.displacement, + }, + { + key: 'riskScore', + label: strings.riskScoreOptionLabel, + applicableHazards: applicableHazardsByRiskMetric.riskScore, + }, + ]), + [ + strings.peopleExposedOptionLabel, + strings.peopleAtRiskOptionLabel, + strings.riskScoreOptionLabel, + ], + ); + + const availableHazards: Record< + RiskMetric, + { [key in HazardType]?: string } + > | undefined = useMemo( + () => { + if (isNotDefined(data)) { + return undefined; + } + + return { + exposure: { + ...listToMap( + data.exposure, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }, + displacement: { + ...listToMap( + data.displacement, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }, + riskScore: { + ...listToMap( + data.riskScore, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }, + }; + }, + [data], + ); + + const [filters, setFilters] = useInputState( + defaultFilterValue, + (newValue, oldValue) => { + // We only apply side effect when risk metric is changed + if (newValue.riskMetric === oldValue.riskMetric) { + return newValue; + } + + const availableHazardsForSelectedRiskMetric = availableHazards?.[newValue.riskMetric]; + + if (isNotDefined(availableHazardsForSelectedRiskMetric)) { + return newValue; + } + + const selectedRiskMetricDetail = riskMetricOptions.find( + (option) => option.key === newValue.riskMetric, + ); + + if (isNotDefined(selectedRiskMetricDetail)) { + return newValue; + } + + const newHazardTypeOptions = selectedRiskMetricDetail.applicableHazards.map( + (hazardType) => { + const hazard_type_display = availableHazardsForSelectedRiskMetric[hazardType]; + if (isFalsyString(hazard_type_display)) { + return undefined; + } + + return { + hazard_type: hazardType, + hazard_type_display, + }; + }, + ).filter(isDefined); + + setHazardTypeOptions(newHazardTypeOptions); + + return { + ...newValue, + hazardTypes: newHazardTypeOptions.map(({ hazard_type }) => hazard_type), + }; + }, + ); + + // NOTE: setting default values + useEffect( + () => { + if ( + isNotDefined(availableHazards) + || isNotDefined(availableHazards.riskScore) + || isNotDefined(countryList) + ) { + return; + } + + const riskMetric = riskMetricOptions.find( + (option) => option.key === 'riskScore', + ); + + if (isNotDefined(riskMetric)) { + return; + } + + const newHazardTypeOptions = riskMetric.applicableHazards.map( + (hazardType) => { + const hazard_type_display = availableHazards.riskScore[hazardType]; + if (isFalsyString(hazard_type_display)) { + return undefined; + } + + return { + hazard_type: hazardType, + hazard_type_display, + }; + }, + ).filter(isDefined); + + setHazardTypeOptions(newHazardTypeOptions); + + setFilters({ + countries: countryList.map((country) => country.iso3), + riskMetric: riskMetric.key, + hazardTypes: riskMetric.applicableHazards.filter( + (hazardType) => isDefined(availableHazards[riskMetric.key]?.[hazardType]), + ), + months: [new Date().getMonth()], + normalizeByPopulation: false, + includeCopingCapacity: false, + }); + }, + [countryList, riskMetricOptions, availableHazards, setFilters, setHazardTypeOptions], + ); + + const mappings = useMemo( + () => { + if (isNotDefined(riskScoreResponse) || isNotDefined(riskScoreResponse.results)) { + return undefined; + } + + const riskScoreList = riskScoreResponse.results.map( + (item) => { + if ( + isNotDefined(item.country_details) + || isFalsyString(item.country_details.iso3) + ) { + return undefined; + } + + return { + iso3: item.country_details.iso3, + lcc: item.lcc, + population_in_thousands: item.population_in_thousands, + }; + }, + ).filter(isDefined); + + const populationListSafe = riskScoreList.map( + (item) => ( + isDefined(item.population_in_thousands) + ? { + iso3: item.iso3, + population_in_thousands: item.population_in_thousands, + } : undefined + ), + ).filter(isDefined); + + const maxPopulation = maxSafe( + populationListSafe.map( + (item) => item.population_in_thousands, + ), + ) ?? 1; + + const populationFactorMap = listToMap( + populationListSafe, + (result) => result.iso3, + (result) => result.population_in_thousands / maxPopulation, + ); + + const populationMap = listToMap( + riskScoreList, + (result) => result.iso3, + (result) => result.population_in_thousands, + ); + + const lccListSafe = riskScoreList.map( + (item) => ( + isDefined(item.lcc) + ? { + iso3: item.iso3, + lcc: item.lcc, + } : undefined + ), + ).filter(isDefined); + + const maxLcc = maxSafe( + lccListSafe.map( + (item) => item.lcc, + ), + ) ?? 10; + + const lccMap = listToMap( + lccListSafe, + (item) => item.iso3, + (item) => item.lcc, + ); + + const lccFactorMap = listToMap( + lccListSafe, + (item) => item.iso3, + (item) => item.lcc / maxLcc, + ); + + return { + lcc: lccMap, + population: populationMap, + populationFactor: populationFactorMap, + lccFactor: lccFactorMap, + }; + }, + [riskScoreResponse], + ); + + const filteredData = useMemo( + () => { + const selectedHazards = listToMap( + filters.hazardTypes, + (hazardType) => hazardType, + () => true, + ); + + const selectedCountries = listToMap( + filters.countries, + (iso3) => iso3.toLowerCase(), + () => true, + ); + + const selectedMonths = listToMap( + filters.months, + (monthKey) => monthKey, + () => true, + ); + + type RiskDataItemWithHazard = RiskDataItem & { + hazard_type: HazardType; + hazard_type_display: string; + country_details: { + id: number; + name?: string | null; + iso3?: string | null; + } + } + + function groupByCountry(riskDataList: RiskDataItemWithHazard[] | undefined) { + return listToGroupList( + riskDataList?.map( + (item) => { + const { country_details } = item; + if ( + !selectedHazards[item.hazard_type] + || isNotDefined(country_details) + || isFalsyString(country_details.iso3) + || !selectedCountries[country_details.iso3] + || isFalsyString(country_details.name) + ) { + return undefined; + } + + return { + ...item, + country_details: { + id: country_details.id, + iso3: country_details.iso3, + name: country_details.name, + }, + }; + }, + ).filter(isDefined) ?? [], + (item) => item.country_details.iso3, + ); + } + + function transformRiskData( + riskDataList: RiskDataItemWithHazard[] | undefined, + mode: 'sum' | 'max' = 'sum', + ) { + const transformedList = mapToList( + groupByCountry(riskDataList), + (itemList, key) => { + const firstItem = itemList[0]; + const valueListByHazard = itemList.map( + (item) => { + const value = getValueForSelectedMonths( + selectedMonths, + item, + mode, + ); + + if (isNotDefined(value)) { + return undefined; + } + + const newValue = filters.riskMetric === 'riskScore' + ? riskScoreToCategory( + // NOTE: Risk Scores are multiplited + // by vulnerability (from server) + // So, dividing by 10 to + // correct the range (0 - 100 -> 0 - 10) + item.hazard_type === 'WF' ? value : (value / 10), + item.hazard_type, + ) : value; + + if (isNotDefined(newValue)) { + return undefined; + } + + let riskCategory; + if (filters.riskMetric === 'exposure') { + riskCategory = getExposureRiskCategory(newValue); + } else if (filters.riskMetric === 'displacement') { + riskCategory = getDisplacementRiskCategory(newValue); + } else if (filters.riskMetric === 'riskScore') { + riskCategory = newValue; + } + + if (filters.normalizeByPopulation) { + const populationFactor = mappings?.populationFactor[ + item.country_details.iso3 + ]; + + if (isDefined(riskCategory) && isDefined(populationFactor)) { + riskCategory = Math.ceil(riskCategory * populationFactor); + } + } + + if (filters.includeCopingCapacity) { + const lccFactor = mappings?.lccFactor[ + item.country_details.iso3 + ]; + + if (isDefined(riskCategory) && isDefined(lccFactor)) { + riskCategory = Math.ceil(riskCategory * lccFactor); + } + } + + if (isNotDefined(riskCategory)) { + return undefined; + } + + return { + value: newValue, + riskCategory, + hazard_type: item.hazard_type, + hazard_type_display: item.hazard_type_display, + }; + }, + ).filter(isDefined).sort( + (a, b) => compareNumber(a.riskCategory, b.riskCategory, -1), + ); + + const maxValue = maxSafe(valueListByHazard.map(({ value }) => value)); + const sum = sumSafe(valueListByHazard.map(({ value }) => value)); + const maxRiskCategory = maxSafe( + valueListByHazard.map(({ riskCategory }) => riskCategory), + ); + const riskCategorySum = sumSafe( + valueListByHazard.map(({ riskCategory }) => riskCategory), + ); + + if ( + isNotDefined(maxValue) + || maxValue === 0 + || isNotDefined(sum) + || sum === 0 + || isNotDefined(maxRiskCategory) + ) { + return undefined; + } + + const totalValue = sum; + const normalizedValueListByHazard = valueListByHazard.map( + (item) => ({ + ...item, + normalizedValue: item.value / totalValue, + }), + ); + + return { + iso3: key, + totalValue, + maxValue, + riskCategory: maxRiskCategory, + riskCategorySum, + valueListByHazard: normalizedValueListByHazard, + country_details: firstItem.country_details, + }; + }, + ).filter(isDefined); + + const maxValue = maxSafe(transformedList.map((item) => item.totalValue)); + if (isNotDefined(maxValue) || maxValue === 0) { + return undefined; + } + + return transformedList.map( + (item) => ({ + ...item, + normalizedValue: item.totalValue / maxValue, + maxValue, + }), + ).sort((a, b) => compareNumber(a.riskCategorySum, b.riskCategorySum, -1)); + } + + if (filters.riskMetric === 'displacement') { + return transformRiskData( + data?.displacement, + ); + } + + if (filters.riskMetric === 'exposure') { + return transformRiskData( + data?.exposure, + ); + } + + if (filters.riskMetric === 'riskScore') { + const transformedData = transformRiskData( + data?.riskScore, + 'max', + ); + + return transformedData; + } + + return undefined; + }, + [data, filters, mappings], + ); + + const MAX_RISK_SCORE = CATEGORY_RISK_VERY_HIGH; + + // NOTE: we need to generate the layerOptions because we cannot use MapState + // The id in the vector tile does not match the id in GO + // We also cannot use promoteId as it is a non-managed mapbox source + const layerOptions = useMemo>( + () => { + if (isNotDefined(filteredData) || filteredData.length === 0) { + return { + type: 'fill', + paint: { + 'fill-color': COLOR_LIGHT_GREY, + }, + }; + } + + return { + type: 'fill', + paint: { + 'fill-color': [ + 'match', + ['get', 'iso3'], + ...filteredData.flatMap( + (item) => [ + item.country_details.iso3.toUpperCase(), + [ + 'interpolate', + ['linear'], + ['number', item.riskCategory], + CATEGORY_RISK_VERY_LOW, + COLOR_LIGHT_BLUE, + CATEGORY_RISK_VERY_HIGH, + COLOR_PRIMARY_RED, + ], + ], + ), + COLOR_LIGHT_GREY, + ], + 'fill-outline-color': [ + 'case', + ['boolean', ['feature-state', 'hovered'], false], + COLOR_DARK_GREY, + 'transparent', + ], + }, + }; + }, + [filteredData], + ); + + const handleCountryClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + feature: feature as unknown as ClickedPoint['feature'], + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClose = useCallback( + () => { + setClickedPointProperties(undefined); + }, + [setClickedPointProperties], + ); + + const riskPopupValue = useMemo(() => ( + filteredData?.find( + (filter) => filter.iso3 === clickedPointProperties + ?.feature.properties.iso3.toLowerCase(), + ) + ), [filteredData, clickedPointProperties]); + + return ( + + )} + headerDescriptionContainerClassName={styles.headerDescription} + headerDescription={( + <> +
+ {strings.seasonalEventsDescriptionOne} +
+
+ {strings.seasonalEventsDescriptionTwo} +
+ + )} + actions={( + + )} + childrenContainerClassName={styles.mainContent} + withHeaderBorder + footerClassName={styles.footer} + footerContent={( +
+
{strings.severityLegendLabel}
+
+
+
+
+ {strings.severityLowLabel} +
+
+ {strings.severityHighLabel} +
+
+
+
+ )} + footerActionsContainerClassName={styles.footerActions} + footerActions={( +
+
+ {strings.hazardsTypeLegendLabel} +
+
+ {hazardTypeOptions.map((hazard) => ( + + ))} +
+
+ )} + > + + )} + > + + + {clickedPointProperties?.lngLat && riskPopupValue && ( + + {clickedPointProperties.feature.properties.name} + + )} + contentViewType="vertical" + childrenContainerClassName={styles.popupContent} + > + + + )} + + + {dataPending && } + {!dataPending && (isNotDefined(filteredData) || filteredData?.length === 0) && ( + + )} + {/* FIXME: use List */} + {!dataPending && filteredData?.map( + (dataItem) => { + const totalValuePercentage = 100 * dataItem.normalizedValue; + if (totalValuePercentage < 1) { + return null; + } + + const countryId = countryIso3ToIdMap[dataItem.country_details.iso3]; + + return ( +
+ + {dataItem.country_details.name} + +
+ {dataItem.valueListByHazard.map( + ({ + hazard_type, + riskCategory, + }) => { + // eslint-disable-next-line max-len + const percentage = (100 * riskCategory) / (MAX_RISK_SCORE * filters.hazardTypes.length); + + if (percentage < 1) { + return null; + } + + return ( +
+ ); + }, + )} +
+ + )} + /> +
+ ); + }, + )} + + + ); +} + +export default RiskSeasonalMap; diff --git a/src/components/domain/RiskSeasonalMap/styles.module.css b/app/src/components/domain/RiskSeasonalMap/styles.module.css similarity index 100% rename from src/components/domain/RiskSeasonalMap/styles.module.css rename to app/src/components/domain/RiskSeasonalMap/styles.module.css diff --git a/app/src/components/domain/SeverityIndicator/index.tsx b/app/src/components/domain/SeverityIndicator/index.tsx new file mode 100644 index 000000000..006680b48 --- /dev/null +++ b/app/src/components/domain/SeverityIndicator/index.tsx @@ -0,0 +1,40 @@ +import { + _cs, + isNotDefined, +} from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +interface Props { + className?: string; + level: number | undefined | null; + title?: string; +} + +function SeverityIndicator(props: Props) { + const { + level, + title, + className, + } = props; + const classNameMap: Record = { + 0: styles.yellow, + 1: styles.orange, + 2: styles.red, + }; + + if (isNotDefined(level)) { + return null; + } + + return ( +
+
+
+ ); +} + +export default SeverityIndicator; diff --git a/src/components/domain/SeverityIndicator/styles.module.css b/app/src/components/domain/SeverityIndicator/styles.module.css similarity index 100% rename from src/components/domain/SeverityIndicator/styles.module.css rename to app/src/components/domain/SeverityIndicator/styles.module.css diff --git a/app/src/components/domain/SurgeCardContainer/index.tsx b/app/src/components/domain/SurgeCardContainer/index.tsx new file mode 100644 index 000000000..9a91fe520 --- /dev/null +++ b/app/src/components/domain/SurgeCardContainer/index.tsx @@ -0,0 +1,28 @@ +import { Container } from '@ifrc-go/ui'; + +import styles from './styles.module.css'; + +interface Props { + heading: React.ReactNode; + children: React.ReactNode; +} + +function SurgeCardContainer(props: Props) { + const { + heading, + children, + } = props; + + return ( + + {children} + + ); +} + +export default SurgeCardContainer; diff --git a/src/components/domain/SurgeCardContainer/styles.module.css b/app/src/components/domain/SurgeCardContainer/styles.module.css similarity index 100% rename from src/components/domain/SurgeCardContainer/styles.module.css rename to app/src/components/domain/SurgeCardContainer/styles.module.css diff --git a/src/components/domain/SurgeCatalogueContainer/i18n.json b/app/src/components/domain/SurgeCatalogueContainer/i18n.json similarity index 100% rename from src/components/domain/SurgeCatalogueContainer/i18n.json rename to app/src/components/domain/SurgeCatalogueContainer/i18n.json diff --git a/app/src/components/domain/SurgeCatalogueContainer/index.tsx b/app/src/components/domain/SurgeCatalogueContainer/index.tsx new file mode 100644 index 000000000..880881826 --- /dev/null +++ b/app/src/components/domain/SurgeCatalogueContainer/index.tsx @@ -0,0 +1,86 @@ +import { useCallback } from 'react'; +import { ArrowLeftLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + Image, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import useRouting from '#hooks/useRouting'; + +import { WrappedRoutes } from '../../../App/routes'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface ImageListItem { + src: string; + caption?: string; +} + +interface Props { + heading: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + goBackFallbackLink?: keyof WrappedRoutes; + imageList?: ImageListItem[]; +} + +function SurgeCatalogueContainer(props: Props) { + const { + heading, + description, + children, + goBackFallbackLink, + imageList, + } = props; + + const strings = useTranslation(i18n); + + const { goBack } = useRouting(); + const handleBackButtonClick = useCallback(() => { + if (isDefined(goBackFallbackLink)) { + goBack(goBackFallbackLink); + } + }, [goBack, goBackFallbackLink]); + + return ( + ( + + ), + )} + icons={isDefined(goBackFallbackLink) && ( + + )} + > + {children} + + ); +} + +export default SurgeCatalogueContainer; diff --git a/src/components/domain/SurgeCatalogueContainer/styles.module.css b/app/src/components/domain/SurgeCatalogueContainer/styles.module.css similarity index 100% rename from src/components/domain/SurgeCatalogueContainer/styles.module.css rename to app/src/components/domain/SurgeCatalogueContainer/styles.module.css diff --git a/app/src/components/domain/SurgeContentContainer/index.tsx b/app/src/components/domain/SurgeContentContainer/index.tsx new file mode 100644 index 000000000..1017cef0c --- /dev/null +++ b/app/src/components/domain/SurgeContentContainer/index.tsx @@ -0,0 +1,28 @@ +import { Container } from '@ifrc-go/ui'; + +import styles from './styles.module.css'; + +interface Props { + heading: React.ReactNode; + children: React.ReactNode; +} + +function SurgeContentContainer(props: Props) { + const { + heading, + children, + } = props; + + return ( + + {children} + + ); +} + +export default SurgeContentContainer; diff --git a/src/components/domain/SurgeContentContainer/styles.module.css b/app/src/components/domain/SurgeContentContainer/styles.module.css similarity index 100% rename from src/components/domain/SurgeContentContainer/styles.module.css rename to app/src/components/domain/SurgeContentContainer/styles.module.css diff --git a/src/components/domain/UserSearchMultiSelectInput.tsx b/app/src/components/domain/UserSearchMultiSelectInput.tsx similarity index 96% rename from src/components/domain/UserSearchMultiSelectInput.tsx rename to app/src/components/domain/UserSearchMultiSelectInput.tsx index a2dd974f7..b7cbcb526 100644 --- a/src/components/domain/UserSearchMultiSelectInput.tsx +++ b/app/src/components/domain/UserSearchMultiSelectInput.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; - -import SearchMultiSelectInput, { +import { + SearchMultiSelectInput, SearchMultiSelectInputProps, -} from '#components/SearchMultiSelectInput'; +} from '@ifrc-go/ui'; -import { useRequest } from '#utils/restRequest'; -import type { GoApiResponse } from '#utils/restRequest'; import useDebouncedValue from '#hooks/useDebouncedValue'; import { getUserName } from '#utils/domain/user'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; type UserDetails = NonNullable['results']>[number]; export type User = Pick; diff --git a/src/components/printable/Link/index.tsx b/app/src/components/printable/Link/index.tsx similarity index 100% rename from src/components/printable/Link/index.tsx rename to app/src/components/printable/Link/index.tsx diff --git a/src/components/printable/Link/styles.module.css b/app/src/components/printable/Link/styles.module.css similarity index 100% rename from src/components/printable/Link/styles.module.css rename to app/src/components/printable/Link/styles.module.css diff --git a/src/config.ts b/app/src/config.ts similarity index 100% rename from src/config.ts rename to app/src/config.ts diff --git a/src/contexts/domain.tsx b/app/src/contexts/domain.tsx similarity index 99% rename from src/contexts/domain.tsx rename to app/src/contexts/domain.tsx index e923d2d1f..b05d9022e 100644 --- a/src/contexts/domain.tsx +++ b/app/src/contexts/domain.tsx @@ -1,4 +1,5 @@ import { createContext } from 'react'; + import { type GoApiResponse } from '#utils/restRequest'; export type CacheKey = 'country' | 'global-enums' | 'disaster-type' | 'user-me' | 'region'; diff --git a/src/contexts/route.tsx b/app/src/contexts/route.tsx similarity index 99% rename from src/contexts/route.tsx rename to app/src/contexts/route.tsx index 39a9822e7..4f8c9081f 100644 --- a/src/contexts/route.tsx +++ b/app/src/contexts/route.tsx @@ -1,4 +1,5 @@ import { createContext } from 'react'; + import type { WrappedRoutes } from '../App/routes'; const RouteContext = createContext( diff --git a/src/contexts/user.tsx b/app/src/contexts/user.tsx similarity index 100% rename from src/contexts/user.tsx rename to app/src/contexts/user.tsx diff --git a/src/declarations/env.d.ts b/app/src/declarations/env.d.ts similarity index 100% rename from src/declarations/env.d.ts rename to app/src/declarations/env.d.ts diff --git a/src/hooks/domain/useAuth.ts b/app/src/hooks/domain/useAuth.ts similarity index 86% rename from src/hooks/domain/useAuth.ts rename to app/src/hooks/domain/useAuth.ts index 262ae6fda..f74a7a384 100644 --- a/src/hooks/domain/useAuth.ts +++ b/app/src/hooks/domain/useAuth.ts @@ -1,4 +1,7 @@ -import { useContext, useMemo } from 'react'; +import { + useContext, + useMemo, +} from 'react'; import { isDefined } from '@togglecorp/fujs'; import UserContext from '#contexts/user'; diff --git a/src/hooks/domain/useCountry.ts b/app/src/hooks/domain/useCountry.ts similarity index 100% rename from src/hooks/domain/useCountry.ts rename to app/src/hooks/domain/useCountry.ts index 3cc8be194..c1fd2e111 100644 --- a/src/hooks/domain/useCountry.ts +++ b/app/src/hooks/domain/useCountry.ts @@ -1,12 +1,12 @@ import { - isDefined, - isTruthyString, -} from '@togglecorp/fujs'; -import { - useMemo, useContext, useEffect, + useMemo, } from 'react'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; import DomainContext, { type Countries } from '#contexts/domain'; diff --git a/src/hooks/domain/useCountryRaw.ts b/app/src/hooks/domain/useCountryRaw.ts similarity index 100% rename from src/hooks/domain/useCountryRaw.ts rename to app/src/hooks/domain/useCountryRaw.ts index 7cb74c1c0..057bd420c 100644 --- a/src/hooks/domain/useCountryRaw.ts +++ b/app/src/hooks/domain/useCountryRaw.ts @@ -1,9 +1,9 @@ -import { isDefined } from '@togglecorp/fujs'; import { - useMemo, useContext, useEffect, + useMemo, } from 'react'; +import { isDefined } from '@togglecorp/fujs'; import DomainContext, { type Countries } from '#contexts/domain'; diff --git a/src/hooks/domain/useCurrentLanguage.ts b/app/src/hooks/domain/useCurrentLanguage.ts similarity index 77% rename from src/hooks/domain/useCurrentLanguage.ts rename to app/src/hooks/domain/useCurrentLanguage.ts index 030b0fc94..7aca9307f 100644 --- a/src/hooks/domain/useCurrentLanguage.ts +++ b/app/src/hooks/domain/useCurrentLanguage.ts @@ -1,6 +1,5 @@ import { useContext } from 'react'; - -import LanguageContext from '#contexts/language'; +import { LanguageContext } from '@ifrc-go/ui/contexts'; function useCurrentLanguage() { const { currentLanguage } = useContext(LanguageContext); diff --git a/src/hooks/domain/useDisasterType.ts b/app/src/hooks/domain/useDisasterType.ts similarity index 100% rename from src/hooks/domain/useDisasterType.ts rename to app/src/hooks/domain/useDisasterType.ts index 25847950a..e7575e484 100644 --- a/src/hooks/domain/useDisasterType.ts +++ b/app/src/hooks/domain/useDisasterType.ts @@ -1,9 +1,9 @@ -import { isTruthyString } from '@togglecorp/fujs'; import { useContext, useEffect, useMemo, } from 'react'; +import { isTruthyString } from '@togglecorp/fujs'; import DomainContext, { DisasterTypes } from '#contexts/domain'; diff --git a/src/hooks/domain/useGlobalEnums.ts b/app/src/hooks/domain/useGlobalEnums.ts similarity index 100% rename from src/hooks/domain/useGlobalEnums.ts rename to app/src/hooks/domain/useGlobalEnums.ts diff --git a/src/hooks/domain/useNationalSociety.ts b/app/src/hooks/domain/useNationalSociety.ts similarity index 100% rename from src/hooks/domain/useNationalSociety.ts rename to app/src/hooks/domain/useNationalSociety.ts index 58d9ba475..39730d253 100644 --- a/src/hooks/domain/useNationalSociety.ts +++ b/app/src/hooks/domain/useNationalSociety.ts @@ -1,19 +1,19 @@ import { - isDefined, - isTruthyString, -} from '@togglecorp/fujs'; -import { - useMemo, useContext, useEffect, + useMemo, } from 'react'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; import DomainContext from '#contexts/domain'; import { - type PartialCountry, type Country, isValidCountry, + type PartialCountry, } from './useCountry'; export type NationalSociety = Omit & { diff --git a/src/hooks/domain/usePermissions.ts b/app/src/hooks/domain/usePermissions.ts similarity index 100% rename from src/hooks/domain/usePermissions.ts rename to app/src/hooks/domain/usePermissions.ts diff --git a/src/hooks/domain/useRegion.ts b/app/src/hooks/domain/useRegion.ts similarity index 100% rename from src/hooks/domain/useRegion.ts rename to app/src/hooks/domain/useRegion.ts index 9f62b3fec..5c3862a44 100644 --- a/src/hooks/domain/useRegion.ts +++ b/app/src/hooks/domain/useRegion.ts @@ -1,11 +1,11 @@ import { - useMemo, useContext, useEffect, + useMemo, } from 'react'; +import { isDefined } from '@togglecorp/fujs'; import DomainContext, { type Regions } from '#contexts/domain'; -import { isDefined } from '@togglecorp/fujs'; export type Region = NonNullable[number]; diff --git a/src/hooks/domain/useUserMe.ts b/app/src/hooks/domain/useUserMe.ts similarity index 100% rename from src/hooks/domain/useUserMe.ts rename to app/src/hooks/domain/useUserMe.ts diff --git a/src/hooks/useAlert.ts b/app/src/hooks/useAlert.ts similarity index 82% rename from src/hooks/useAlert.ts rename to app/src/hooks/useAlert.ts index bfa0551e4..328790e7d 100644 --- a/src/hooks/useAlert.ts +++ b/app/src/hooks/useAlert.ts @@ -1,9 +1,15 @@ -import { useContext, useCallback, useMemo } from 'react'; +import { + useCallback, + useContext, + useMemo, +} from 'react'; +import { + AlertContext, + type AlertType, +} from '@ifrc-go/ui/contexts'; +import { DURATION_DEFAULT_ALERT_DISMISS } from '@ifrc-go/ui/utils'; import { randomString } from '@togglecorp/fujs'; -import AlertContext, { AlertType } from '#contexts/alert'; -import { DURATION_DEFAULT_ALERT_DISMISS } from '#utils/constants'; - interface AddAlertOption { name?: string; variant?: AlertType; diff --git a/src/hooks/useChartData.ts b/app/src/hooks/useChartData.ts similarity index 97% rename from src/hooks/useChartData.ts rename to app/src/hooks/useChartData.ts index b3b96f5fc..641ae6389 100644 --- a/src/hooks/useChartData.ts +++ b/app/src/hooks/useChartData.ts @@ -1,17 +1,15 @@ import { useMemo } from 'react'; -import { isDefined } from '@togglecorp/fujs'; - +import { useSizeTracking } from '@ifrc-go/ui/hooks'; import { type Bounds, - type Rect, + ChartScale, getBounds, getIntervals, getScaleFunction, - ChartScale, -} from '#utils/chart'; -import { formatNumber } from '#utils/common'; - -import useSizeTracking from '#hooks/useSizeTracking'; + type Rect, +} from '@ifrc-go/ui/utils'; +import { formatNumber } from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; type Key = string | number; diff --git a/src/hooks/useDebouncedValue.ts b/app/src/hooks/useDebouncedValue.ts similarity index 93% rename from src/hooks/useDebouncedValue.ts rename to app/src/hooks/useDebouncedValue.ts index 35a76f8d2..5cb59a4fd 100644 --- a/src/hooks/useDebouncedValue.ts +++ b/app/src/hooks/useDebouncedValue.ts @@ -1,4 +1,7 @@ -import { useState, useEffect } from 'react'; +import { + useEffect, + useState, +} from 'react'; function useDebouncedValue( input: T, diff --git a/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts similarity index 98% rename from src/hooks/useFilterState.ts rename to app/src/hooks/useFilterState.ts index c3b171f2c..174e271ec 100644 --- a/src/hooks/useFilterState.ts +++ b/app/src/hooks/useFilterState.ts @@ -1,14 +1,14 @@ import { type SetStateAction, - useReducer, useCallback, useMemo, + useReducer, } from 'react'; +import { hasSomeDefinedValue } from '@ifrc-go/ui/utils'; import { isNotDefined } from '@togglecorp/fujs'; import { EntriesAsList } from '@togglecorp/toggle-form'; import useDebouncedValue from '#hooks/useDebouncedValue'; -import { hasSomeDefinedValue } from '#utils/common'; type SortDirection = 'asc' | 'dsc'; interface SortParameter { diff --git a/src/hooks/useInputState.ts b/app/src/hooks/useInputState.ts similarity index 94% rename from src/hooks/useInputState.ts rename to app/src/hooks/useInputState.ts index 9ead3c667..f731be64c 100644 --- a/src/hooks/useInputState.ts +++ b/app/src/hooks/useInputState.ts @@ -1,4 +1,7 @@ -import React, { useEffect, useRef } from 'react'; +import React, { + useEffect, + useRef, +} from 'react'; type ValueOrSetterFn = T | ((value: T) => T); function isSetterFn(value: ValueOrSetterFn): value is ((value: T) => T) { diff --git a/src/hooks/useRecursiveCsvRequest.ts b/app/src/hooks/useRecursiveCsvRequest.ts similarity index 97% rename from src/hooks/useRecursiveCsvRequest.ts rename to app/src/hooks/useRecursiveCsvRequest.ts index 8ac68f9ee..d1af0c4f7 100644 --- a/src/hooks/useRecursiveCsvRequest.ts +++ b/app/src/hooks/useRecursiveCsvRequest.ts @@ -1,4 +1,9 @@ -import { useCallback, useState, useRef } from 'react'; +import { + useCallback, + useRef, + useState, +} from 'react'; +import { type Language } from '@ifrc-go/ui/contexts'; import { isDefined, isFalsyString, @@ -6,15 +11,17 @@ import { } from '@togglecorp/fujs'; import Papa from 'papaparse'; -import { KEY_LANGUAGE_STORAGE, KEY_USER_STORAGE } from '#utils/constants'; -import { getFromStorage } from '#utils/localStorage'; -import { resolveUrl } from '#utils/resolveUrl'; -import { type UserAuth } from '#contexts/user'; -import { type Language } from '#contexts/language'; import { - riskApi, api, + riskApi, } from '#config'; +import { type UserAuth } from '#contexts/user'; +import { + KEY_LANGUAGE_STORAGE, + KEY_USER_STORAGE, +} from '#utils/constants'; +import { getFromStorage } from '#utils/localStorage'; +import { resolveUrl } from '#utils/resolveUrl'; type Maybe = T | null | undefined; diff --git a/src/hooks/useRouting.ts b/app/src/hooks/useRouting.ts similarity index 92% rename from src/hooks/useRouting.ts rename to app/src/hooks/useRouting.ts index 5449795fb..477408e17 100644 --- a/src/hooks/useRouting.ts +++ b/app/src/hooks/useRouting.ts @@ -1,11 +1,17 @@ -import { useContext, useCallback } from 'react'; import { - useNavigate, - useLocation, + useCallback, + useContext, +} from 'react'; +import { type NavigateOptions, + useLocation, + useNavigate, } from 'react-router-dom'; -import { resolvePath, type UrlParams } from '#components/Link'; +import { + resolvePath, + type UrlParams, +} from '#components/Link'; import RouteContext from '#contexts/route'; import { type WrappedRoutes } from '../App/routes'; diff --git a/src/hooks/useUrlSearchState.ts b/app/src/hooks/useUrlSearchState.ts similarity index 94% rename from src/hooks/useUrlSearchState.ts rename to app/src/hooks/useUrlSearchState.ts index 4048b4fef..b0e7da62e 100644 --- a/src/hooks/useUrlSearchState.ts +++ b/app/src/hooks/useUrlSearchState.ts @@ -4,9 +4,15 @@ import { useMemo, useRef, } from 'react'; -import { encodeDate, isNotDefined } from '@togglecorp/fujs'; +import { + NavigateOptions, + useSearchParams, +} from 'react-router-dom'; +import { + encodeDate, + isNotDefined, +} from '@togglecorp/fujs'; import { isCallable } from '@togglecorp/toggle-form'; -import { useSearchParams, NavigateOptions } from 'react-router-dom'; type SearchValueFromUrl = string | null | undefined; type SearchValueFromUser = string | number | boolean | Date | undefined | null; diff --git a/app/src/index.css b/app/src/index.css new file mode 100644 index 000000000..1161db1ad --- /dev/null +++ b/app/src/index.css @@ -0,0 +1,40 @@ +* { + box-sizing: border-box; +} + +html { + @media screen { + margin: 0; + padding: 0; + scrollbar-gutter: stable; + } +} + +body { + line-height: var(--go-ui-line-height-md); + color: var(--go-ui-color-text); + font-family: var(--go-ui-font-family-sans-serif); + font-size: var(--go-ui-font-size-md); + font-weight: var(--go-ui-font-weight-normal); + + @media screen { + margin: 0; + background-color: var(--go-ui-color-background); + padding: 0; + } +} + +ul, ol, p { + margin: 0; +} + +@media print { + @page { + size: portrait A4; + margin: 10mm 10mm 16mm 10mm; + } + + body { + font-family: 'Open Sans', sans-serif; + } +} diff --git a/app/src/index.tsx b/app/src/index.tsx new file mode 100644 index 000000000..2be01a74e --- /dev/null +++ b/app/src/index.tsx @@ -0,0 +1,81 @@ +import '@ifrc-go/ui/index.css'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import './index.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import * as Sentry from '@sentry/react'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { + api, + appCommitHash, + appTitle, + appVersion, + environment, + sentryAppDsn, + sentryReplaysOnErrorSampleRate, + sentryReplaysSessionSampleRate, + sentryTracesSampleRate, +} from '#config'; + +import App from './App/index.tsx'; + +if (isDefined(sentryAppDsn)) { + Sentry.init({ + dsn: sentryAppDsn, + release: `${appTitle}@${appVersion}+${appCommitHash}`, + environment, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + new Sentry.Replay(), + ], + + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + tracesSampleRate: Number(sentryTracesSampleRate), + + // Set `tracePropagationTargets` to control for which URLs distributed + // tracing should be enabled + tracePropagationTargets: [ + api, + // riskApi, TODO: let's add this once sentry is configured for risk server + ], + + // Capture Replay for (sentryReplaysSessionSampleRate)% of all sessions, + // plus for (sentryReplaysOnErrorSampleRate)% of sessions with an error + replaysSessionSampleRate: Number(sentryReplaysSessionSampleRate), + replaysOnErrorSampleRate: Number(sentryReplaysOnErrorSampleRate), + }); +} + +const webappRootId = 'webapp-root'; +const webappRootElement = document.getElementById(webappRootId); + +if (isNotDefined(webappRootElement)) { + // eslint-disable-next-line no-console + console.error(`Could not find html element with id '${webappRootId}'`); +} else { + ReactDOM.createRoot(webappRootElement).render( + + + , + ); +} diff --git a/app/src/utils/common.ts b/app/src/utils/common.ts new file mode 100644 index 000000000..12b1960e1 --- /dev/null +++ b/app/src/utils/common.ts @@ -0,0 +1,18 @@ +import type { GoApiResponse } from '#utils/restRequest'; + +type SearchResponse = GoApiResponse<'/api/v1/search/'>; + +type SearchResponseKeys = keyof SearchResponse; +// eslint-disable-next-line import/prefer-default-export +export const defaultRanking: Record = { + regions: 1, + countries: 2, + district_province_response: 3, + + emergencies: 4, + projects: 5, + surge_alerts: 6, + surge_deployments: 7, + reports: 8, + rapid_response_deployments: 9, +}; diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts new file mode 100644 index 000000000..16bb07015 --- /dev/null +++ b/app/src/utils/constants.ts @@ -0,0 +1,172 @@ +import { type components } from '#generated/types'; + +export const defaultChartMargin = { + top: 0, + right: 0, + bottom: 0, + left: 0, +}; + +export const defaultChartPadding = { + top: 10, + right: 10, + bottom: 10, + left: 10, +}; + +// Map +export const DURATION_MAP_ZOOM = 1000; +export const DEFAULT_MAP_PADDING = 50; + +// Storage + +export const KEY_USER_STORAGE = 'user'; +export const KEY_LANGUAGE_STORAGE = 'language'; + +// Search page + +export const KEY_URL_SEARCH = 'keyword'; +export const SEARCH_TEXT_LENGTH_MIN = 3; + +// Risk + +export const COLOR_HAZARD_CYCLONE = '#a4bede'; +export const COLOR_HAZARD_DROUGHT = '#b68fba'; +export const COLOR_HAZARD_FOOD_INSECURITY = '#b7c992'; +export const COLOR_HAZARD_FLOOD = '#5a80b0'; +export const COLOR_HAZARD_EARTHQUAKE = '#eca48c'; +export const COLOR_HAZARD_STORM = '#97b8c2'; +export const COLOR_HAZARD_WILDFIRE = '#ff5014'; + +// FIXME: should these constants satisfy an existing enum? +export const CATEGORY_RISK_VERY_LOW = 1; +export const CATEGORY_RISK_LOW = 2; +export const CATEGORY_RISK_MEDIUM = 3; +export const CATEGORY_RISK_HIGH = 4; +export const CATEGORY_RISK_VERY_HIGH = 5; + +// FIXME: should these constants satisfy an existing enum? +export const FORECAST_SEVERITY_UNKNOWN = 0; +export const FORECAST_SEVERITY_INFO = 1; +export const FORECAST_SEVERITY_WARNING = 2; +export const FORECAST_SEVERITY_DANGER = 3; + +// Colors + +export const COLOR_WHITE = '#ffffff'; +export const COLOR_TEXT = '#313131'; +export const COLOR_TEXT_ON_DARK = COLOR_WHITE; +export const COLOR_LIGHT_GREY = '#e0e0e0'; +export const COLOR_DARK_GREY = '#a5a5a5'; +export const COLOR_BLACK = '#000000'; +export const COLOR_LIGHT_YELLOW = '#ffd470'; +export const COLOR_YELLOW = '#ff9e00'; +export const COLOR_BLUE = '#4c5d9b'; +export const COLOR_LIGHT_BLUE = '#c7d3e0'; +export const COLOR_ORANGE = '#ff8000'; +export const COLOR_RED = '#f5333f'; +export const COLOR_DARK_RED = '#730413'; +export const COLOR_PRIMARY_BLUE = '#011e41'; +export const COLOR_PRIMARY_RED = '#f5333f'; + +// Three W + +type OperationTypeEnum = components<'read'>['schemas']['OperationTypeEnum']; +export const OPERATION_TYPE_PROGRAMME = 0 satisfies OperationTypeEnum; +export const OPERATION_TYPE_EMERGENCY = 1 satisfies OperationTypeEnum; +export const OPERATION_TYPE_MULTI = -1; + +type ProgrammeTypeEnum = components<'read'>['schemas']['Key1d2Enum']; +export const PROGRAMME_TYPE_MULTILATERAL = 1 satisfies ProgrammeTypeEnum; +export const PROGRAMME_TYPE_DOMESTIC = 2 satisfies ProgrammeTypeEnum; +export const PROGRAMME_TYPE_BILATERAL = 0 satisfies ProgrammeTypeEnum; + +type StatusTypeEnum = components<'read'>['schemas']['Key1d2Enum']; +export const PROJECT_STATUS_COMPLETED = 2 satisfies StatusTypeEnum; +export const PROJECT_STATUS_ONGOING = 1 satisfies StatusTypeEnum; +export const PROJECT_STATUS_PLANNED = 0 satisfies StatusTypeEnum; + +// DREF + +// FIXME: fix typing in server (medium priority) +// This should not be the same as OperationType. +type DrefStatus = components<'read'>['schemas']['OperationTypeEnum']; +export const DREF_STATUS_COMPLETED = 1 satisfies DrefStatus; +export const DREF_STATUS_IN_PROGRESS = 0 satisfies DrefStatus; +type TypeOfDrefEnum = components<'read'>['schemas']['TypeOfDrefEnum']; +export const DREF_TYPE_IMMINENT = 0 satisfies TypeOfDrefEnum; +export const DREF_TYPE_ASSESSMENT = 1 satisfies TypeOfDrefEnum; +export const DREF_TYPE_RESPONSE = 2 satisfies TypeOfDrefEnum; +export const DREF_TYPE_LOAN = 3 satisfies TypeOfDrefEnum; + +type TypeOfOnsetEnum = components<'read'>['schemas']['TypeValidatedEnum']; +export const ONSET_SLOW = 1 satisfies TypeOfOnsetEnum; + +// Subscriptions +type SubscriptionRecordTypeEnum = components<'read'>['schemas']['RtypeEnum']; +export const SUBSCRIPTION_SURGE_ALERT = 3 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_COUNTRY = 4 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_REGION = 5 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_DISASTER_TYPE = 6 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_PER_DUE_DATE = 7 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_FOLLOWED_EVENTS = 8 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_SURGE_DEPLOYMENT_MESSAGES = 9 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_WEEKLY_DIGEST = 11 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_NEW_EMERGENCIES = 12 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_NEW_OPERATIONS = 13 satisfies SubscriptionRecordTypeEnum; +export const SUBSCRIPTION_GENERAL = 14 satisfies SubscriptionRecordTypeEnum; + +// Field Report + +export type FieldReportStatusEnum = components<'read'>['schemas']['StatusBb2Enum']; +export const FIELD_REPORT_STATUS_EARLY_WARNING = 8 satisfies FieldReportStatusEnum; +export const FIELD_REPORT_STATUS_EVENT = 9 satisfies FieldReportStatusEnum; + +export type Bulletin = components<'read'>['schemas']['BulletinEnum']; +export const BULLETIN_PUBLISHED_NO = 0 satisfies Bulletin; +export const BULLETIN_PUBLISHED_PLANNED = 2 satisfies Bulletin; +export const BULLETIN_PUBLISHED_YES = 3 satisfies Bulletin; + +type RequestChoices = components<'read'>['schemas']['Key02bEnum']; +export const REQUEST_CHOICES_NO = 0 satisfies RequestChoices; + +export type ContactType = 'Originator' | 'NationalSociety' | 'Federation' | 'Media'; +export type OrganizationType = components<'read'>['schemas']['Key1aeEnum']; +export type ReportType = components<'read'>['schemas']['FieldReportTypesEnum']; +export type CategoryType = components<'read'>['schemas']['KeyA87Enum']; + +// Common + +// FIXME: we need to identify a typesafe way to get this value +export const DISASTER_TYPE_EPIDEMIC = 1; + +export type Visibility = components<'read'>['schemas']['VisibilityD1bEnum']; +export const VISIBILITY_RCRC_MOVEMENT = 1 satisfies Visibility; +export const VISIBILITY_IFRC_SECRETARIAT = 2 satisfies Visibility; +export const VISIBILITY_PUBLIC = 3 satisfies Visibility; +export const VISIBILITY_IFRC_NS = 4 satisfies Visibility; + +export type DisasterCategory = components<'read'>['schemas']['DisasterCategoryEnum']; +export const DISASTER_CATEGORY_YELLOW = 0 satisfies DisasterCategory; +export const DISASTER_CATEGORY_ORANGE = 1 satisfies DisasterCategory; +export const DISASTER_CATEGORY_RED = 2 satisfies DisasterCategory; + +export const COUNTRY_AMERICAS_REGION = 282; +export const COUNTRY_ASIA_REGION = 283; +export const COUNTRY_AFRICA_REGION = 285; +export const COUNTRY_EUROPE_REGION = 286; +export const COUNTRY_MENA_REGION = 287; + +export type Region = components<'read'>['schemas']['Key86cEnum']; +export const REGION_AFRICA = 0 satisfies Region; +export const REGION_AMERICAS = 1 satisfies Region; +export const REGION_ASIA = 2 satisfies Region; +export const REGION_EUROPE = 3 satisfies Region; +export const REGION_MENA = 4 satisfies Region; + +type CountryRecordTypeEnum = components<'read'>['schemas']['RecordTypeEnum']; +export const COUNTRY_RECORD_TYPE_COUNTRY = 1 satisfies CountryRecordTypeEnum; +export const COUNTRY_RECORD_TYPE_CLUSTER = 2 satisfies CountryRecordTypeEnum; +export const COUNTRY_RECORD_TYPE_REGION = 3 satisfies CountryRecordTypeEnum; +export const COUNTRY_RECORD_TYPE_COUNTRY_OFFICE = 4 satisfies CountryRecordTypeEnum; +export const COUNTRY_RECORD_TYPE_REPRESENTATIVE_OFFICE = 5 satisfies CountryRecordTypeEnum; diff --git a/src/components/Table/ColumnShortcuts/CountryLink/index.tsx b/app/src/utils/domain/CountryLink/index.tsx similarity index 100% rename from src/components/Table/ColumnShortcuts/CountryLink/index.tsx rename to app/src/utils/domain/CountryLink/index.tsx diff --git a/src/components/Table/ColumnShortcuts/RegionLink/index.tsx b/app/src/utils/domain/RegionLink/index.tsx similarity index 100% rename from src/components/Table/ColumnShortcuts/RegionLink/index.tsx rename to app/src/utils/domain/RegionLink/index.tsx diff --git a/src/utils/domain/country.ts b/app/src/utils/domain/country.ts similarity index 99% rename from src/utils/domain/country.ts rename to app/src/utils/domain/country.ts index 499dd7c6d..8e8f88373 100644 --- a/src/utils/domain/country.ts +++ b/app/src/utils/domain/country.ts @@ -1,17 +1,18 @@ +import { isNotDefined } from '@togglecorp/fujs'; + import { COUNTRY_AFRICA_REGION, COUNTRY_AMERICAS_REGION, COUNTRY_ASIA_REGION, COUNTRY_EUROPE_REGION, COUNTRY_MENA_REGION, + type Region, REGION_AFRICA, REGION_AMERICAS, REGION_ASIA, REGION_EUROPE, REGION_MENA, - type Region, } from '#utils/constants'; -import { isNotDefined } from '@togglecorp/fujs'; // eslint-disable-next-line import/prefer-default-export export const countryIdToRegionIdMap: Record = { diff --git a/src/utils/domain/dref.ts b/app/src/utils/domain/dref.ts similarity index 100% rename from src/utils/domain/dref.ts rename to app/src/utils/domain/dref.ts diff --git a/src/utils/domain/emergency.ts b/app/src/utils/domain/emergency.ts similarity index 92% rename from src/utils/domain/emergency.ts rename to app/src/utils/domain/emergency.ts index 177a76721..9193d0cf9 100644 --- a/src/utils/domain/emergency.ts +++ b/app/src/utils/domain/emergency.ts @@ -1,5 +1,6 @@ +import { sumSafe } from '@ifrc-go/ui/utils'; import { max } from '@togglecorp/fujs'; -import { sumSafe } from '#utils/common'; + import { type GoApiResponse } from '#utils/restRequest'; type EventResponse = GoApiResponse<'/api/v2/event/'>; diff --git a/src/utils/domain/per.ts b/app/src/utils/domain/per.ts similarity index 93% rename from src/utils/domain/per.ts rename to app/src/utils/domain/per.ts index 893ae7ad6..c11af214f 100644 --- a/src/utils/domain/per.ts +++ b/app/src/utils/domain/per.ts @@ -1,6 +1,10 @@ -import { isNotDefined, isDefined } from '@togglecorp/fujs'; -import { type GoApiResponse } from '#utils/restRequest'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + import { type components } from '#generated/types'; +import { type GoApiResponse } from '#utils/restRequest'; type PerPhase = components<'read'>['schemas']['PhaseEnum']; diff --git a/app/src/utils/domain/risk.test.ts b/app/src/utils/domain/risk.test.ts new file mode 100644 index 000000000..89fa34c9b --- /dev/null +++ b/app/src/utils/domain/risk.test.ts @@ -0,0 +1,26 @@ +import { + expect, + test, +} from 'vitest'; + +import { + CATEGORY_RISK_LOW, + CATEGORY_RISK_VERY_LOW, +} from '#utils/constants'; + +import { riskScoreToCategory } from './risk.ts'; + +test('Risk score to category', () => { + expect( + riskScoreToCategory( + 0, + 'FL', + ), + ).toEqual(CATEGORY_RISK_VERY_LOW); + expect( + riskScoreToCategory( + 3, + 'FL', + ), + ).toEqual(CATEGORY_RISK_LOW); +}); diff --git a/src/utils/domain/risk.ts b/app/src/utils/domain/risk.ts similarity index 99% rename from src/utils/domain/risk.ts rename to app/src/utils/domain/risk.ts index f20534a6a..95dc07cf1 100644 --- a/src/utils/domain/risk.ts +++ b/app/src/utils/domain/risk.ts @@ -1,3 +1,8 @@ +import { + avgSafe, + maxSafe, + sumSafe, +} from '@ifrc-go/ui/utils'; import { compareNumber, isDefined, @@ -9,14 +14,12 @@ import { unique, } from '@togglecorp/fujs'; -import { type RiskApiResponse } from '#utils/restRequest'; import { type components } from '#generated/riskTypes'; -import { sumSafe, maxSafe, avgSafe } from '#utils/common'; import { CATEGORY_RISK_HIGH, - CATEGORY_RISK_VERY_HIGH, - CATEGORY_RISK_MEDIUM, CATEGORY_RISK_LOW, + CATEGORY_RISK_MEDIUM, + CATEGORY_RISK_VERY_HIGH, CATEGORY_RISK_VERY_LOW, COLOR_HAZARD_CYCLONE, COLOR_HAZARD_DROUGHT, @@ -27,6 +30,7 @@ import { COLOR_HAZARD_WILDFIRE, COLOR_LIGHT_GREY, } from '#utils/constants'; +import { type RiskApiResponse } from '#utils/restRequest'; export type HazardType = components<'read'>['schemas']['HazardTypeEnum']; type IpcEstimationType = components<'read'>['schemas']['EstimationTypeEnum']; diff --git a/app/src/utils/domain/tableHelpers.ts b/app/src/utils/domain/tableHelpers.ts new file mode 100644 index 000000000..226e95bc9 --- /dev/null +++ b/app/src/utils/domain/tableHelpers.ts @@ -0,0 +1,176 @@ +import { + type Column, + HeaderCell, + type HeaderCellProps, + ReducedListDisplay, + type ReducedListDisplayProps, + type SortDirection, + TableActionsProps, +} from '@ifrc-go/ui'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; + +import Link, { type Props as LinkProps } from '#components/Link'; +import { type GoApiResponse } from '#utils/restRequest'; + +import type { Props as CountryLinkProps } from './CountryLink'; +import CountryLink from './CountryLink'; +import type { Props as RegionLinkProps } from './RegionLink'; +import RegionLink from './RegionLink'; + +type Options = { + sortable?: boolean, + defaultSortDirection?: SortDirection, + + columnClassName?: string; + headerCellRendererClassName?: string; + headerContainerClassName?: string; + cellRendererClassName?: string; + cellContainerClassName?: string; + columnWidth?: Column['columnWidth']; + columnStretch?: Column['columnStretch']; + columnStyle?: Column['columnStyle']; + + headerInfoTitle?: HeaderCellProps['infoTitle']; + headerInfoDescription?: HeaderCellProps['infoDescription']; +} + +export function createLinkColumn( + id: string, + title: string, + accessor: (item: D) => React.ReactNode, + rendererParams: (item: D) => LinkProps, + options?: Options, +) { + const item: Column & { + valueSelector: (item: D) => string | undefined | null, + valueComparator: (foo: D, bar: D) => number, + } = { + id, + title, + headerCellRenderer: HeaderCell, + headerCellRendererParams: { + sortable: options?.sortable, + infoTitle: options?.headerInfoTitle, + infoDescription: options?.headerInfoDescription, + }, + cellRenderer: Link, + cellRendererParams: (_: K, datum: D): LinkProps => ({ + children: accessor(datum) || '--', + withUnderline: true, + ...rendererParams(datum), + }), + valueSelector: () => '', + valueComparator: () => 0, + cellRendererClassName: options?.cellRendererClassName, + columnClassName: options?.columnClassName, + headerCellRendererClassName: options?.headerCellRendererClassName, + cellContainerClassName: options?.cellContainerClassName, + columnWidth: options?.columnWidth, + columnStretch: options?.columnStretch, + columnStyle: options?.columnStyle, + }; + + return item; +} + +type CountryResponse = GoApiResponse<'/api/v2/country/'>; +type CountryListItem = NonNullable[number]; +type PartialCountry = Pick; + +type RegionListResponse = GoApiResponse<'/api/v2/region/'>; +type RegionListItem = NonNullable[number]; +type PartialRegion = Pick; + +const countryLinkRendererParams = (country: PartialCountry) => ({ + id: country.id, + name: country.name ?? '?', +}); + +const regionLinkRendererParams = (region: PartialRegion) => ({ + id: region.id, + name: region.region_name ?? '', +}); + +export function createCountryListColumn( + id: string, + title: string, + countryListSelector: (datum: DATUM) => PartialCountry[] | undefined, + options?: Options, +) { + const item: Column< + DATUM, + KEY, + ReducedListDisplayProps, + HeaderCellProps + > = { + id, + title, + headerCellRenderer: HeaderCell, + headerCellRendererParams: { + sortable: false, + }, + headerContainerClassName: options?.headerContainerClassName, + cellRenderer: ReducedListDisplay, + cellRendererParams: (_, datum) => { + const countryList = countryListSelector(datum); + + return { + list: countryList, + renderer: CountryLink, + keySelector: numericIdSelector, + rendererParams: countryLinkRendererParams, + }; + }, + cellRendererClassName: options?.cellRendererClassName, + columnClassName: options?.columnClassName, + headerCellRendererClassName: options?.headerCellRendererClassName, + cellContainerClassName: options?.cellContainerClassName, + columnWidth: options?.columnWidth, + columnStretch: options?.columnStretch, + columnStyle: options?.columnStyle, + }; + + return item; +} + +export function createRegionListColumn( + id: string, + title: string, + regionListSelector: (datum: DATUM) => PartialRegion[] | undefined, + options?: Options, +) { + const item: Column< + DATUM, + KEY, + ReducedListDisplayProps, + HeaderCellProps + > = { + id, + title, + headerCellRenderer: HeaderCell, + headerCellRendererParams: { + sortable: false, + }, + headerContainerClassName: options?.headerContainerClassName, + cellRenderer: ReducedListDisplay, + cellRendererParams: (_, datum) => { + const regionList = regionListSelector(datum); + + return { + list: regionList, + renderer: RegionLink, + keySelector: numericIdSelector, + rendererParams: regionLinkRendererParams, + }; + }, + cellRendererClassName: options?.cellRendererClassName, + columnClassName: options?.columnClassName, + headerCellRendererClassName: options?.headerCellRendererClassName, + cellContainerClassName: options?.cellContainerClassName, + columnWidth: options?.columnWidth, + columnStretch: options?.columnStretch, + columnStyle: options?.columnStyle, + }; + + return item; +} diff --git a/src/utils/domain/user.ts b/app/src/utils/domain/user.ts similarity index 90% rename from src/utils/domain/user.ts rename to app/src/utils/domain/user.ts index 86f9e992b..852ce433a 100644 --- a/src/utils/domain/user.ts +++ b/app/src/utils/domain/user.ts @@ -1,4 +1,7 @@ -import { isNotDefined, isTruthyString } from '@togglecorp/fujs'; +import { + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; interface User { first_name: string | undefined | null; diff --git a/src/utils/form.ts b/app/src/utils/form.ts similarity index 100% rename from src/utils/form.ts rename to app/src/utils/form.ts index 98078565b..e08310983 100644 --- a/src/utils/form.ts +++ b/app/src/utils/form.ts @@ -1,8 +1,8 @@ +import type { Maybe } from '@togglecorp/fujs'; import { - isInteger, isDefined, + isInteger, } from '@togglecorp/fujs'; -import type { Maybe } from '@togglecorp/fujs'; function isNumber(value: unknown): value is number { return typeof value === 'number'; diff --git a/src/utils/localStorage.ts b/app/src/utils/localStorage.ts similarity index 100% rename from src/utils/localStorage.ts rename to app/src/utils/localStorage.ts diff --git a/src/utils/map.ts b/app/src/utils/map.ts similarity index 100% rename from src/utils/map.ts rename to app/src/utils/map.ts index 2805ff60d..b22616ead 100644 --- a/src/utils/map.ts +++ b/app/src/utils/map.ts @@ -1,22 +1,22 @@ +import getBbox from '@turf/bbox'; import type { FillLayer, - SymbolLayer, - NavigationControl, Map, + NavigationControl, + SymbolLayer, } from 'mapbox-gl'; -import getBbox from '@turf/bbox'; +import { type Country } from '#hooks/domain/useCountryRaw'; import { COLOR_BLUE, - COLOR_RED, + COLOR_DARK_GREY, + COLOR_LIGHT_GREY, COLOR_ORANGE, + COLOR_RED, OPERATION_TYPE_EMERGENCY, OPERATION_TYPE_MULTI, OPERATION_TYPE_PROGRAMME, - COLOR_LIGHT_GREY, - COLOR_DARK_GREY, } from '#utils/constants'; -import { type Country } from '#hooks/domain/useCountryRaw'; export const defaultMapStyle = 'mapbox://styles/go-ifrc/ckrfe16ru4c8718phmckdfjh0'; type NavControlOptions = NonNullable[0]>; diff --git a/src/utils/outletContext.ts b/app/src/utils/outletContext.ts similarity index 100% rename from src/utils/outletContext.ts rename to app/src/utils/outletContext.ts diff --git a/src/utils/resolveUrl.ts b/app/src/utils/resolveUrl.ts similarity index 100% rename from src/utils/resolveUrl.ts rename to app/src/utils/resolveUrl.ts diff --git a/src/utils/restRequest/error.ts b/app/src/utils/restRequest/error.ts similarity index 100% rename from src/utils/restRequest/error.ts rename to app/src/utils/restRequest/error.ts diff --git a/src/utils/restRequest/go.ts b/app/src/utils/restRequest/go.ts similarity index 98% rename from src/utils/restRequest/go.ts rename to app/src/utils/restRequest/go.ts index 192751fc2..a3330dd8c 100644 --- a/src/utils/restRequest/go.ts +++ b/app/src/utils/restRequest/go.ts @@ -1,3 +1,4 @@ +import { type Language } from '@ifrc-go/ui/contexts'; import { isDefined, isFalsyString, @@ -5,15 +6,17 @@ import { } from '@togglecorp/fujs'; import { ContextInterface } from '@togglecorp/toggle-request'; -import { KEY_LANGUAGE_STORAGE, KEY_USER_STORAGE } from '#utils/constants'; -import { getFromStorage } from '#utils/localStorage'; -import { resolveUrl } from '#utils/resolveUrl'; import { - riskApi, api, + riskApi, } from '#config'; import { type UserAuth } from '#contexts/user'; -import { type Language } from '#contexts/language'; +import { + KEY_LANGUAGE_STORAGE, + KEY_USER_STORAGE, +} from '#utils/constants'; +import { getFromStorage } from '#utils/localStorage'; +import { resolveUrl } from '#utils/resolveUrl'; import { type ResponseObjectError } from './error'; diff --git a/app/src/utils/restRequest/index.ts b/app/src/utils/restRequest/index.ts new file mode 100644 index 000000000..8dcb1abdf --- /dev/null +++ b/app/src/utils/restRequest/index.ts @@ -0,0 +1,72 @@ +import { + RequestContext, + useLazyRequest, + useRequest, +} from '@togglecorp/toggle-request'; + +import type { paths as riskApiPaths } from '#generated/riskTypes'; +import type { paths as goApiPaths } from '#generated/types'; + +import type { + ApiBody, + ApiResponse, + ApiUrlQuery, + CustomLazyRequestOptions, + CustomLazyRequestReturn, + CustomRequestOptions, + CustomRequestReturn, + VALID_METHOD, +} from './overrideTypes'; + +export type GoApiResponse = ApiResponse; +export type GoApiUrlQuery = ApiUrlQuery +export type GoApiBody = ApiBody +export type RiskApiResponse = ApiResponse; +export type RiskApiUrlQuery = ApiUrlQuery +export type RiskApiBody = ApiBody + +export type ListResponseItem +} | undefined> = NonNullable['results']>[number]; + +// FIXME: identify a way to do this without a cast +const useGoRequest = useRequest as < + PATH extends keyof goApiPaths, + METHOD extends VALID_METHOD | undefined = 'GET', +>( + requestOptions: CustomRequestOptions +) => CustomRequestReturn; + +// FIXME: identify a way to do this without a cast +const useGoLazyRequest = useLazyRequest as < + PATH extends keyof goApiPaths, + CONTEXT = unknown, + METHOD extends VALID_METHOD | undefined = 'GET', +>( + requestOptions: CustomLazyRequestOptions +) => CustomLazyRequestReturn; + +// FIXME: identify a way to do this without a cast +const useRiskRequest = useRequest as < + PATH extends keyof riskApiPaths, + METHOD extends VALID_METHOD | undefined = 'GET', +>( + requestOptions: CustomRequestOptions & { apiType: 'risk' }, +) => CustomRequestReturn; + +// FIXME: identify a way to do this without a cast +const useRiskLazyRequest = useLazyRequest as < + PATH extends keyof riskApiPaths, + CONTEXT = unknown, + METHOD extends VALID_METHOD | undefined = 'GET', +>( + requestOptions: CustomLazyRequestOptions & { apiType: 'risk' } +) => CustomLazyRequestReturn; + +export { + RequestContext, + useGoLazyRequest as useLazyRequest, + useGoRequest as useRequest, + useRiskLazyRequest, + useRiskRequest, +}; diff --git a/src/utils/restRequest/overrideTypes.ts b/app/src/utils/restRequest/overrideTypes.ts similarity index 99% rename from src/utils/restRequest/overrideTypes.ts rename to app/src/utils/restRequest/overrideTypes.ts index 58ffe5ff6..85343d986 100644 --- a/src/utils/restRequest/overrideTypes.ts +++ b/app/src/utils/restRequest/overrideTypes.ts @@ -1,14 +1,14 @@ +import { type DeepNevaRemove } from '@ifrc-go/ui/utils'; import { - RequestOptions, LazyRequestOptions, - useRequest, + RequestOptions, useLazyRequest, + useRequest, } from '@togglecorp/toggle-request'; -import { type DeepNevaRemove } from '#utils/common'; import { - TransformedError, AdditionalOptions, + TransformedError, } from './go'; export type VALID_METHOD = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; diff --git a/src/utils/routes.test.tsx b/app/src/utils/routes.test.tsx similarity index 89% rename from src/utils/routes.test.tsx rename to app/src/utils/routes.test.tsx index 28139247f..463352a85 100644 --- a/src/utils/routes.test.tsx +++ b/app/src/utils/routes.test.tsx @@ -1,5 +1,12 @@ -import { expect, test } from 'vitest'; -import { trimChar, joinUrlPart } from './routes.tsx'; +import { + expect, + test, +} from 'vitest'; + +import { + joinUrlPart, + trimChar, +} from './routes.tsx'; // Edit an assertion and save to see HMR in action diff --git a/src/utils/routes.tsx b/app/src/utils/routes.tsx similarity index 100% rename from src/utils/routes.tsx rename to app/src/utils/routes.tsx index 5dccdeaca..a941984c2 100644 --- a/src/utils/routes.tsx +++ b/app/src/utils/routes.tsx @@ -1,15 +1,15 @@ -import { - listToMap, - mapToList, - randomString, - isNotDefined, - isDefined, -} from '@togglecorp/fujs'; import { IndexRouteObject, NonIndexRouteObject, RouteObject, } from 'react-router-dom'; +import { + isDefined, + isNotDefined, + listToMap, + mapToList, + randomString, +} from '@togglecorp/fujs'; export function trimChar(str: string, char: string) { let op = str; diff --git a/src/views/Account/i18n.json b/app/src/views/Account/i18n.json similarity index 100% rename from src/views/Account/i18n.json rename to app/src/views/Account/i18n.json diff --git a/app/src/views/Account/index.tsx b/app/src/views/Account/index.tsx new file mode 100644 index 000000000..4b9ef0da2 --- /dev/null +++ b/app/src/views/Account/index.tsx @@ -0,0 +1,58 @@ +import { useContext } from 'react'; +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 WikiLink from '#components/WikiLink'; +import UserContext from '#contexts/user'; +import useUserMe from '#hooks/domain/useUserMe'; +import { getUserName } from '#utils/domain/user'; + +import i18n from './i18n.json'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { userAuth: userDetails } = useContext(UserContext); + const userMe = useUserMe(); + + return ( + + )} + heading={ + userMe + ? getUserName(userMe) + : userDetails?.displayName ?? '--' + } + > + + + {strings.accountDetailsTabTitle} + + + {strings.accountMyFormTabTitle} + + + {strings.accountNotificationTabTitle} + + + + + ); +} + +Component.displayName = 'Account'; diff --git a/src/views/AccountDetails/ChangePassword/i18n.json b/app/src/views/AccountDetails/ChangePassword/i18n.json similarity index 100% rename from src/views/AccountDetails/ChangePassword/i18n.json rename to app/src/views/AccountDetails/ChangePassword/i18n.json diff --git a/app/src/views/AccountDetails/ChangePassword/index.tsx b/app/src/views/AccountDetails/ChangePassword/index.tsx new file mode 100644 index 000000000..8561b5fb8 --- /dev/null +++ b/app/src/views/AccountDetails/ChangePassword/index.tsx @@ -0,0 +1,224 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Button, + Modal, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isTruthyString } from '@togglecorp/fujs'; +import { + addCondition, + createSubmitHandler, + getErrorObject, + ObjectSchema, + PartialForm, + requiredStringCondition, + undefinedValue, + useForm, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import useAlert from '#hooks/useAlert'; +import { + GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; +import { transformObjectError } from '#utils/restRequest/error'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type PasswordChangeRequestBody = GoApiBody<'/change_password', 'POST'>; + +type PartialFormValue = PartialForm; +const defaultFormValue: PartialFormValue = {}; + +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +interface Props { + handleModalCloseButton: () => void; +} + +function ChangePasswordModal(props: Props) { + const { + handleModalCloseButton, + } = props; + + const strings = useTranslation(i18n); + const alert = useAlert(); + + const getPasswordMatchCondition = useCallback((referenceVal: string | undefined) => { + function passwordMatchCondition(val: string | undefined) { + if (isTruthyString(val) && isTruthyString(referenceVal) && val !== referenceVal) { + return strings.changePasswordDoNotMatch; + } + return undefined; + } + return passwordMatchCondition; + }, [ + strings.changePasswordDoNotMatch, + ]); + + const formSchema: FormSchema = useMemo(() => ( + { + fields: (value): FormSchemaFields => { + let fields: FormSchemaFields = { + old_password: { + required: true, + requiredValidation: requiredStringCondition, + }, + new_password: { + required: true, + requiredValidation: requiredStringCondition, + }, + confirmNewPassword: { + required: true, + requiredValidation: requiredStringCondition, + forceValue: undefinedValue, + }, + }; + + fields = addCondition( + fields, + value, + ['new_password'], + ['confirmNewPassword'], + (val) => ({ + confirmNewPassword: { + required: true, + requiredValidation: requiredStringCondition, + forceValue: undefinedValue, + validations: [getPasswordMatchCondition(val?.new_password)], + }, + }), + ); + return fields; + }, + } + ), [getPasswordMatchCondition]); + + const { + value: formValue, + error: formError, + setFieldValue, + setError, + validate, + } = useForm(formSchema, { value: defaultFormValue }); + + const { + pending: updatePasswordPending, + trigger: updatePassword, + } = useLazyRequest({ + method: 'POST', + url: '/change_password', + body: (body: PasswordChangeRequestBody) => body, + onSuccess: () => { + alert.show( + strings.changePasswordSuccessMessage, + { variant: 'success' }, + ); + handleModalCloseButton(); + }, + onFailure: (error) => { + const { + value: { + formErrors, + }, + } = error; + + setError(transformObjectError(formErrors, () => undefined)); + + alert.show( + strings.changePasswordFailureMessage, + { variant: 'danger' }, + ); + }, + }); + + const handleConfirmPasswordChange = useCallback((formValues: PartialFormValue) => { + const passwordFormValues = { + ...formValues, + }; + updatePassword(passwordFormValues as PasswordChangeRequestBody); + }, [updatePassword]); + + const handleSubmitPassword = createSubmitHandler( + validate, + setError, + handleConfirmPasswordChange, + ); + + const fieldError = getErrorObject(formError); + + return ( + + + + + )} + childrenContainerClassName={styles.content} + > + + + + + + ); +} + +export default ChangePasswordModal; diff --git a/src/views/AccountDetails/ChangePassword/styles.module.css b/app/src/views/AccountDetails/ChangePassword/styles.module.css similarity index 100% rename from src/views/AccountDetails/ChangePassword/styles.module.css rename to app/src/views/AccountDetails/ChangePassword/styles.module.css diff --git a/src/views/AccountDetails/EditAccountInfo/i18n.json b/app/src/views/AccountDetails/EditAccountInfo/i18n.json similarity index 100% rename from src/views/AccountDetails/EditAccountInfo/i18n.json rename to app/src/views/AccountDetails/EditAccountInfo/i18n.json diff --git a/app/src/views/AccountDetails/EditAccountInfo/index.tsx b/app/src/views/AccountDetails/EditAccountInfo/index.tsx new file mode 100644 index 000000000..65f362666 --- /dev/null +++ b/app/src/views/AccountDetails/EditAccountInfo/index.tsx @@ -0,0 +1,321 @@ +import { + useCallback, + useContext, +} from 'react'; +import { + Button, + Modal, + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { stringValueSelector } from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, +} from '@togglecorp/fujs'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + requiredStringCondition, + useForm, + useFormObject, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; +import DomainContext from '#contexts/domain'; +import UserContext from '#contexts/user'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useNationalSociety, { type NationalSociety } from '#hooks/domain/useNationalSociety'; +import useAlert from '#hooks/useAlert'; +import { + type GoApiBody, + type GoApiResponse, + useLazyRequest, +} from '#utils/restRequest'; +import { transformObjectError } from '#utils/restRequest/error'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type UserMeResponse = GoApiResponse<'/api/v2/user/me/'>; +type AccountRequestBody = GoApiBody<'/api/v2/user/{id}/', 'PATCH'>; +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; +type OrganizationTypeOption = { + key: NonNullable[number]['key']; + value: string; +} + +const organizationTypeKeySelector = (item: OrganizationTypeOption) => item.key; +const nsLabelSelector = (item: NationalSociety) => item.society_name; + +type PartialFormFields = PartialForm; + +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType + +type ProfileSchema = ObjectSchema; +type ProfileSchemaFields = ReturnType + +const formSchema: FormSchema = { + fields: (): FormSchemaFields => ({ + first_name: { + required: true, + requiredValidation: requiredStringCondition, + }, + last_name: { + required: true, + requiredValidation: requiredStringCondition, + }, + profile: { + fields: (): ProfileSchemaFields => ({ + org: { + // FIXME: Server does not accept null as empty value + defaultValue: '', + }, + org_type: { + // FIXME: Server does not accept null as empty value + defaultValue: '' as never, + }, + city: {}, + department: {}, + position: {}, + phone_number: {}, + }), + }, + }), +}; + +// FIXME: move this to utils +function clearEmptyString(value: T | null | undefined) { + if (isFalsyString(value)) { + return undefined; + } + return value; +} + +interface Props { + userDetails: UserMeResponse | undefined; + handleModalCloseButton: () => void; +} + +function EditAccountInfo(props: Props) { + const { + userDetails, + handleModalCloseButton, + } = props; + + const defaultFormValue: PartialFormFields = { + first_name: userDetails?.first_name, + last_name: userDetails?.last_name, + profile: { + city: userDetails?.profile?.city, + // FIXME: The server sends empty string as org_type + org_type: clearEmptyString(userDetails?.profile?.org_type), + // FIXME: The server sends empty string as org + org: clearEmptyString(userDetails?.profile?.org), + department: userDetails?.profile?.department, + phone_number: userDetails?.profile?.phone_number, + }, + }; + + const strings = useTranslation(i18n); + const alert = useAlert(); + const { userAuth } = useContext(UserContext); + const { invalidate } = useContext(DomainContext); + const { api_profile_org_types: organizationTypeOptions } = useGlobalEnums(); + + const { + value: formValue, + error: formError, + setFieldValue, + setError, + validate, + } = useForm(formSchema, { value: defaultFormValue }); + + const setProfileFieldValue = useFormObject<'profile', NonNullable>( + 'profile' as const, + setFieldValue, + {}, + ); + + const { + pending: updateAccountPending, + trigger: updateAccountInfo, + } = useLazyRequest({ + method: 'PATCH', + url: '/api/v2/user/{id}/', + pathVariables: userAuth && isDefined(userAuth.id) + ? { id: userAuth.id } + : undefined, + body: (body: AccountRequestBody) => body, + onSuccess: () => { + alert.show( + strings.editAccoutSuccessfulMessage, + { variant: 'success' }, + ); + invalidate('user-me'); + handleModalCloseButton(); + }, + onFailure: (error) => { + const { + value: { + formErrors, + }, + } = error; + + setError(transformObjectError(formErrors, () => undefined)); + + alert.show( + strings.editAccountFailureMessage, + { variant: 'danger' }, + ); + }, + }); + + const nationalSocietyOptions = useNationalSociety(); + + const handleConfirmProfileEdit = useCallback( + (formValues: PartialFormFields) => { + updateAccountInfo(formValues as AccountRequestBody); + }, + [updateAccountInfo], + ); + + const handleOrganizationNameChange = useCallback( + (val: OrganizationTypeOption['key'] | undefined) => { + setProfileFieldValue(val, 'org_type'); + if (val === 'NTLS') { + setProfileFieldValue(undefined, 'org'); + } + }, + [setProfileFieldValue], + ); + + const handleFormSubmit = createSubmitHandler(validate, setError, handleConfirmProfileEdit); + + const fieldError = getErrorObject(formError); + + const isNationalSociety = formValue.profile?.org_type === 'NTLS'; + + const profileError = getErrorObject(fieldError?.profile); + + return ( + + + + + )} + childrenContainerClassName={styles.content} + > + + + + + + + {isNationalSociety ? ( + + ) : ( + + )} + + + + ); +} + +export default EditAccountInfo; diff --git a/src/views/AccountDetails/EditAccountInfo/styles.module.css b/app/src/views/AccountDetails/EditAccountInfo/styles.module.css similarity index 100% rename from src/views/AccountDetails/EditAccountInfo/styles.module.css rename to app/src/views/AccountDetails/EditAccountInfo/styles.module.css diff --git a/src/views/AccountDetails/i18n.json b/app/src/views/AccountDetails/i18n.json similarity index 100% rename from src/views/AccountDetails/i18n.json rename to app/src/views/AccountDetails/i18n.json diff --git a/app/src/views/AccountDetails/index.tsx b/app/src/views/AccountDetails/index.tsx new file mode 100644 index 000000000..7216db3c5 --- /dev/null +++ b/app/src/views/AccountDetails/index.tsx @@ -0,0 +1,136 @@ +import { + useCallback, + useState, +} from 'react'; +import { PencilFillIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import useUserMe from '#hooks/domain/useUserMe'; + +import ChangePasswordModal from './ChangePassword'; +import EditAccountInfo from './EditAccountInfo'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const [showEditProfileModal, setShowEditProfileModal] = useState(false); + const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); + + const meResponse = useUserMe(); + + const onEditProfileCancel = useCallback(() => { + setShowEditProfileModal(false); + }, []); + + const onCancelPasswordChange = useCallback(() => { + setShowChangePasswordModal(false); + }, []); + + return ( +
+ + + + + )} + > +
+ + + + + + + + + +
+
+ {showEditProfileModal && ( + + )} + {showChangePasswordModal && ( + + )} +
+ ); +} + +Component.displayName = 'AccountInformation'; diff --git a/src/views/AccountDetails/styles.module.css b/app/src/views/AccountDetails/styles.module.css similarity index 100% rename from src/views/AccountDetails/styles.module.css rename to app/src/views/AccountDetails/styles.module.css diff --git a/src/views/AccountMyFormsDref/ActiveDrefTable/i18n.json b/app/src/views/AccountMyFormsDref/ActiveDrefTable/i18n.json similarity index 100% rename from src/views/AccountMyFormsDref/ActiveDrefTable/i18n.json rename to app/src/views/AccountMyFormsDref/ActiveDrefTable/i18n.json diff --git a/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx b/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx new file mode 100644 index 000000000..3d206e5e2 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/ActiveDrefTable/index.tsx @@ -0,0 +1,381 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + Pager, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { type RowOptions } 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 { + _cs, + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +import useUserMe from '#hooks/domain/useUserMe'; +import useFilterState from '#hooks/useFilterState'; +import { useRequest } from '#utils/restRequest'; + +import DrefTableActions, { type Props as DrefTableActionsProps } from '../DrefTableActions'; +import Filters, { type FilterValue } from '../Filters'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const NUM_ITEMS_PER_PAGE = 6; + +interface Props { + className?: string; + actions?: React.ReactNode; +} + +function ActiveDrefTable(props: Props) { + const { + className, + actions, + } = props; + + const strings = useTranslation(i18n); + const { + page, + setPage, + rawFilter, + filter, + filtered, + setFilterField, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 6, + }); + + const { + response: activeDrefResponse, + pending: activeDrefResponsePending, + retrigger: refetchActiveDref, + } = useRequest({ + url: '/api/v2/active-dref/', + preserveResponse: true, + query: { + offset, + limit, + // FIXME server should accept country + country: isDefined(filter.country) ? [filter.country] : undefined, + type_of_dref: isDefined(filter.type_of_dref) ? [filter.type_of_dref] : undefined, + disaster_type: filter.disaster_type, + appeal_code: filter.appeal_code, + }, + }); + + const userMe = useUserMe(); + const userRegionCoordinatorMap = useMemo( + () => { + if ( + isNotDefined(userMe) + || isNotDefined(userMe.is_dref_coordinator_for_regions) + || userMe.is_dref_coordinator_for_regions.length === 0 + ) { + return undefined; + } + + return listToMap( + userMe.is_dref_coordinator_for_regions, + (region) => region, + () => true, + ); + }, + [userMe], + ); + + type DrefItem = NonNullable['results']>[number]; + type Key = DrefItem['id']; + + const getLatestStageOfDref = useCallback( + (dref: DrefItem) => { + const { + final_report_details, + operational_update_details, + has_ops_update, + has_final_report, + } = dref; + + if (has_final_report) { + const finalReportList = final_report_details; + return finalReportList[0]; + } + + if (has_ops_update) { + const opsUpdateList = operational_update_details; + return opsUpdateList[0]; + } + + return dref; + }, + [], + ); + + const latestDrefs = useMemo( + () => activeDrefResponse?.results?.map(getLatestStageOfDref), + [activeDrefResponse, getLatestStageOfDref], + ); + + type LatestDref = NonNullable[number]; + + const latestDrefToOriginalMap = useMemo( + () => listToMap( + activeDrefResponse?.results ?? [], + (dref) => { + const val = getLatestStageOfDref(dref); + return val.id; + }, + ), + [activeDrefResponse, getLatestStageOfDref], + ); + + const [expandedRow, setExpandedRow] = useState(); + const handleExpandClick = useCallback( + (row: LatestDref) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const baseColumns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.activeDrefTableCreatedHeading, + (item) => item.created_at, + { columnClassName: styles.date }, + ), + createStringColumn( + 'appeal_code', + strings.activeDrefTableAppealCodeHeading, + (item) => item.appeal_code, + { columnClassName: styles.appealCode }, + ), + createStringColumn( + 'title', + strings.activeDrefTableTitleHeading, + (item) => item.title, + { columnClassName: styles.title }, + ), + createStringColumn( + 'type', + strings.activeDrefTableStageHeading, + (item) => item.application_type_display, + { columnClassName: styles.stage }, + ), + createStringColumn( + 'country', + strings.activeDrefTableCountryHeading, + (item) => item.country_details?.name, + ), + createStringColumn( + 'type_of_dref', + strings.activeDrefTableTypeOfDrefHeading, + (item) => item.type_of_dref_display, + ), + createStringColumn( + 'status', + strings.activeDrefTableStatusHeading, + (item) => item.status_display, + ), + createElementColumn( + 'actions', + '', + DrefTableActions, + (id, item) => { + const originalDref = latestDrefToOriginalMap[id]; + // FIXME: fix typing in server (medium priority) + // the application_type should be an enum + const applicationType = item.application_type as 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT'; + if (!originalDref) { + return { + id, + drefId: id, + status: item.status, + applicationType, + canAddOpsUpdate: false, + canCreateFinalReport: false, + }; + } + + const { + // unpublished_final_report_count, + unpublished_op_update_count, + is_published, + has_ops_update, + has_final_report, + country_details, + } = originalDref; + + const canAddOpsUpdate = (is_published ?? false) + && (applicationType === 'DREF' || applicationType === 'OPS_UPDATE') + && !has_final_report + && unpublished_op_update_count === 0; + + const canCreateFinalReport = !has_final_report + && (applicationType === 'DREF' || applicationType === 'OPS_UPDATE') + && (is_published ?? false) + && ( + !has_ops_update + || (has_ops_update && unpublished_op_update_count === 0) + ); + + const drefRegion = country_details?.region; + const isRegionCoordinator = isDefined(drefRegion) + ? userRegionCoordinatorMap?.[drefRegion] ?? false + : false; + + return { + id, + drefId: originalDref.id, + status: item.status, + applicationType, + canAddOpsUpdate, + canCreateFinalReport, + hasPermissionToApprove: isRegionCoordinator || userMe?.is_superuser, + onPublishSuccess: refetchActiveDref, + }; + }, + ), + ]), + [ + strings.activeDrefTableCreatedHeading, + strings.activeDrefTableAppealCodeHeading, + strings.activeDrefTableTitleHeading, + strings.activeDrefTableStageHeading, + strings.activeDrefTableCountryHeading, + strings.activeDrefTableTypeOfDrefHeading, + strings.activeDrefTableStatusHeading, + latestDrefToOriginalMap, + userMe, + userRegionCoordinatorMap, + refetchActiveDref, + ], + ); + + const columns = useMemo( + () => ([ + createExpansionIndicatorColumn(false), + ...baseColumns, + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + disabled: row.application_type === 'DREF', + }), + ), + ]), + [baseColumns, handleExpandClick, expandedRow], + ); + + const detailColumns = useMemo( + () => ([ + createExpansionIndicatorColumn(true), + ...baseColumns, + createEmptyColumn(), + ]), + [baseColumns], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (expandedRow?.id !== datum.id) { + return row; + } + + const originalDref = latestDrefToOriginalMap[datum.id]; + + if (!originalDref || (!originalDref.has_final_report && !originalDref.has_ops_update)) { + return row; + } + + const { + final_report_details, + operational_update_details, + } = originalDref; + + const finalReportList = final_report_details; + const opsUpdateList = operational_update_details; + + const subRows: LatestDref[] = [ + ...finalReportList, + ...opsUpdateList, + originalDref, + ].slice(1); + // We don't need first element since, it will be + // rendered by row + + return ( + <> + {row} + + + ); + }, + [expandedRow, detailColumns, latestDrefToOriginalMap], + ); + + return ( + + )} + withHeaderBorder + filters={( + + )} + withGridViewInFilter + > +
+ + ); +} + +export default ActiveDrefTable; diff --git a/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css b/app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css similarity index 100% rename from src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css rename to app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css diff --git a/src/views/AccountMyFormsDref/CompletedDrefTable/i18n.json b/app/src/views/AccountMyFormsDref/CompletedDrefTable/i18n.json similarity index 100% rename from src/views/AccountMyFormsDref/CompletedDrefTable/i18n.json rename to app/src/views/AccountMyFormsDref/CompletedDrefTable/i18n.json diff --git a/app/src/views/AccountMyFormsDref/CompletedDrefTable/index.tsx b/app/src/views/AccountMyFormsDref/CompletedDrefTable/index.tsx new file mode 100644 index 000000000..88a3872d8 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/CompletedDrefTable/index.tsx @@ -0,0 +1,259 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + Pager, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { type RowOptions } 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 { + _cs, + isDefined, +} from '@togglecorp/fujs'; + +import useFilterState from '#hooks/useFilterState'; +import { useRequest } from '#utils/restRequest'; + +import DrefTableActions, { type Props as DrefTableActionsProps } from '../DrefTableActions'; +import Filters, { type FilterValue } from '../Filters'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + className?: string; + actions?: React.ReactNode; +} + +function CompletedDrefTable(props: Props) { + const { + className, + actions, + } = props; + + const strings = useTranslation(i18n); + const { + page, + setPage, + rawFilter, + filter, + filtered, + setFilterField, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 6, + }); + + const { + response: completedDrefResponse, + pending: completedDrefResponsePending, + } = useRequest({ + url: '/api/v2/completed-dref/', + query: { + offset, + limit, + // FIXME server should accept country + country: isDefined(filter.country) ? [filter.country] : undefined, + type_of_dref: isDefined(filter.type_of_dref) ? [filter.type_of_dref] : undefined, + disaster_type: filter.disaster_type, + appeal_code: filter.appeal_code, + }, + }); + + type DrefResultItem = NonNullable['results']>[number]; + type Key = DrefResultItem['id']; + + const [expandedRow, setExpandedRow] = useState(); + const handleExpandClick = useCallback( + (row: DrefResultItem) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const baseColumns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.completedDrefTableCreatedHeading, + (item) => item.created_at, + { columnClassName: styles.date }, + ), + createStringColumn( + 'appeal_code', + strings.completedDrefTableAppealCodeHeading, + (item) => item.appeal_code, + { columnClassName: styles.appealCode }, + ), + createStringColumn( + 'title', + strings.completedDrefTableTitleHeading, + (item) => item.title, + { columnClassName: styles.title }, + ), + createStringColumn( + 'type', + strings.completedDrefTableStageHeading, + (item) => item.application_type_display, + { columnClassName: styles.stage }, + ), + createStringColumn( + 'country', + strings.completedDrefTableCountryHeading, + (item) => item.country_details?.name, + ), + createStringColumn( + 'status', + strings.completedDrefTableStatusHeading, + (item) => item.status_display, + ), + createElementColumn( + 'actions', + '', + DrefTableActions, + (id, item) => ({ + id, + drefId: item.dref.id, + status: item.status, + // FIXME: fix typing in server (medium priority) + // the application_type should be an enum + applicationType: item.application_type as 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT', + canAddOpsUpdate: false, + canCreateFinalReport: false, + }), + ), + ]), + [ + strings.completedDrefTableCreatedHeading, + strings.completedDrefTableAppealCodeHeading, + strings.completedDrefTableTitleHeading, + strings.completedDrefTableStageHeading, + strings.completedDrefTableCountryHeading, + strings.completedDrefTableStatusHeading, + ], + ); + + const columns = useMemo( + () => ([ + createExpansionIndicatorColumn(false), + ...baseColumns, + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + disabled: row.application_type === 'DREF', + }), + ), + ]), + [baseColumns, handleExpandClick, expandedRow], + ); + + const detailColumns = useMemo( + () => ([ + createExpansionIndicatorColumn(true), + ...baseColumns, + createEmptyColumn(), + ]), + [baseColumns], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (expandedRow?.id !== datum.id) { + return row; + } + + const { + operational_update_details, + } = datum.dref; + + const opsUpdateList = operational_update_details ?? []; + + const subRows: DrefResultItem[] = [ + ...opsUpdateList.map( + (opsUpdate) => ({ + ...opsUpdate, + dref: datum.dref, + glide_code: datum.glide_code, + date_of_publication: datum.date_of_publication, + }), + ), + { + ...datum.dref, + dref: datum.dref, + glide_code: datum.glide_code, + date_of_publication: datum.date_of_publication, + }, + ]; + + return ( + <> + {row} + + + ); + }, + [expandedRow, detailColumns], + ); + + return ( + + )} + withHeaderBorder + filtersContainerClassName={styles.filters} + filters={( + + )} + > +
+ + ); +} + +export default CompletedDrefTable; diff --git a/src/views/AccountMyFormsDref/CompletedDrefTable/styles.module.css b/app/src/views/AccountMyFormsDref/CompletedDrefTable/styles.module.css similarity index 100% rename from src/views/AccountMyFormsDref/CompletedDrefTable/styles.module.css rename to app/src/views/AccountMyFormsDref/CompletedDrefTable/styles.module.css diff --git a/src/views/AccountMyFormsDref/DrefTableActions/drefAllocationExport.ts b/app/src/views/AccountMyFormsDref/DrefTableActions/drefAllocationExport.ts similarity index 100% rename from src/views/AccountMyFormsDref/DrefTableActions/drefAllocationExport.ts rename to app/src/views/AccountMyFormsDref/DrefTableActions/drefAllocationExport.ts diff --git a/src/views/AccountMyFormsDref/DrefTableActions/i18n.json b/app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json similarity index 100% rename from src/views/AccountMyFormsDref/DrefTableActions/i18n.json rename to app/src/views/AccountMyFormsDref/DrefTableActions/i18n.json diff --git a/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx b/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx new file mode 100644 index 000000000..1e6b98e4b --- /dev/null +++ b/app/src/views/AccountMyFormsDref/DrefTableActions/index.tsx @@ -0,0 +1,523 @@ +import { useCallback } from 'react'; +import { + AddLineIcon, + CaseManagementIcon, + CheckLineIcon, + DocumentPdfLineIcon, + DownloadLineIcon, + PencilLineIcon, + ShareLineIcon, +} from '@ifrc-go/icons'; +import type { ButtonProps } from '@ifrc-go/ui'; +import { + Message, + Modal, + TableActions, +} from '@ifrc-go/ui'; +import { + useBooleanState, + useTranslation, +} from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import DrefExportModal from '#components/domain/DrefExportModal'; +import DrefShareModal from '#components/domain/DrefShareModal'; +import DropdownMenuItem from '#components/DropdownMenuItem'; +import Link from '#components/Link'; +import { type components } from '#generated/types'; +import useAlert from '#hooks/useAlert'; +import useRouting from '#hooks/useRouting'; +import { + DREF_STATUS_IN_PROGRESS, + DREF_TYPE_IMMINENT, + DREF_TYPE_LOAN, +} from '#utils/constants'; +import { + GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; + +import { exportDrefAllocation } from './drefAllocationExport'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type DrefStatus = components<'read'>['schemas']['OperationTypeEnum']; + +export interface Props { + drefId: number; + id: number; + status: DrefStatus | null | undefined; + + applicationType: 'DREF' | 'OPS_UPDATE' | 'FINAL_REPORT'; + canAddOpsUpdate: boolean; + canCreateFinalReport: boolean; + hasPermissionToApprove?: boolean; + + onPublishSuccess?: () => void; +} + +function DrefTableActions(props: Props) { + const { + id, + drefId: drefIdFromProps, + status, + applicationType, + canAddOpsUpdate, + canCreateFinalReport, + hasPermissionToApprove, + onPublishSuccess, + } = props; + + const { navigate } = useRouting(); + + const alert = useAlert(); + + const strings = useTranslation(i18n); + const [showExportModal, { + setTrue: setShowExportModalTrue, + setFalse: setShowExportModalFalse, + }] = useBooleanState(false); + + const { + trigger: fetchDref, + pending: fetchingDref, + } = useLazyRequest({ + url: '/api/v2/dref/{id}/', + pathVariables: (ctx: number) => ( + isDefined(ctx) ? { + id: String(ctx), + } : undefined + ), + onSuccess: (response) => { + const exportData = { + // FIXME: use translations + allocationFor: response?.type_of_dref === DREF_TYPE_LOAN ? 'Emergency Appeal' : 'DREF Operation', + appealManager: response?.ifrc_appeal_manager_name, + projectManager: response?.ifrc_project_manager_name, + affectedCountry: response?.country_details?.name, + name: response?.title, + disasterType: response?.disaster_type_details?.name, + // FIXME: use translations + responseType: response?.type_of_dref === DREF_TYPE_IMMINENT ? 'Imminent Crisis' : response?.type_of_onset_display, + noOfPeopleTargeted: response?.num_assisted, + nsRequestDate: response?.ns_request_date, + disasterStartDate: response?.event_date, + implementationPeriod: response?.operation_timeframe, + allocationRequested: response?.amount_requested, + previousAllocation: undefined, + totalDREFAllocation: response?.amount_requested, + // FIXME: use translations + toBeAllocatedFrom: response?.type_of_dref === DREF_TYPE_IMMINENT ? 'Anticipatory Pillar' : 'Response Pillar', + focalPointName: response?.regional_focal_point_name, + }; + exportDrefAllocation(exportData); + }, + }); + + const { + trigger: fetchOpsUpdate, + pending: fetchingOpsUpdate, + } = useLazyRequest({ + url: '/api/v2/dref-op-update/{id}/', + pathVariables: (ctx: number) => ( + isDefined(ctx) ? { + id: String(ctx), + } : undefined + ), + onSuccess: (response) => { + const exportData = { + allocationFor: response?.type_of_dref_display === 'Loan' ? 'Emergency Appeal' : 'DREF Operation', + appealManager: response?.ifrc_appeal_manager_name, + projectManager: response?.ifrc_project_manager_name, + affectedCountry: response?.country_details?.name, + name: response?.title, + disasterType: response?.disaster_type_details?.name, + responseType: + response?.type_of_dref_display === 'Imminent' + // FIXME: can't compare imminent with Imminent Crisis directly + ? 'Imminent Crisis' + : response?.type_of_onset_display, + noOfPeopleTargeted: response?.number_of_people_targeted, + nsRequestDate: response?.ns_request_date, + disasterStartDate: response?.event_date, + implementationPeriod: response?.total_operation_timeframe, + allocationRequested: response?.additional_allocation, + previousAllocation: response?.dref_allocated_so_far ?? 0, + totalDREFAllocation: response?.total_dref_allocation, + toBeAllocatedFrom: + response?.type_of_dref_display === 'Imminent' + // FIXME: can't compare imminent with Anticipatory Pillar + ? 'Anticipatory Pillar' + : 'Response Pillar', + focalPointName: response?.regional_focal_point_name, + }; + exportDrefAllocation(exportData); + }, + }); + + const { + trigger: publishDref, + pending: publishDrefPending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/dref/{id}/publish/', + pathVariables: { id: String(id) }, + // FIXME: typings should be fixed in the server + body: () => ({} as never), + onSuccess: () => { + alert.show( + strings.drefApprovalSuccessTitle, + { variant: 'success' }, + ); + if (onPublishSuccess) { + onPublishSuccess(); + } + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.drefApprovalFailureTitle, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + const { + trigger: publishOpsUpdate, + pending: publishOpsUpdatePending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/dref-op-update/{id}/publish/', + pathVariables: { id: String(id) }, + // FIXME: typings should be fixed in the server + body: () => ({} as never), + onSuccess: () => { + alert.show( + strings.drefApprovalSuccessTitle, + { variant: 'success' }, + ); + if (onPublishSuccess) { + onPublishSuccess(); + } + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.drefApprovalFailureTitle, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + const { + trigger: publishFinalReport, + pending: publishFinalReportPending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/dref-final-report/{id}/publish/', + pathVariables: { id: String(id) }, + // FIXME: typings should be fixed in the server + body: () => ({} as never), + onSuccess: () => { + alert.show( + strings.drefApprovalSuccessTitle, + { variant: 'success' }, + ); + if (onPublishSuccess) { + onPublishSuccess(); + } + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.drefApprovalFailureTitle, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + // FIXME: the type should be fixed on the server + type OpsUpdateRequestBody = GoApiBody<'/api/v2/dref-op-update/', 'POST'>; + + const { + trigger: createOpsUpdate, + pending: createOpsUpdatePending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/dref-op-update/', + // FIXME: the type should be fixed on the server + body: (drefId: number) => ({ dref: drefId } as OpsUpdateRequestBody), + onSuccess: (response) => { + navigate( + 'drefOperationalUpdateForm', + { params: { opsUpdateId: response.id } }, + { state: { isNewOpsUpdate: true } }, + ); + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.drefAccountCouldNotCreate, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + // FIXME: the type should be fixed on the server + type FinalReportRequestBody = GoApiBody<'/api/v2/dref-final-report/', 'POST'>; + const { + trigger: createFinalReport, + pending: createFinalReportPending, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/dref-final-report/', + // FIXME: the type should be fixed on the server + body: (drefId: number) => ({ dref: drefId } as FinalReportRequestBody), + onSuccess: (response) => { + navigate( + 'drefFinalReportForm', + { params: { finalReportId: response.id } }, + ); + }, + onFailure: ({ + value: { messageForNotification }, + }) => { + alert.show( + strings.drefAccountCouldNotCreateFinalReport, + { + description: messageForNotification, + variant: 'danger', + }, + ); + }, + }); + + const handleAddOpsUpdate = useCallback( + () => { + createOpsUpdate(drefIdFromProps); + }, + [drefIdFromProps, createOpsUpdate], + ); + + const handleAddFinalReport = useCallback( + () => { + createFinalReport(drefIdFromProps); + }, + [drefIdFromProps, createFinalReport], + ); + + const [showShareModal, { + setTrue: setShowShareModalTrue, + setFalse: setShowShareModalFalse, + }] = useBooleanState(false); + + const handleExportClick: NonNullable['onClick']> = useCallback( + () => { + setShowExportModalTrue(); + }, + [setShowExportModalTrue], + ); + + const handleShareClick: NonNullable['onClick']> = useCallback( + () => { + setShowShareModalTrue(); + }, + [setShowShareModalTrue], + ); + + const handlePublishClick = useCallback( + () => { + if (applicationType === 'DREF') { + publishDref(null); + } else if (applicationType === 'OPS_UPDATE') { + publishOpsUpdate(null); + } else if (applicationType === 'FINAL_REPORT') { + publishFinalReport(null); + } + }, + [ + applicationType, + publishDref, + publishOpsUpdate, + publishFinalReport, + ], + ); + + const handleDrefAllocationExport = useCallback( + () => { + if (applicationType === 'DREF') { + fetchDref(id); + } else if (applicationType === 'OPS_UPDATE') { + fetchOpsUpdate(id); + } + }, + [fetchDref, fetchOpsUpdate, applicationType, id], + ); + + const drefApprovalPending = publishDrefPending + || publishOpsUpdatePending + || publishFinalReportPending; + + const canDownloadAllocation = (applicationType === 'DREF' || applicationType === 'OPS_UPDATE'); + + const canApprove = status === DREF_STATUS_IN_PROGRESS && hasPermissionToApprove; + + const disabled = fetchingDref + || fetchingOpsUpdate + || publishDrefPending + || publishOpsUpdatePending + || publishFinalReportPending + || createOpsUpdatePending + || createFinalReportPending; + + return ( + + {canApprove && ( + } + confirmMessage={strings.drefAccountConfirmMessage} + onConfirm={handlePublishClick} + disabled={disabled} + persist + > + {strings.dropdownActionApproveLabel} + + )} + {canDownloadAllocation && ( + } + disabled={disabled} + persist + > + {strings.dropdownActionAllocationFormLabel} + + )} + {canAddOpsUpdate && ( + } + onClick={handleAddOpsUpdate} + disabled={disabled} + persist + > + {strings.dropdownActionAddOpsUpdateLabel} + + )} + {canCreateFinalReport && ( + } + disabled={disabled} + persist + > + {strings.dropdownActionCreateFinalReportLabel} + + )} + } + onClick={handleShareClick} + disabled={disabled} + persist + > + {strings.dropdownActionShareLabel} + + } + onClick={handleExportClick} + disabled={disabled} + persist + > + {strings.dropdownActionExportLabel} + + + )} + > + {status === DREF_STATUS_IN_PROGRESS && applicationType === 'DREF' && ( + } + > + {strings.dropdownActionEditLabel} + + )} + {status === DREF_STATUS_IN_PROGRESS && applicationType === 'OPS_UPDATE' && ( + } + > + {strings.dropdownActionEditLabel} + + )} + {status === DREF_STATUS_IN_PROGRESS && applicationType === 'FINAL_REPORT' && ( + } + > + {strings.dropdownActionEditLabel} + + )} + {showExportModal && ( + + )} + {showShareModal && ( + + )} + {drefApprovalPending && ( + + + + )} + + ); +} + +export default DrefTableActions; diff --git a/src/views/AccountMyFormsDref/DrefTableActions/styles.module.css b/app/src/views/AccountMyFormsDref/DrefTableActions/styles.module.css similarity index 100% rename from src/views/AccountMyFormsDref/DrefTableActions/styles.module.css rename to app/src/views/AccountMyFormsDref/DrefTableActions/styles.module.css diff --git a/src/views/AccountMyFormsDref/Filters/i18n.json b/app/src/views/AccountMyFormsDref/Filters/i18n.json similarity index 100% rename from src/views/AccountMyFormsDref/Filters/i18n.json rename to app/src/views/AccountMyFormsDref/Filters/i18n.json diff --git a/app/src/views/AccountMyFormsDref/Filters/index.tsx b/app/src/views/AccountMyFormsDref/Filters/index.tsx new file mode 100644 index 000000000..75718f798 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/Filters/index.tsx @@ -0,0 +1,75 @@ +import { + SelectInput, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { stringValueSelector } from '@ifrc-go/ui/utils'; +import { EntriesAsList } from '@togglecorp/toggle-form'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import { type components } from '#generated/types'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; + +import i18n from './i18n.json'; + +type TypeOfDref = components<'read'>['schemas']['TypeOfDrefEnum']; +function typeOfDrefKeySelector({ key } : { key: TypeOfDref }) { + return key; +} + +export interface FilterValue { + country?: number | undefined; + type_of_dref?: TypeOfDref | undefined; + disaster_type?: number | undefined; + appeal_code?: string | undefined; +} + +interface Props { + value: FilterValue; + onChange: (...args: EntriesAsList) => void; +} + +function Filters(props: Props) { + const { + value, + onChange, + } = props; + + const strings = useTranslation(i18n); + const { dref_dref_dref_type: drefTypeOptions } = useGlobalEnums(); + + return ( + <> + + + + + + ); +} + +export default Filters; diff --git a/src/views/AccountMyFormsDref/i18n.json b/app/src/views/AccountMyFormsDref/i18n.json similarity index 100% rename from src/views/AccountMyFormsDref/i18n.json rename to app/src/views/AccountMyFormsDref/i18n.json diff --git a/app/src/views/AccountMyFormsDref/index.tsx b/app/src/views/AccountMyFormsDref/index.tsx new file mode 100644 index 000000000..5f14110f1 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/index.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { + ChevronLeftLineIcon, + ChevronRightLineIcon, +} from '@ifrc-go/icons'; +import { Button } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import ActiveDrefTable from './ActiveDrefTable'; +import CompletedDrefTable from './CompletedDrefTable'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const [currentView, setCurrentView] = useState<'active' | 'completed'>('active'); + const strings = useTranslation(i18n); + + return ( +
+ {currentView === 'active' && ( + } + > + {strings.showCompletedButtonLabel} + + )} + /> + )} + {currentView === 'completed' && ( + } + > + {strings.backToActiveButtonLabel} + + )} + /> + )} +
+ ); +} + +Component.displayName = 'AccountDREFApplications'; diff --git a/src/views/AccountMyFormsDref/styles.module.css b/app/src/views/AccountMyFormsDref/styles.module.css similarity index 100% rename from src/views/AccountMyFormsDref/styles.module.css rename to app/src/views/AccountMyFormsDref/styles.module.css diff --git a/src/views/AccountMyFormsFieldReport/i18n.json b/app/src/views/AccountMyFormsFieldReport/i18n.json similarity index 100% rename from src/views/AccountMyFormsFieldReport/i18n.json rename to app/src/views/AccountMyFormsFieldReport/i18n.json diff --git a/app/src/views/AccountMyFormsFieldReport/index.tsx b/app/src/views/AccountMyFormsFieldReport/index.tsx new file mode 100644 index 000000000..518874da1 --- /dev/null +++ b/app/src/views/AccountMyFormsFieldReport/index.tsx @@ -0,0 +1,173 @@ +import { useMemo } from 'react'; +import { + Container, + NumberOutput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createStringColumn, + numericIdSelector, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useUserMe from '#hooks/domain/useUserMe'; +import useFilterState from '#hooks/useFilterState'; +import { + createCountryListColumn, + createLinkColumn, +} from '#utils/domain/tableHelpers'; +import { + GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type FieldReportResponse = GoApiResponse<'/api/v2/field-report/'>; +type FieldReportListItem = NonNullable[number]; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const userMe = useUserMe(); + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 10, + }); + + const { + response: fieldReportResponse, + pending: fieldReportResponsePending, + } = useRequest({ + skip: isNotDefined(userMe?.id), + url: '/api/v2/field-report/', + query: { + user: userMe?.id, + limit, + ordering, + offset, + }, + preserveResponse: true, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.createdAtHeading, + (item) => item.start_date, + { + sortable: true, + columnClassName: styles.createdAt, + }, + ), + createLinkColumn( + 'summary', + strings.nameHeading, + (item) => item.summary, + (item) => ({ + to: 'fieldReportDetails', + urlParams: { fieldReportId: item.id }, + }), + { + sortable: true, + columnClassName: styles.summary, + }, + ), + createLinkColumn( + 'event_name', + strings.emergencyHeading, + (item) => item.event_details?.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.event }, + }), + ), + createStringColumn( + 'dtype', + strings.disasterTypeHeading, + (item) => item.dtype_details?.name, + { sortable: true }, + ), + createCountryListColumn( + 'countries', + strings.countryHeading, + (item) => item.countries_details, + ), + ]), + [ + strings.createdAtHeading, + strings.nameHeading, + strings.emergencyHeading, + strings.disasterTypeHeading, + strings.countryHeading, + ], + ); + + const heading = useMemo( + () => resolveToComponent( + strings.pageHeading, + { + numFieldReports: ( + + ), + }, + ), + [fieldReportResponse, strings.pageHeading], + ); + + return ( + + {strings.viewAllReportsButtonLabel} + + )} + footerActions={( + + )} + > + +
+ + + ); +} + +Component.displayName = 'AccountMyFormsFieldReport'; diff --git a/src/views/AccountMyFormsFieldReport/styles.module.css b/app/src/views/AccountMyFormsFieldReport/styles.module.css similarity index 100% rename from src/views/AccountMyFormsFieldReport/styles.module.css rename to app/src/views/AccountMyFormsFieldReport/styles.module.css diff --git a/src/views/AccountMyFormsLayout/i18n.json b/app/src/views/AccountMyFormsLayout/i18n.json similarity index 100% rename from src/views/AccountMyFormsLayout/i18n.json rename to app/src/views/AccountMyFormsLayout/i18n.json diff --git a/app/src/views/AccountMyFormsLayout/index.tsx b/app/src/views/AccountMyFormsLayout/index.tsx new file mode 100644 index 000000000..ac2a7b65b --- /dev/null +++ b/app/src/views/AccountMyFormsLayout/index.tsx @@ -0,0 +1,43 @@ +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 i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + return ( +
+ + + {strings.fieldReportTabTitle} + + + {strings.perTabTitle} + + + {strings.drefTabTitle} + + + {strings.threeWTabTitle} + + + +
+ ); +} + +Component.displayName = 'AccountMyFormsLayout'; diff --git a/src/views/AccountMyFormsLayout/styles.module.css b/app/src/views/AccountMyFormsLayout/styles.module.css similarity index 100% rename from src/views/AccountMyFormsLayout/styles.module.css rename to app/src/views/AccountMyFormsLayout/styles.module.css diff --git a/src/views/AccountMyFormsPer/PerTableActions/i18n.json b/app/src/views/AccountMyFormsPer/PerTableActions/i18n.json similarity index 100% rename from src/views/AccountMyFormsPer/PerTableActions/i18n.json rename to app/src/views/AccountMyFormsPer/PerTableActions/i18n.json diff --git a/app/src/views/AccountMyFormsPer/PerTableActions/index.tsx b/app/src/views/AccountMyFormsPer/PerTableActions/index.tsx new file mode 100644 index 000000000..3d8a41dfb --- /dev/null +++ b/app/src/views/AccountMyFormsPer/PerTableActions/index.tsx @@ -0,0 +1,141 @@ +import { useCallback } from 'react'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { TableActions } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + listToMap, +} from '@togglecorp/fujs'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; +import Link from '#components/Link'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { + PER_PHASE_ACTION, + PER_PHASE_ASSESSMENT, + PER_PHASE_OVERVIEW, + PER_PHASE_PRIORITIZATION, + PER_PHASE_WORKPLAN, +} from '#utils/domain/per'; +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type AggregatedPerProcessStatusResponse = GoApiResponse<'/api/v2/aggregated-per-process-status/'>; +type PerPhase = NonNullable[number]['phase']; + +export interface Props { + phase: PerPhase; + phaseDisplay: string | undefined; + perId: number; +} + +function PerTableActions(props: Props) { + const { + perId, + phase, + phaseDisplay, + } = props; + + const strings = useTranslation(i18n); + const { per_perphases } = useGlobalEnums(); + const phaseMap = listToMap( + per_perphases, + ({ key }) => key, + ({ value }) => value, + ); + + const getRouteUrl = useCallback( + (currentPhase: number) => { + const perPhaseUrl = { + [PER_PHASE_OVERVIEW]: 'perOverviewForm', + [PER_PHASE_ASSESSMENT]: 'perAssessmentForm', + [PER_PHASE_PRIORITIZATION]: 'perPrioritizationForm', + [PER_PHASE_WORKPLAN]: 'perWorkPlanForm', + } as const; + + if ( + currentPhase === PER_PHASE_OVERVIEW + || currentPhase === PER_PHASE_ASSESSMENT + || currentPhase === PER_PHASE_PRIORITIZATION + || currentPhase === PER_PHASE_WORKPLAN + ) { + return perPhaseUrl[currentPhase]; + } + + return undefined; + }, + [], + ); + + return ( + + {phase === PER_PHASE_OVERVIEW ? ( + } + > + {resolveToString(strings.tableActionEditLabel, { phaseDisplay: phaseMap?.[PER_PHASE_OVERVIEW] ?? '--' })} + + ) : ( + } + > + {resolveToString(strings.tableActionViewLabel, { phaseDisplay: phaseMap?.[PER_PHASE_OVERVIEW] ?? '--' })} + + )} + {phase > PER_PHASE_ASSESSMENT && ( + + {resolveToString(strings.tableActionViewLabel, { phaseDisplay: phaseMap?.[PER_PHASE_ASSESSMENT] ?? '--' })} + + )} + {phase > PER_PHASE_PRIORITIZATION && ( + + {resolveToString(strings.tableActionEditLabel, { phaseDisplay: phaseMap?.[PER_PHASE_PRIORITIZATION] ?? '--' })} + + )} + + )} + > + {isDefined(phase) && phase <= PER_PHASE_WORKPLAN && ( + + {resolveToString( + strings.tableActionEditLabel, + { phaseDisplay }, + )} + + )} + {isDefined(phase) && phase === PER_PHASE_ACTION && ( + + {resolveToString(strings.tableActionEditLabel, { phaseDisplay: phaseMap?.[PER_PHASE_WORKPLAN] ?? '--' })} + + )} + + ); +} + +export default PerTableActions; diff --git a/src/views/AccountMyFormsPer/i18n.json b/app/src/views/AccountMyFormsPer/i18n.json similarity index 100% rename from src/views/AccountMyFormsPer/i18n.json rename to app/src/views/AccountMyFormsPer/i18n.json diff --git a/app/src/views/AccountMyFormsPer/index.tsx b/app/src/views/AccountMyFormsPer/index.tsx new file mode 100644 index 000000000..6866fa5a5 --- /dev/null +++ b/app/src/views/AccountMyFormsPer/index.tsx @@ -0,0 +1,278 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + Pager, + Table, + TableBodyContent, +} from '@ifrc-go/ui'; +import { type RowOptions } from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createEmptyColumn, + createExpandColumn, + createExpansionIndicatorColumn, + createNumberColumn, + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import RegionSelectInput, { type RegionOption } from '#components/domain/RegionSelectInput'; +import Link from '#components/Link'; +import WikiLink from '#components/WikiLink'; +import useFilterState from '#hooks/useFilterState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import PerTableActions, { type Props as PerTableActionsProps } from './PerTableActions'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type AggregatedPerProcessStatusResponse = GoApiResponse<'/api/v2/aggregated-per-process-status/'>; +type PerProcessStatusItem = NonNullable[number]; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + rawFilter, + filter, + filtered, + setFilterField, + limit, + offset, + page, + setPage, + } = useFilterState<{ + region?: RegionOption['key'], + country?: number, + }>({ + filter: {}, + ordering: { name: 'date_of_assessment', direction: 'dsc' }, + pageSize: 10, + }); + + const [expandedRow, setExpandedRow] = useState(); + + const { + pending: aggregatedStatusPending, + response: aggregatedStatusResponse, + } = useRequest({ + url: '/api/v2/aggregated-per-process-status/', + preserveResponse: true, + query: { + ordering, + country: isDefined(filter.country) ? [filter.country] : undefined, + region: filter.region, + limit, + offset, + }, + }); + + const { + // pending: countryStatusPending, + response: countryStatusResponse, + } = useRequest({ + skip: isNotDefined(expandedRow), + url: '/api/v2/per-process-status/', + query: { + country: expandedRow?.country + ? [expandedRow.country] + : undefined, + limit: 9999, + }, + }); + + const handleExpandClick = useCallback( + (row: PerProcessStatusItem) => { + setExpandedRow( + (prevValue) => (prevValue?.id === row.id ? undefined : row), + ); + }, + [], + ); + + const baseColumn = useMemo( + () => ([ + createLinkColumn( + 'country', + strings.tableCountryTitle, + (item) => item.country_details?.name, + (item) => ({ + to: 'countryPreparedness', + urlParams: { + countryId: item.country, + perId: item.id, + }, + }), + ), + createDateColumn( + 'date_of_assessment', + strings.tableStartDateTitle, + (item) => item.date_of_assessment, + { sortable: true }, + ), + createNumberColumn( + 'assessment_number', + strings.tablePerCycleTitle, + (item) => item.assessment_number, + ), + createStringColumn( + 'phase', + strings.tablePerPhaseTitle, + (item) => (isDefined(item.phase) ? `${item.phase} - ${item.phase_display}` : '-'), + { sortable: true }, + ), + createElementColumn( + 'actions', + '', + PerTableActions, + (perId, statusItem) => ({ + perId, + phase: statusItem.phase, + phaseDisplay: statusItem.phase_display, + }), + ), + ]), + [ + strings.tableCountryTitle, + strings.tableStartDateTitle, + strings.tablePerCycleTitle, + strings.tablePerPhaseTitle, + ], + ); + + const aggregatedColumns = useMemo( + () => ([ + createExpansionIndicatorColumn( + false, + ), + ...baseColumn, + createExpandColumn( + 'expandRow', + '', + (row) => ({ + onClick: handleExpandClick, + expanded: row.id === expandedRow?.id, + disabled: (row.assessment_number ?? 0) <= 1, + }), + ), + ]), + [handleExpandClick, baseColumn, expandedRow?.id], + ); + + const detailColumns = useMemo( + () => ([ + createExpansionIndicatorColumn( + true, + ), + ...baseColumn, + createEmptyColumn(), + ]), + [baseColumn], + ); + + const rowModifier = useCallback( + ({ row, datum }: RowOptions) => { + if (datum.country !== expandedRow?.country) { + return row; + } + + const subRows = countryStatusResponse?.results?.filter( + (subRow) => subRow.id !== datum.id, + ); + + return ( + <> + {row} + + + ); + }, + [expandedRow, detailColumns, countryStatusResponse], + ); + + return ( + + + {strings.newProcessButtonLabel} + + + + )} + filtersContainerClassName={styles.filters} + filters={( + <> + + + + )} + footerActions={( + + )} + > + +
+ + + ); +} + +Component.displayName = 'AccountPerForms'; diff --git a/src/views/AccountMyFormsPer/styles.module.css b/app/src/views/AccountMyFormsPer/styles.module.css similarity index 100% rename from src/views/AccountMyFormsPer/styles.module.css rename to app/src/views/AccountMyFormsPer/styles.module.css diff --git a/src/views/AccountMyFormsThreeW/ThreeWTableActions/i18n.json b/app/src/views/AccountMyFormsThreeW/ThreeWTableActions/i18n.json similarity index 100% rename from src/views/AccountMyFormsThreeW/ThreeWTableActions/i18n.json rename to app/src/views/AccountMyFormsThreeW/ThreeWTableActions/i18n.json diff --git a/app/src/views/AccountMyFormsThreeW/ThreeWTableActions/index.tsx b/app/src/views/AccountMyFormsThreeW/ThreeWTableActions/index.tsx new file mode 100644 index 000000000..d6f318dd3 --- /dev/null +++ b/app/src/views/AccountMyFormsThreeW/ThreeWTableActions/index.tsx @@ -0,0 +1,93 @@ +import { + CopyLineIcon, + PencilFillIcon, + ShareBoxLineIcon, +} from '@ifrc-go/icons'; +import { TableActions } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +export type Props = ({ + type: 'project'; + projectId: number; + activityId?: never; +} | { + type: 'activity'; + activityId: number; + projectId?: never; +}) + +function ThreeWTableActions(props: Props) { + const { + type, + projectId, + activityId, + } = props; + + const strings = useTranslation(i18n); + + if (type === 'activity') { + return ( + + } + > + {strings.threeWViewDetails} + + } + > + {strings.threeWEdit} + + } + > + {strings.threeWDuplicate} + + + )} + /> + ); + } + + return ( + + } + > + {strings.threeWViewDetails} + + } + > + {strings.threeWEdit} + + + )} + /> + ); +} + +export default ThreeWTableActions; diff --git a/src/views/AccountMyFormsThreeW/i18n.json b/app/src/views/AccountMyFormsThreeW/i18n.json similarity index 100% rename from src/views/AccountMyFormsThreeW/i18n.json rename to app/src/views/AccountMyFormsThreeW/i18n.json diff --git a/app/src/views/AccountMyFormsThreeW/index.tsx b/app/src/views/AccountMyFormsThreeW/index.tsx new file mode 100644 index 000000000..bb0d13de9 --- /dev/null +++ b/app/src/views/AccountMyFormsThreeW/index.tsx @@ -0,0 +1,408 @@ +import { + useContext, + useMemo, +} from 'react'; +import { + Container, + Pager, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createListDisplayColumn, + createNumberColumn, + createStringColumn, + numericIdSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import UserContext from '#contexts/user'; +import useFilterState from '#hooks/useFilterState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import ThreeWTableActions, { type Props as ThreeWTableActionsProps } from './ThreeWTableActions'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ActivitiesResponse = GoApiResponse<'/api/v2/emergency-project/'>; +type ActivityListItem = NonNullable[number]; + +type ProjectsResponse = GoApiResponse<'/api/v2/project/'>; +type ProjectListItem = NonNullable[number]; + +type DistrictDetails = ActivityListItem['districts_details'][number]; + +function getPeopleReachedInActivity(activity: NonNullable[number]) { + const { + is_simplified_report, + + people_count, + male_0_1_count, + male_2_5_count, + male_6_12_count, + male_13_17_count, + male_18_59_count, + male_60_plus_count, + male_unknown_age_count, + + female_0_1_count, + female_2_5_count, + female_6_12_count, + female_13_17_count, + female_18_59_count, + female_60_plus_count, + female_unknown_age_count, + + other_0_1_count, + other_2_5_count, + other_6_12_count, + other_13_17_count, + other_18_59_count, + other_60_plus_count, + other_unknown_age_count, + } = activity; + + if (is_simplified_report === true) { + return people_count ?? 0; + } + + if (is_simplified_report === false) { + return sumSafe([ + male_0_1_count, + male_2_5_count, + male_6_12_count, + male_13_17_count, + male_18_59_count, + male_60_plus_count, + male_unknown_age_count, + + female_0_1_count, + female_2_5_count, + female_6_12_count, + female_13_17_count, + female_18_59_count, + female_60_plus_count, + female_unknown_age_count, + + other_0_1_count, + other_2_5_count, + other_6_12_count, + other_13_17_count, + other_18_59_count, + other_60_plus_count, + other_unknown_age_count, + ]); + } + + return undefined; +} + +function getPeopleReached(activity: ActivityListItem) { + const peopleReached = sumSafe(activity?.activities?.map(getPeopleReachedInActivity)); + + return peopleReached; +} + +function DistrictNameOutput({ districtName }: { districtName: string }) { + return districtName; +} + +interface Props { + className?: string; +} + +// eslint-disable-next-line import/prefer-default-export +export function Component(props: Props) { + const { className } = props; + const strings = useTranslation(i18n); + const { userAuth: userDetails } = useContext(UserContext); + const { + page: projectActivePage, + setPage: setProjectActivePage, + limit: projectLimit, + offset: projectOffset, + } = useFilterState({ + filter: {}, + pageSize: 5, + }); + + const { + page: activityActivePage, + setPage: setActivityActivePage, + limit: activityLimit, + offset: activityOffset, + } = useFilterState({ + filter: {}, + pageSize: 5, + }); + + const { + response: projectResponse, + pending: projectResponsePending, + } = useRequest({ + url: '/api/v2/project/', + preserveResponse: true, + query: { + limit: projectLimit, + offset: projectOffset, + user: userDetails?.id, + }, + }); + + const { + response: activityResponse, + pending: activityResponsePending, + } = useRequest({ + url: '/api/v2/emergency-project/', + preserveResponse: true, + query: { + limit: activityLimit, + offset: activityOffset, + user: userDetails?.id, + }, + }); + + const projectColumns = useMemo( + () => ([ + createLinkColumn( + 'country', + strings.threeWTableCountry, + (item) => item.project_country_detail?.name, + (item) => ({ + to: 'countryOngoingActivitiesThreeWProjects', + urlParams: { countryId: item.project_country }, + }), + ), + createStringColumn( + 'ns', + strings.threeWTableNS, + (item) => item.reporting_ns_detail?.society_name, + ), + createLinkColumn( + 'name', + strings.threeWTableProjectName, + (item) => item.name, + (item) => ({ + to: 'threeWProjectDetail', + urlParams: { projectId: item.id }, + }), + ), + createStringColumn( + 'sector', + strings.threeWTableSector, + (item) => item.primary_sector_display, + ), + createNumberColumn( + 'budget', + strings.threeWTableTotalBudget, + (item) => item.budget_amount, + undefined, + ), + createStringColumn( + 'programmeType', + strings.threeWTableProgrammeType, + (item) => item.programme_type_display, + ), + createStringColumn( + 'disasterType', + strings.threeWTableDisasterType, + (item) => item.dtype_detail?.name, + ), + createNumberColumn( + 'peopleTargeted', + strings.threeWTablePeopleTargeted, + (item) => item.target_total, + undefined, + ), + createNumberColumn( + 'peopleReached', + strings.threeWTablePeopleReached2, + (item) => item.reached_total, + undefined, + ), + createElementColumn( + 'actions', + '', + ThreeWTableActions, + (projectId) => ({ + type: 'project', + projectId, + }), + ), + ]), + [ + strings.threeWTableCountry, + strings.threeWTableNS, + strings.threeWTableProjectName, + strings.threeWTableSector, + strings.threeWTableTotalBudget, + strings.threeWTableProgrammeType, + strings.threeWTableDisasterType, + strings.threeWTablePeopleTargeted, + strings.threeWTablePeopleReached2, + ], + ); + + const activityColumns = useMemo( + () => ([ + createStringColumn( + 'national_society_eru', + strings.threeWNationalSociety, + (item) => ( + item.activity_lead === 'deployed_eru' + ? item.deployed_eru_details + ?.eru_owner_details + ?.national_society_country_details + ?.society_name + : item.reporting_ns_details?.society_name + ), + ), + createLinkColumn( + 'title', + strings.threeWEmergencyTitle, + (item) => item.title, + (item) => ({ + to: 'threeWActivityDetail', + urlParams: { activityId: item.id }, + }), + ), + createDateColumn( + 'start_date', + strings.threeWEmergencyStartDate, + (item) => item.start_date, + ), + createLinkColumn( + 'country', + strings.threeWEmergencyCountryName, + (item) => item.country_details?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { countryId: item.id }, + }), + ), + createListDisplayColumn< + ActivityListItem, + number, + DistrictDetails, + { districtName: string } + >( + 'districts', + strings.threeWEmergencyRegion, + (activity) => ({ + list: activity.districts_details, + renderer: DistrictNameOutput, + rendererParams: (districtDetail) => ({ districtName: districtDetail.name }), + keySelector: (districtDetail) => districtDetail.id, + }), + ), + createStringColumn( + 'status', + strings.threeWEmergencyStatus, + (item) => item.status_display, + ), + createNumberColumn( + 'people_reached', + strings.threeWEmergencyServices, // People Reached + (item) => getPeopleReached(item), + ), + createElementColumn< + ActivityListItem, + number, + ThreeWTableActionsProps + >( + 'actions', + '', + ThreeWTableActions, + (activityId) => ({ + type: 'activity', + activityId, + }), + ), + ]), + [ + strings.threeWNationalSociety, + strings.threeWEmergencyTitle, + strings.threeWEmergencyStartDate, + strings.threeWEmergencyCountryName, + strings.threeWEmergencyRegion, + strings.threeWEmergencyStatus, + strings.threeWEmergencyServices, + ], + ); + + return ( +
+ + {strings.threeWViewAllProjectLabel} + + )} + footerActions={( + + )} + > +
+ + + {strings.threeWViewAllActivityLabel} + + )} + withHeaderBorder + footerActions={( + + )} + > +
+ + + ); +} + +Component.displayName = 'AccountThreeWForms'; diff --git a/src/views/AccountMyFormsThreeW/styles.module.css b/app/src/views/AccountMyFormsThreeW/styles.module.css similarity index 100% rename from src/views/AccountMyFormsThreeW/styles.module.css rename to app/src/views/AccountMyFormsThreeW/styles.module.css diff --git a/src/views/AccountNotifications/SubscriptionPreferences/i18n.json b/app/src/views/AccountNotifications/SubscriptionPreferences/i18n.json similarity index 100% rename from src/views/AccountNotifications/SubscriptionPreferences/i18n.json rename to app/src/views/AccountNotifications/SubscriptionPreferences/i18n.json diff --git a/app/src/views/AccountNotifications/SubscriptionPreferences/index.tsx b/app/src/views/AccountNotifications/SubscriptionPreferences/index.tsx new file mode 100644 index 000000000..d0a859ae8 --- /dev/null +++ b/app/src/views/AccountNotifications/SubscriptionPreferences/index.tsx @@ -0,0 +1,343 @@ +import { + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { + Button, + Checkbox, + Checklist, + Container, + MultiSelectInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + numericIdSelector, + stringNameSelector, + stringValueSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + listToGroupList, +} from '@togglecorp/fujs'; +import { type EntriesAsList } from '@togglecorp/toggle-form'; + +import DomainContext from '#contexts/domain'; +import { type components } from '#generated/types'; +import useCountry from '#hooks/domain/useCountry'; +import useDisasterTypes from '#hooks/domain/useDisasterType'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useUserMe from '#hooks/domain/useUserMe'; +import useAlert from '#hooks/useAlert'; +import { + SUBSCRIPTION_COUNTRY, + SUBSCRIPTION_DISASTER_TYPE, + SUBSCRIPTION_FOLLOWED_EVENTS, + SUBSCRIPTION_GENERAL, + SUBSCRIPTION_NEW_EMERGENCIES, + SUBSCRIPTION_NEW_OPERATIONS, + SUBSCRIPTION_PER_DUE_DATE, + SUBSCRIPTION_REGION, + SUBSCRIPTION_SURGE_ALERT, + SUBSCRIPTION_SURGE_DEPLOYMENT_MESSAGES, + SUBSCRIPTION_WEEKLY_DIGEST, +} from '#utils/constants'; +import { + type GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +export type RegionOption = components<'read'>['schemas']['ApiRegionNameEnum']; + +function regionKeySelector(option: RegionOption) { + return option.key; +} + +interface Value { + weeklyDigest?: boolean; + newEmergencies?: boolean; + newOperations?: boolean; + general?: boolean; + + region?: number[]; + country?: number[]; + + disasterType?: number[]; + + surge?: boolean; + // Deployment Messages + surgeDM?: boolean; + + perDueDate?: boolean; + + followedEvent?: number[]; +} + +type UpdateSubscriptionBody = GoApiBody<'/api/v2/update_subscriptions/', 'POST'>; + +function SubscriptionPreferences() { + const countryOptions = useCountry(); + const disasterTypeOptions = useDisasterTypes(); + const user = useUserMe(); + const alert = useAlert(); + const strings = useTranslation(i18n); + const { api_region_name: regionOptions } = useGlobalEnums(); + const { invalidate } = useContext(DomainContext); + + const [value, setValue] = useState({}); + const { trigger: updateUserSubscription } = useLazyRequest({ + url: '/api/v2/update_subscriptions/', + method: 'POST', + body: (ctx: UpdateSubscriptionBody) => ctx, + onSuccess: () => { + alert.show( + strings.subscriptionPreferencesUpdatedMessage, + { variant: 'success' }, + ); + invalidate('user-me'); + }, + // TODO: handle failure + }); + + // NOTE: Setting initial value from userMe + useEffect( + () => { + if (!user) { + return; + } + + const groupedSubcriptions = listToGroupList( + user.subscription ?? [], + (sub) => sub.rtype ?? '-1', + ); + + const weeklyDigest = isDefined( + groupedSubcriptions[SUBSCRIPTION_WEEKLY_DIGEST]?.[0], + ); + const newEmergencies = isDefined( + groupedSubcriptions[SUBSCRIPTION_NEW_EMERGENCIES]?.[0], + ); + const newOperations = isDefined( + groupedSubcriptions[SUBSCRIPTION_NEW_OPERATIONS]?.[0], + ); + const general = isDefined( + groupedSubcriptions[SUBSCRIPTION_GENERAL]?.[0], + ); + const surge = isDefined( + groupedSubcriptions[SUBSCRIPTION_SURGE_ALERT]?.[0], + ); + const surgeDM = isDefined( + groupedSubcriptions[SUBSCRIPTION_SURGE_DEPLOYMENT_MESSAGES]?.[0], + ); + const perDueDate = isDefined( + groupedSubcriptions[SUBSCRIPTION_PER_DUE_DATE]?.[0], + ); + + const region = groupedSubcriptions[SUBSCRIPTION_REGION]?.map( + (record) => record.region, + ).filter(isDefined) ?? []; + const country = groupedSubcriptions[SUBSCRIPTION_COUNTRY]?.map( + (record) => record.country, + ).filter(isDefined) ?? []; + const disasterType = groupedSubcriptions[SUBSCRIPTION_DISASTER_TYPE]?.map( + (record) => record.dtype, + ).filter(isDefined) ?? []; + const followedEvent = groupedSubcriptions[SUBSCRIPTION_FOLLOWED_EVENTS]?.map( + (record) => record.event, + ).filter(isDefined) ?? []; + + setValue({ + weeklyDigest, + newEmergencies, + newOperations, + general, + region, + country, + disasterType, + surge, + surgeDM, + perDueDate, + followedEvent, + }); + }, + [user], + ); + + const handleChange = useCallback( + (...args: EntriesAsList) => { + const [val, key] = args; + setValue((prevValue): Value => ({ + ...prevValue, + [key]: val, + })); + }, + [], + ); + + const handleUpdateButtonClick = useCallback( + (fieldValues: Value) => { + const fieldKeys = Object.keys(fieldValues) as (keyof Value)[]; + // NOTE: using flatMap gives an error + const updates = fieldKeys.map( + (fieldKey) => { + const fieldValue = fieldValues[fieldKey]; + + if (typeof fieldValue === 'boolean' && fieldValue === true) { + return [{ + type: fieldKey, + value: fieldValue, + }]; + } + + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + return fieldValue.map( + (subValue) => ({ + type: fieldKey, + value: subValue, + }), + ); + } + + return []; + }, + ).flat(); + + // FIXME: fix typing in server (low priority) + updateUserSubscription(updates as never); + }, + [updateUserSubscription], + ); + + return ( + + {strings.subscriptionUpdateButtonLabel} + + )} + > + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SubscriptionPreferences; diff --git a/src/views/AccountNotifications/SubscriptionPreferences/styles.module.css b/app/src/views/AccountNotifications/SubscriptionPreferences/styles.module.css similarity index 100% rename from src/views/AccountNotifications/SubscriptionPreferences/styles.module.css rename to app/src/views/AccountNotifications/SubscriptionPreferences/styles.module.css diff --git a/src/views/AccountNotifications/i18n.json b/app/src/views/AccountNotifications/i18n.json similarity index 100% rename from src/views/AccountNotifications/i18n.json rename to app/src/views/AccountNotifications/i18n.json diff --git a/app/src/views/AccountNotifications/index.tsx b/app/src/views/AccountNotifications/index.tsx new file mode 100644 index 000000000..4ad58a482 --- /dev/null +++ b/app/src/views/AccountNotifications/index.tsx @@ -0,0 +1,101 @@ +import { useCallback } from 'react'; +import { + Container, + List, + Pager, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { numericIdSelector } from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; + +import OperationListItem, { type Props as OperationListItemProps } from '#components/domain/OperationListItem'; +import useFilterState from '#hooks/useFilterState'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import SubscriptionPreferences from './SubscriptionPreferences'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type OperationsResponse = GoApiResponse<'/api/v2/event/'>; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 5, + }); + + const { + error: subscribedEventsResponseError, + response: subscribedEventsResponse, + pending: subscribedEventsResponsePending, + retrigger: updateSubscribedEventsResponse, + } = useRequest({ + url: '/api/v2/event/', + query: { + limit, + offset, + is_subscribed: true, + }, + preserveResponse: true, + }); + + const rendererParams = useCallback( + ( + _: number, + operation: NonNullable[number], + i: number, + data: unknown[], + ): OperationListItemProps => ({ + eventItem: operation, + updateSubscibedEvents: updateSubscribedEventsResponse, + isLastItem: i === (data.length - 1), + }), + [updateSubscribedEventsResponse], + ); + + const eventList = subscribedEventsResponse?.results; + + return ( +
+ + )} + > + + + +
+ ); +} + +Component.displayName = 'AccountNotifications'; diff --git a/src/views/AccountNotifications/styles.module.css b/app/src/views/AccountNotifications/styles.module.css similarity index 100% rename from src/views/AccountNotifications/styles.module.css rename to app/src/views/AccountNotifications/styles.module.css diff --git a/src/views/AllAppeals/i18n.json b/app/src/views/AllAppeals/i18n.json similarity index 100% rename from src/views/AllAppeals/i18n.json rename to app/src/views/AllAppeals/i18n.json diff --git a/app/src/views/AllAppeals/index.tsx b/app/src/views/AllAppeals/index.tsx new file mode 100644 index 000000000..04d6fc913 --- /dev/null +++ b/app/src/views/AllAppeals/index.tsx @@ -0,0 +1,403 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + DateInput, + NumberOutput, + Pager, + SelectInput, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createNumberColumn, + createProgressColumn, + createStringColumn, + getPercentage, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import ExportButton from '#components/domain/ExportButton'; +import RegionSelectInput from '#components/domain/RegionSelectInput'; +import Page from '#components/Page'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type RegionResponse = GoApiResponse<'/api/v2/region/'>; +type RegionListItem = NonNullable[number]; + +type AppealResponse = GoApiResponse<'/api/v2/appeal/'>; +type AppealQueryParams = GoApiUrlQuery<'/api/v2/appeal/'>; +type AppealListItem = NonNullable[number]; + +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; +type AppealTypeOption = NonNullable[number]; + +const appealKeySelector = (option: AppealListItem) => option.id; +const appealTypeKeySelector = (option: AppealTypeOption) => option.key; +const appealTypeLabelSelector = (option: AppealTypeOption) => option.value; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + pageSize: 10, + }); + const alert = useAlert(); + + const { api_appeal_type: appealTypeOptions } = useGlobalEnums(); + + const [filterAppealType, setFilterAppealType] = useUrlSearchState( + 'atype', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + // FIXME: use enums + if (potentialValue === 0 + || potentialValue === 1 + || potentialValue === 2 + || potentialValue === 3 + ) { + return potentialValue; + } + + return undefined; + }, + (atype) => atype, + ); + const [filterDisasterType, setFilterDisasterType] = useUrlSearchState( + 'dtype', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (dtype) => dtype, + ); + const [filterRegion, setFilterRegion] = useUrlSearchState( + 'region', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + // FIXME: use enums + if (potentialValue === 0 + || potentialValue === 1 + || potentialValue === 2 + || potentialValue === 3 + || potentialValue === 4 + ) { + return potentialValue; + } + + return undefined; + }, + (regionId) => regionId, + ); + const [filterCountry, setFilterCountry] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const query = useMemo( + () => ({ + limit, + offset, + ordering, + atype: filterAppealType, + dtype: filterDisasterType, + country: filterCountry ? [filterCountry] : undefined, + region: filterRegion ? [filterRegion] : undefined, + start_date__gte: filter.startDateAfter, + start_date__lte: filter.startDateBefore, + }), + [ + limit, + offset, + ordering, + filterAppealType, + filterDisasterType, + filterCountry, + filterRegion, + filter, + ], + ); + const { + pending: appealsPending, + response: appealsResponse, + } = useRequest({ + url: '/api/v2/appeal/', + preserveResponse: true, + query, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'start_date', + strings.allAppealsStartDate, + (item) => item.start_date, + { + sortable: true, + columnClassName: styles.startDate, + }, + ), + createStringColumn( + 'atype', + strings.allAppealsType, + (item) => item.atype_display, + { + sortable: true, + columnClassName: styles.appealType, + }, + ), + createStringColumn( + 'code', + strings.allAppealsCode, + (item) => item.code, + { + columnClassName: styles.code, + }, + ), + createLinkColumn( + 'operation', + strings.allAppealsOperation, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item?.event }, + }), + { sortable: true }, + ), + createStringColumn( + 'dtype', + strings.allAppealsDisasterType, + (item) => item.dtype?.name, + ), + createNumberColumn( + 'amount_requested', + strings.allAppealsRequestedAmount, + (item) => item.amount_requested, + { + sortable: true, + suffix: ' CHF', + }, + ), + createProgressColumn( + 'amount_funded', + strings.allAppealsFundedAmount, + // FIXME: use progress function + (item) => ( + getPercentage( + item.amount_funded, + item.amount_requested, + ) + ), + { + sortable: true, + columnClassName: styles.funding, + }, + ), + createLinkColumn( + 'country', + strings.allAppealsCountry, + (item) => item.country?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { countryId: item.country?.id }, + }), + ), + ]), + [ + strings.allAppealsStartDate, + strings.allAppealsType, + strings.allAppealsCode, + strings.allAppealsOperation, + strings.allAppealsDisasterType, + strings.allAppealsRequestedAmount, + strings.allAppealsFundedAmount, + strings.allAppealsCountry, + ], + ); + + const heading = useMemo( + () => resolveToComponent( + strings.allAppealsHeading, + { + numAppeals: ( + + ), + }, + ), + [appealsResponse, strings.allAppealsHeading], + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'all-appeals.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!appealsResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/appeal/', + appealsResponse.count, + query, + ); + }, [ + query, + triggerExportStart, + appealsResponse?.count, + ]); + + const isFilterApplied = isDefined(filterDisasterType) + || isDefined(filterAppealType) + || isDefined(filterCountry) + || filtered; + + return ( + + + + + + + + + + )} + actions={( + + )} + footerActions={( + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllAppeals'; diff --git a/src/views/AllAppeals/styles.module.css b/app/src/views/AllAppeals/styles.module.css similarity index 100% rename from src/views/AllAppeals/styles.module.css rename to app/src/views/AllAppeals/styles.module.css diff --git a/src/views/AllDeployedEmergencyResponseUnits/i18n.json b/app/src/views/AllDeployedEmergencyResponseUnits/i18n.json similarity index 100% rename from src/views/AllDeployedEmergencyResponseUnits/i18n.json rename to app/src/views/AllDeployedEmergencyResponseUnits/i18n.json diff --git a/app/src/views/AllDeployedEmergencyResponseUnits/index.tsx b/app/src/views/AllDeployedEmergencyResponseUnits/index.tsx new file mode 100644 index 000000000..f15838591 --- /dev/null +++ b/app/src/views/AllDeployedEmergencyResponseUnits/index.tsx @@ -0,0 +1,277 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + Pager, + SelectInput, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createNumberColumn, + createStringColumn, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import Page from '#components/Page'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type EruTableItem = NonNullable['results']>[number]; +type GlobalEnumsResponse = GoApiResponse<'/api/v2/global-enums/'>; +type EruTypeOption = NonNullable[number]; + +function keySelector(personnel: EruTableItem) { + return personnel.id; +} +function eruTypeKeySelector(option: EruTypeOption) { + return option.key; +} +function eruTypeLabelSelector(option: EruTypeOption) { + return option.value; +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + rawFilter, + filter, + filtered, + setFilterField, + limit, + offset, + } = useFilterState<{ + eruType?: EruTypeOption['key'] + }>({ + filter: {}, + pageSize: 10, + }); + + const alert = useAlert(); + const { + deployments_eru_type: eruTypeOptions, + } = useGlobalEnums(); + + const eruTypes = useMemo( + () => ( + listToMap( + eruTypeOptions, + (item) => item.key, + (item) => item.value, + ) + ), + [eruTypeOptions], + ); + + const getEruType = useCallback( + (id: EruTableItem['type']) => { + if (isNotDefined(id) || isNotDefined(eruTypes)) { + return undefined; + } + return eruTypes[id]; + }, + [eruTypes], + ); + + // FIXME: Add types + const query = useMemo(() => ({ + limit, + offset, + ordering, + deployed_to__isnull: false, + type: filter.eruType, + }), [ + limit, + offset, + ordering, + filter, + ]); + + const { + response: eruResponse, + pending: eruPending, + } = useRequest({ + url: '/api/v2/eru/', + preserveResponse: true, + query, + }); + + const columns = useMemo( + () => ([ + createStringColumn( + 'eru_owner__national_society_country__society_name', + strings.eruTableName, + (item) => item.eru_owner.national_society_country.society_name + ?? item.eru_owner.national_society_country.name, + { sortable: true }, + ), + createStringColumn( + 'type', + strings.eruTableType, + (item) => getEruType(item.type), + { sortable: true }, + ), + createNumberColumn( + 'units', + strings.eruTablePersonnel, + (item) => item.units, + { sortable: true }, + ), + createNumberColumn( + 'equipment_units', + strings.eruTableEquipment, + (item) => item.equipment_units, + { sortable: true }, + ), + createLinkColumn( + 'deployed_to__society_name', + strings.eruTableCountry, + (item) => item.deployed_to.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { + countryId: item.deployed_to?.id, + }, + }), + { sortable: true }, + ), + createLinkColumn( + 'event__name', + strings.eruTableEmergency, + (item) => item.event?.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { + emergencyId: item.event?.id, + }, + }), + { sortable: true }, + ), + ]), + [ + getEruType, + strings.eruTableName, + strings.eruTableType, + strings.eruTablePersonnel, + strings.eruTableEquipment, + strings.eruTableCountry, + strings.eruTableEmergency, + ], + ); + const containerHeading = resolveToComponent( + strings.containerHeading, + { + count: eruResponse?.count ?? 0, + }, + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'deployed-erus.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!eruResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/eru/', + eruResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + eruResponse?.count, + ]); + + return ( + + + )} + withGridViewInFilter + actions={( + + )} + footerActions={( + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllDeployedEmergencyResponseUnits'; diff --git a/src/views/AllDeployedPersonnel/i18n.json b/app/src/views/AllDeployedPersonnel/i18n.json similarity index 100% rename from src/views/AllDeployedPersonnel/i18n.json rename to app/src/views/AllDeployedPersonnel/i18n.json diff --git a/app/src/views/AllDeployedPersonnel/index.tsx b/app/src/views/AllDeployedPersonnel/index.tsx new file mode 100644 index 000000000..c1144df5d --- /dev/null +++ b/app/src/views/AllDeployedPersonnel/index.tsx @@ -0,0 +1,298 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + DateInput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createStringColumn, + resolveToComponent, + toDateTimeString, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import Page from '#components/Page'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { COUNTRY_RECORD_TYPE_REGION } from '#utils/constants'; +import { countryIdToRegionIdMap } from '#utils/domain/country'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type PersonnelTableItem = NonNullable['results']>[number]; +function keySelector(personnel: PersonnelTableItem) { + return personnel.id; +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + pageSize: 10, + }); + const alert = useAlert(); + + const getTypeName = useCallback((type: PersonnelTableItem['type']) => { + if (type === 'rr') { + return strings.rapidResponse; + } + return type.toUpperCase(); + }, [strings.rapidResponse]); + + const query = useMemo(() => ({ + limit, + offset, + ordering, + // FIXME: The server does not support date string + start_date__gte: toDateTimeString(filter.startDateAfter), + start_date__lte: toDateTimeString(filter.startDateBefore), + }), [ + limit, + offset, + ordering, + filter, + ]); + + const { + response: personnelResponse, + pending: personnelPending, + } = useRequest({ + url: '/api/v2/personnel/', + preserveResponse: true, + query, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'start_date', + strings.personnelTableStartDate, + (item) => item.start_date, + { sortable: true }, + ), + createDateColumn( + 'end_date', + strings.personnelTableEndDate, + (item) => item.end_date, + { sortable: true }, + ), + createStringColumn( + 'name', + strings.personnelTableName, + (item) => item.name, + { sortable: true }, + ), + createStringColumn( + 'role', + strings.personnelTablePosition, + (item) => item.role, + { sortable: true }, + ), + createStringColumn( + 'type', + strings.personnelTableType, + (item) => getTypeName(item.type), + { sortable: true }, + ), + // NOTE:We don't have proper mapping for region + createLinkColumn( + 'country_from', + strings.personnelTableDeployingParty, + (item) => ( + item.country_from?.society_name + || item.country_from?.name + ), + (item) => { + if (isNotDefined(item.country_from)) { + return { to: undefined }; + } + + const countryId = item.country_from.id; + + if (item.country_from.record_type === COUNTRY_RECORD_TYPE_REGION) { + const regionId = isDefined(countryId) + ? countryIdToRegionIdMap[countryId] + : undefined; + + return { + to: 'regionsLayout', + urlParams: { regionId }, + }; + } + + return { + to: 'countriesLayout', + urlParams: { countryId }, + }; + }, + { sortable: true }, + ), + createLinkColumn( + 'country_to', + strings.personnelTableDeployedTo, + (item) => item.country_to?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { countryId: item.country_to?.id }, + }), + { sortable: true }, + ), + createLinkColumn( + 'event_deployed_to', + strings.personnelTableEmergency, + (item) => item.deployment?.event_deployed_to?.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { + emergencyId: item.deployment?.event_deployed_to?.id, + }, + }), + { + sortable: true, + }, + ), + ]), + [ + strings.personnelTableStartDate, + strings.personnelTableEndDate, + strings.personnelTableName, + strings.personnelTablePosition, + strings.personnelTableType, + strings.personnelTableDeployingParty, + strings.personnelTableDeployedTo, + strings.personnelTableEmergency, + getTypeName, + ], + ); + + const containerHeading = resolveToComponent( + strings.containerHeading, + { + count: personnelResponse?.count ?? 0, + }, + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'all-deployed-personnel.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!personnelResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/personnel/', + personnelResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + personnelResponse?.count, + ]); + + return ( + + + )} + withGridViewInFilter + filters={( + <> + + + + )} + footerActions={( + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllDeployedPersonnel'; diff --git a/src/views/AllEmergencies/i18n.json b/app/src/views/AllEmergencies/i18n.json similarity index 100% rename from src/views/AllEmergencies/i18n.json rename to app/src/views/AllEmergencies/i18n.json diff --git a/app/src/views/AllEmergencies/index.tsx b/app/src/views/AllEmergencies/index.tsx new file mode 100644 index 000000000..63db92ced --- /dev/null +++ b/app/src/views/AllEmergencies/index.tsx @@ -0,0 +1,353 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + DateInput, + NumberOutput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createNumberColumn, + createStringColumn, + resolveToComponent, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + max, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import ExportButton from '#components/domain/ExportButton'; +import RegionSelectInput from '#components/domain/RegionSelectInput'; +import Page from '#components/Page'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import { + createCountryListColumn, + createLinkColumn, +} from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + type GoApiUrlQuery, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type RegionResponse = GoApiResponse<'/api/v2/region/'>; +type RegionListItem = NonNullable[number]; + +type EventResponse = GoApiResponse<'/api/v2/event/'>; +type EventQueryParams = GoApiUrlQuery<'/api/v2/event/'>; +type EventListItem = NonNullable[number]; + +function getMostRecentAffectedValue(fieldReport: EventListItem['field_reports']) { + const latestReport = max(fieldReport, (item) => new Date(item.updated_at).getTime()); + return latestReport?.num_affected; +} + +const eventKeySelector = (item: EventListItem) => item.id; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + pageSize: 15, + }); + const alert = useAlert(); + + const columns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.allEmergenciesDate, + (item) => item.created_at, + { + sortable: true, + columnClassName: styles.createdAt, + }, + ), + createLinkColumn( + 'event_name', + strings.allEmergenciesName, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.id }, + }), + { sortable: true }, + ), + createStringColumn( + 'dtype', + strings.allEmergenciesDisasterType, + (item) => item.dtype?.name, + ), + createStringColumn( + 'glide', + strings.allEmergenciesGlide, + (item) => item.glide, + { sortable: true }, + ), + createNumberColumn( + 'amount_requested', + strings.allEmergenciesRequestedAmt, + (item) => sumSafe( + item.appeals.map((appeal) => appeal.amount_requested), + ), + { + suffix: ' CHF', + }, + ), + createNumberColumn( + 'num_affected', + strings.allEmergenciesAffected, + (item) => item.num_affected ?? getMostRecentAffectedValue(item.field_reports), + { sortable: true }, + ), + createCountryListColumn( + 'countries', + strings.allEmergenciesCountry, + (item) => item.countries, + ), + ]), + [ + strings.allEmergenciesDate, + strings.allEmergenciesName, + strings.allEmergenciesDisasterType, + strings.allEmergenciesGlide, + strings.allEmergenciesRequestedAmt, + strings.allEmergenciesAffected, + strings.allEmergenciesCountry, + ], + ); + + const [filterDisasterType, setFilterDisasterType] = useUrlSearchState( + 'dtype', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (dtype) => dtype, + ); + const [filterRegion, setFilterRegion] = useUrlSearchState( + 'region', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + // FIXME: use region enum + if (potentialValue === 0 + || potentialValue === 1 + || potentialValue === 2 + || potentialValue === 3 + || potentialValue === 4 + ) { + return potentialValue; + } + + return undefined; + }, + (regionId) => regionId, + ); + const [filterCountry, setFilterCountry] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const query = useMemo( + () => ({ + limit, + offset, + ordering, + dtype: filterDisasterType, + // FIXME: The server should actually accept array of number instead + // of just number + regions__in: isDefined(filterRegion) ? filterRegion : undefined, + countries__in: filterCountry, + disaster_start_date__gte: filter.startDateAfter, + disaster_start_date__lte: filter.startDateBefore, + }), + [ + limit, + offset, + ordering, + filterDisasterType, + filterRegion, + filterCountry, + filter, + ], + ); + + const { + pending: eventPending, + response: eventResponse, + } = useRequest({ + url: '/api/v2/event/', + preserveResponse: true, + query, + }); + + const heading = useMemo( + () => resolveToComponent( + strings.allEmergenciesHeading, + { + numEmergencies: ( + + ), + }, + ), + [eventResponse, strings.allEmergenciesHeading], + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'all-emergencies.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!eventResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/event/', + eventResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + eventResponse?.count, + ]); + + const isFiltered = isDefined(filterDisasterType) + || isDefined(filterRegion) + || isDefined(filterCountry) + || filtered; + + return ( + + + + + + + + + )} + actions={( + + )} + footerActions={( + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllEmergencies'; diff --git a/src/views/AllEmergencies/styles.module.css b/app/src/views/AllEmergencies/styles.module.css similarity index 100% rename from src/views/AllEmergencies/styles.module.css rename to app/src/views/AllEmergencies/styles.module.css diff --git a/src/views/AllFieldReports/i18n.json b/app/src/views/AllFieldReports/i18n.json similarity index 100% rename from src/views/AllFieldReports/i18n.json rename to app/src/views/AllFieldReports/i18n.json diff --git a/app/src/views/AllFieldReports/index.tsx b/app/src/views/AllFieldReports/index.tsx new file mode 100644 index 000000000..122f3de51 --- /dev/null +++ b/app/src/views/AllFieldReports/index.tsx @@ -0,0 +1,323 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + DateInput, + NumberOutput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createStringColumn, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import ExportButton from '#components/domain/ExportButton'; +import RegionSelectInput from '#components/domain/RegionSelectInput'; +import Page from '#components/Page'; +import { components } from '#generated/types'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import { + createCountryListColumn, + createLinkColumn, +} from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type FieldReportResponse = GoApiResponse<'/api/v2/field-report/'>; +type FieldReportListItem = NonNullable[number]; + +type RegionOption = components<'read'>['schemas']['ApiRegionNameEnum']; + +const fieldReportKeySelector = (item: FieldReportListItem) => item.id; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + createdDateAfter?: string, + createdDateBefore?: string, + }>({ + filter: {}, + pageSize: 15, + }); + const alert = useAlert(); + const [filterDisasterType, setFilterDisasterType] = useUrlSearchState( + 'dtype', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (dtype) => dtype, + ); + const [filterCountry, setFilterCountry] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + const [filterRegion, setFilterRegion] = useUrlSearchState( + 'region', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + if (potentialValue === 0 + || potentialValue === 1 + || potentialValue === 2 + || potentialValue === 3 + || potentialValue === 4 + ) { + return potentialValue; + } + return undefined; + }, + (region) => region, + ); + + const columns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.allFieldReportsCreatedAt, + (item) => item.start_date, + { + sortable: true, + columnClassName: styles.createdAt, + }, + ), + createLinkColumn( + 'summary', + strings.allFieldReportsName, + (item) => item.summary, + (item) => ({ + to: 'fieldReportDetails', + urlParams: { fieldReportId: item.id }, + }), + { + sortable: true, + columnClassName: styles.summary, + }, + ), + createLinkColumn( + 'event_name', + strings.allFieldReportsEmergency, + (item) => item.event_details?.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.event }, + }), + ), + createStringColumn( + 'dtype', + strings.allFieldReportsDisasterType, + (item) => item.dtype_details?.name, + { sortable: true }, + ), + createCountryListColumn( + 'countries', + strings.allFieldReportsCountries, + (item) => item.countries_details, + ), + ]), + [ + strings.allFieldReportsCreatedAt, + strings.allFieldReportsName, + strings.allFieldReportsEmergency, + strings.allFieldReportsDisasterType, + strings.allFieldReportsCountries, + ], + ); + + const query = useMemo(() => ({ + limit, + offset, + ordering, + dtype: filterDisasterType, + countries__in: filterCountry, + regions__in: filterRegion, + created_at__gte: filter.createdDateAfter, + created_at__lte: filter.createdDateBefore, + }), [ + limit, + offset, + ordering, + filterDisasterType, + filterCountry, + filterRegion, + filter, + ]); + + const { + pending: fieldReportPending, + response: fieldReportResponse, + } = useRequest({ + url: '/api/v2/field-report/', + preserveResponse: true, + query, + }); + + const fieldReportFiltered = ( + isDefined(filterDisasterType) + || isDefined(filterCountry) + || filtered + ); + + const heading = useMemo( + () => resolveToComponent( + strings.allFieldReportsHeading, + { + numFieldReports: ( + + ), + }, + ), + [fieldReportResponse, strings.allFieldReportsHeading], + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'field-reports.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!fieldReportResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/field-report/', + fieldReportResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + fieldReportResponse?.count, + ]); + + return ( + + + + + + + + + )} + actions={( + + )} + footerActions={( + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllFieldReports'; diff --git a/src/views/AllFieldReports/styles.module.css b/app/src/views/AllFieldReports/styles.module.css similarity index 100% rename from src/views/AllFieldReports/styles.module.css rename to app/src/views/AllFieldReports/styles.module.css diff --git a/src/views/AllFlashUpdates/FlashUpdatesTableActions/i18n.json b/app/src/views/AllFlashUpdates/FlashUpdatesTableActions/i18n.json similarity index 100% rename from src/views/AllFlashUpdates/FlashUpdatesTableActions/i18n.json rename to app/src/views/AllFlashUpdates/FlashUpdatesTableActions/i18n.json diff --git a/app/src/views/AllFlashUpdates/FlashUpdatesTableActions/index.tsx b/app/src/views/AllFlashUpdates/FlashUpdatesTableActions/index.tsx new file mode 100644 index 000000000..743d37727 --- /dev/null +++ b/app/src/views/AllFlashUpdates/FlashUpdatesTableActions/index.tsx @@ -0,0 +1,49 @@ +import { + PencilFillIcon, + ShareBoxLineIcon, +} from '@ifrc-go/icons'; +import { TableActions } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +export interface Props { + flashUpdateId: number; +} + +function FlashUpdatesTableActions(props: Props) { + const { + flashUpdateId, + } = props; + + const strings = useTranslation(i18n); + + return ( + + } + > + {strings.flashUpdateViewDetails} + + } + > + {strings.flashUpdateEdit} + + + )} + /> + ); +} + +export default FlashUpdatesTableActions; diff --git a/src/views/AllFlashUpdates/i18n.json b/app/src/views/AllFlashUpdates/i18n.json similarity index 100% rename from src/views/AllFlashUpdates/i18n.json rename to app/src/views/AllFlashUpdates/i18n.json diff --git a/app/src/views/AllFlashUpdates/index.tsx b/app/src/views/AllFlashUpdates/index.tsx new file mode 100644 index 000000000..dc715a627 --- /dev/null +++ b/app/src/views/AllFlashUpdates/index.tsx @@ -0,0 +1,171 @@ +import { useMemo } from 'react'; +import { + Container, + NumberOutput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createStringColumn, + resolveToComponent, +} from '@ifrc-go/ui/utils'; + +import Page from '#components/Page'; +import useFilterState from '#hooks/useFilterState'; +import { + createCountryListColumn, + createLinkColumn, +} from '#utils/domain/tableHelpers'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import FlashUpdatesTableAction, { Props as FlashUpdatesTableActions } from './FlashUpdatesTableActions'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type FlashUpdateResponse = GoApiResponse<'/api/v2/flash-update/'>; +type FlashUpdateListItem = NonNullable[number]; +type TableKey = number; + +const keySelector = (item: FlashUpdateListItem) => item.id; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 15, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.allFlashUpdatesLastUpdate, + (item) => item.created_at, + { + sortable: true, + columnClassName: styles.createdAt, + }, + ), + createLinkColumn( + 'title', + strings.allFlashUpdatesReport, + (item) => item.title, + (item) => ({ + to: 'flashUpdateFormDetails', + urlParams: { flashUpdateId: item.id }, + }), + { + sortable: true, + columnClassName: styles.title, + }, + ), + createStringColumn( + 'hazard_type', + strings.allFlashUpdatesDisasterType, + (item) => item.hazard_type_details.name, + { sortable: true }, + ), + createCountryListColumn( + 'country_district', + strings.allFlashUpdatesCountry, + (item) => item.country_district?.map( + (country_district) => country_district.country_details, + ), + ), + createElementColumn< + FlashUpdateListItem, + number, + FlashUpdatesTableActions + >( + 'actions', + '', + FlashUpdatesTableAction, + (flashUpdateId) => ({ + type: 'activity', + flashUpdateId, + }), + ), + ]), + [ + strings.allFlashUpdatesLastUpdate, + strings.allFlashUpdatesReport, + strings.allFlashUpdatesDisasterType, + strings.allFlashUpdatesCountry, + ], + ); + + const { + pending: flashUpdatePending, + response: flashUpdateResponse, + } = useRequest({ + url: '/api/v2/flash-update/', + preserveResponse: true, + query: { + limit, + offset, + ordering, + }, + }); + + const heading = useMemo( + () => resolveToComponent( + strings.allFlashUpdatesHeading, + { + numFlashUpdates: ( + + ), + }, + ), + [strings.allFlashUpdatesHeading, flashUpdateResponse], + ); + + return ( + + + )} + > + +
+ + + + ); +} + +Component.displayName = 'AllFlashUpdates'; diff --git a/src/views/AllFlashUpdates/styles.module.css b/app/src/views/AllFlashUpdates/styles.module.css similarity index 100% rename from src/views/AllFlashUpdates/styles.module.css rename to app/src/views/AllFlashUpdates/styles.module.css diff --git a/src/views/AllSurgeAlerts/i18n.json b/app/src/views/AllSurgeAlerts/i18n.json similarity index 100% rename from src/views/AllSurgeAlerts/i18n.json rename to app/src/views/AllSurgeAlerts/i18n.json diff --git a/app/src/views/AllSurgeAlerts/index.tsx b/app/src/views/AllSurgeAlerts/index.tsx new file mode 100644 index 000000000..5e9c43aae --- /dev/null +++ b/app/src/views/AllSurgeAlerts/index.tsx @@ -0,0 +1,370 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + Container, + NumberOutput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createStringColumn, + getDuration, + numericIdSelector, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import CountrySelectInput from '#components/domain/CountrySelectInput'; +import EventSearchSelectInput from '#components/domain/EventSearchSelectInput'; +import ExportButton from '#components/domain/ExportButton'; +import Page from '#components/Page'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type SurgeResponse = GoApiResponse<'/api/v2/surge_alert/'>; +type SurgeListItem = NonNullable[number]; + +type GetEventResponse = GoApiResponse<'/api/v2/event/mini/'>; +export type EventItem = Pick[number], 'id' | 'name' | 'dtype'>; + +type TableKey = number; +const nowTimestamp = new Date().getTime(); + +function getPositionString(alert: SurgeListItem) { + if (isNotDefined(alert.molnix_id)) { + return alert.message; + } + return alert.message?.split(',')[0]; +} + +function getMolnixKeywords(molnixTags: SurgeListItem['molnix_tags']) { + return molnixTags + .map((tag) => tag.name) + .filter((tag) => !tag.startsWith('OP-')) + .filter((tag) => !['Nosuitable', 'NotSurge', 'OpsChange'].includes(tag)) + .join(', '); +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 15, + }); + + const [countryFilter, setCountryFilter] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const [eventFilter, setEventFilter] = useUrlSearchState( + 'event', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const [eventOptions, setEventOptions] = useState< + EventItem[] | undefined | null + >([]); + + useRequest({ + skip: isNotDefined(eventFilter) + || (!!eventOptions?.find((event) => event.id === eventFilter)), + url: '/api/v2/event/{id}/', + pathVariables: isDefined(eventFilter) ? { + id: eventFilter, + } : undefined, + onSuccess: (response) => { + if (isNotDefined(response)) { + return; + } + + const { + id, + dtype, + name, + } = response; + + if (isNotDefined(id) || isNotDefined(dtype) || isNotDefined(name)) { + return; + } + + const newOption = { + id, + dtype: { + id: dtype, + translation_module_original_language: 'en' as const, + name: undefined, + summary: undefined, + }, + name, + }; + + setEventOptions((prevOptions) => ([ + ...prevOptions ?? [], + newOption, + ])); + }, + }); + + const { + response: surgeResponse, + pending: surgeResponsePending, + } = useRequest({ + url: '/api/v2/surge_alert/', + preserveResponse: true, + query: { + limit, + offset, + event: eventFilter, + country: countryFilter, + }, + }); + + const alert = useAlert(); + const getStatus = useCallback( + (surgeAlert: SurgeListItem) => { + if (surgeAlert.is_stood_down) { + return strings.surgeAlertStoodDown; + } + const endDate = isDefined(surgeAlert.end) + ? new Date(surgeAlert.end) + : undefined; + + const closed = isDefined(endDate) + ? endDate.getTime() < nowTimestamp + : false; + + return closed ? strings.surgeAlertClosed : strings.surgeAlertOpen; + }, + [ + strings.surgeAlertStoodDown, + strings.surgeAlertClosed, + strings.surgeAlertOpen, + ], + ); + + const columns = useMemo( + () => ([ + createDateColumn( + 'created_at', + strings.surgeAlertDate, + (item) => item.created_at, + ), + createStringColumn( + 'duration', + strings.surgeAlertDuration, + (item) => { + if (isNotDefined(item.created_at) || isNotDefined(item.end)) { + return undefined; + } + + const alertDate = new Date(item.created_at); + const deadline = new Date(item.end); + const duration = getDuration(alertDate, deadline); + + return duration; + }, + ), + createStringColumn( + 'start', + strings.surgeAlertStartDate, + (item) => { + if (isNotDefined(item.start)) { + return undefined; + } + + const startDate = new Date(item.start); + const nowMs = new Date().getTime(); + + const duration = startDate.getTime() < nowMs + ? strings.surgeAlertImmediately + : startDate.toLocaleString(); + + return duration; + }, + ), + createStringColumn( + 'name', + strings.surgeAlertPosition, + (item) => getPositionString(item), + ), + createStringColumn( + 'keywords', + strings.surgeAlertKeywords, + (item) => getMolnixKeywords(item.molnix_tags), + ), + createLinkColumn( + 'emergency', + strings.surgeAlertEmergency, + (item) => item.event?.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.event?.id }, + }), + ), + createLinkColumn( + 'country', + strings.surgeAlertCountry, + (item) => item.country?.name, + (item) => ({ + to: 'countriesLayout', + urlParams: { + countryId: item.country?.id, + }, + }), + ), + createStringColumn( + 'status', + strings.surgeAlertStatus, + (item) => getStatus(item), + ), + ]), + [ + strings.surgeAlertImmediately, + strings.surgeAlertDate, + strings.surgeAlertDuration, + strings.surgeAlertStartDate, + strings.surgeAlertPosition, + strings.surgeAlertKeywords, + strings.surgeAlertEmergency, + strings.surgeAlertCountry, + strings.surgeAlertStatus, + getStatus, + ], + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'surge-alerts.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!surgeResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/surge_alert/', + surgeResponse?.count, + {}, + ); + }, [ + triggerExportStart, + surgeResponse?.count, + ]); + + const heading = resolveToComponent( + strings.allSurgeAlertsHeading, + { numSurgeAlerts: }, + ); + + return ( + + + + + + )} + footerActions={( + + )} + actions={( + + )} + > +
+ + + ); +} + +Component.displayName = 'AllSurgeAlerts'; diff --git a/src/views/AllSurgeAlerts/styles.module.css b/app/src/views/AllSurgeAlerts/styles.module.css similarity index 100% rename from src/views/AllSurgeAlerts/styles.module.css rename to app/src/views/AllSurgeAlerts/styles.module.css diff --git a/src/views/AllThreeWActivity/AllThreeWProjectTableActions/i18n.json b/app/src/views/AllThreeWActivity/AllThreeWProjectTableActions/i18n.json similarity index 100% rename from src/views/AllThreeWActivity/AllThreeWProjectTableActions/i18n.json rename to app/src/views/AllThreeWActivity/AllThreeWProjectTableActions/i18n.json diff --git a/app/src/views/AllThreeWActivity/AllThreeWProjectTableActions/index.tsx b/app/src/views/AllThreeWActivity/AllThreeWProjectTableActions/index.tsx new file mode 100644 index 000000000..f05c88707 --- /dev/null +++ b/app/src/views/AllThreeWActivity/AllThreeWProjectTableActions/index.tsx @@ -0,0 +1,58 @@ +import { + CopyLineIcon, + PencilFillIcon, + ShareBoxLineIcon, +} from '@ifrc-go/icons'; +import { TableActions } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +export interface Props { + activityId: number; +} + +function AllThreeWActivityTableActions(props: Props) { + const { + activityId, + } = props; + + const strings = useTranslation(i18n); + + return ( + + } + > + {strings.threeWActivityViewDetails} + + } + > + {strings.threeWActivityEdit} + + } + > + {strings.threeWActivityDuplicate} + + + )} + /> + ); +} + +export default AllThreeWActivityTableActions; diff --git a/src/views/AllThreeWActivity/i18n.json b/app/src/views/AllThreeWActivity/i18n.json similarity index 100% rename from src/views/AllThreeWActivity/i18n.json rename to app/src/views/AllThreeWActivity/i18n.json diff --git a/app/src/views/AllThreeWActivity/index.tsx b/app/src/views/AllThreeWActivity/index.tsx new file mode 100644 index 000000000..e1bc51129 --- /dev/null +++ b/app/src/views/AllThreeWActivity/index.tsx @@ -0,0 +1,332 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + Pager, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createListDisplayColumn, + createNumberColumn, + createStringColumn, + formatNumber, + numericIdSelector, + resolveToComponent, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import Page from '#components/Page'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import ThreeWActivityTableActions, { type Props as ThreeWActivityTableActionsProps } from './AllThreeWProjectTableActions'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ProjectsResponse = GoApiResponse<'/api/v2/emergency-project/'>; +type ProjectListItem = NonNullable[number]; +type ActivityListItem = NonNullable[number]; +type DistrictListItem = NonNullable[number]; + +type TableKey = number; + +interface DistrictNameOutputProps { + name: string; +} +function DistrictNameOutput({ name }: DistrictNameOutputProps) { + return name; +} + +function districtKeySelector(item: DistrictListItem) { + return item.id; +} + +function getPeopleReachedInActivity(activity: ActivityListItem) { + const { + is_simplified_report, + people_count, + male_0_1_count, + male_2_5_count, + male_6_12_count, + male_13_17_count, + male_18_59_count, + male_60_plus_count, + male_unknown_age_count, + female_0_1_count, + female_2_5_count, + female_6_12_count, + female_13_17_count, + female_18_59_count, + female_60_plus_count, + female_unknown_age_count, + other_0_1_count, + other_2_5_count, + other_6_12_count, + other_13_17_count, + other_18_59_count, + other_60_plus_count, + other_unknown_age_count, + } = activity; + + if (is_simplified_report) { + return people_count ?? 0; + } + + return sumSafe([ + male_0_1_count, + male_2_5_count, + male_6_12_count, + male_13_17_count, + male_18_59_count, + male_60_plus_count, + male_unknown_age_count, + + female_0_1_count, + female_2_5_count, + female_6_12_count, + female_13_17_count, + female_18_59_count, + female_60_plus_count, + female_unknown_age_count, + + other_0_1_count, + other_2_5_count, + other_6_12_count, + other_13_17_count, + other_18_59_count, + other_60_plus_count, + other_unknown_age_count, + ]); +} + +function getPeopleReached(project: ProjectListItem) { + return sumSafe(project.activities?.map(getPeopleReachedInActivity)); +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const [filterCountry] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const alert = useAlert(); + const { + page: projectActivePage, + setPage: setProjectActivePage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 15, + }); + + const query = useMemo(() => ({ + limit, + offset, + country: isDefined(filterCountry) ? [filterCountry] : undefined, + }), [ + limit, + offset, + filterCountry, + ]); + + const { + response: projectResponse, + pending: projectResponsePending, + } = useRequest({ + url: '/api/v2/emergency-project/', + preserveResponse: true, + query, + }); + + const districtRendererParams = useCallback( + (value: DistrictListItem) => ({ + name: value.name, + }), + [], + ); + + const columns = useMemo( + () => ([ + createStringColumn( + 'ns', + strings.allThreeWActivityNS, + (item) => item.reporting_ns_details?.society_name, + ), + createLinkColumn( + 'title', + strings.allThreeWActivityTitle, + (item) => item.title, + (item) => ({ + to: 'threeWActivityDetail', + urlParams: { activityId: item.id }, + }), + ), + createDateColumn( + 'start_date', + strings.allThreeWActivityStartDate, + (item) => item.start_date, + ), + createLinkColumn( + 'country', + strings.allThreeWCountry, + (activity) => activity.country_details.name, + (activity) => ({ + to: 'countriesLayout', + urlParams: { + countryId: activity.country, + }, + }), + ), + createListDisplayColumn< + ProjectListItem, + number, + DistrictListItem, + DistrictNameOutputProps + >( + 'districts', + strings.allThreeWActivityRegion, + (activity) => ({ + list: activity.districts_details, + renderer: DistrictNameOutput, + rendererParams: districtRendererParams, + keySelector: districtKeySelector, + }), + ), + createStringColumn( + 'disasterType', + strings.allThreeWActivityStatus, + (item) => item.status_display, + ), + createNumberColumn( + 'people_count', + strings.allThreeWActivityPeopleReached, + (item) => getPeopleReached(item), + ), + createElementColumn< + ProjectListItem, + number, + ThreeWActivityTableActionsProps + >( + 'actions', + '', + ThreeWActivityTableActions, + (activityId) => ({ + activityId, + }), + ), + ]), + [ + strings.allThreeWActivityNS, + strings.allThreeWActivityTitle, + strings.allThreeWActivityStartDate, + strings.allThreeWCountry, + strings.allThreeWActivityRegion, + strings.allThreeWActivityStatus, + strings.allThreeWActivityPeopleReached, + districtRendererParams, + ], + ); + + const heading = resolveToComponent( + strings.allThreeWActivityHeading, + { numThreeWs: formatNumber(projectResponse?.count) ?? '--' }, + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'all-3w-emergency-projects.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!projectResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/emergency-project/', + projectResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + projectResponse?.count, + ]); + return ( + + + )} + footerActions={( + + )} + > +
+ + + ); +} + +Component.displayName = 'AllThreeWActivity'; diff --git a/src/views/AllThreeWActivity/styles.module.css b/app/src/views/AllThreeWActivity/styles.module.css similarity index 100% rename from src/views/AllThreeWActivity/styles.module.css rename to app/src/views/AllThreeWActivity/styles.module.css diff --git a/src/views/AllThreeWProject/AllThreeWProjectTableActions/i18n.json b/app/src/views/AllThreeWProject/AllThreeWProjectTableActions/i18n.json similarity index 100% rename from src/views/AllThreeWProject/AllThreeWProjectTableActions/i18n.json rename to app/src/views/AllThreeWProject/AllThreeWProjectTableActions/i18n.json diff --git a/app/src/views/AllThreeWProject/AllThreeWProjectTableActions/index.tsx b/app/src/views/AllThreeWProject/AllThreeWProjectTableActions/index.tsx new file mode 100644 index 000000000..4395e5cbb --- /dev/null +++ b/app/src/views/AllThreeWProject/AllThreeWProjectTableActions/index.tsx @@ -0,0 +1,49 @@ +import { + PencilFillIcon, + ShareBoxLineIcon, +} from '@ifrc-go/icons'; +import { TableActions } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import DropdownMenuItem from '#components/DropdownMenuItem'; + +import i18n from './i18n.json'; + +export interface Props { + projectId: number; +} + +function AllThreeWProjectTableActions(props: Props) { + const { + projectId, + } = props; + + const strings = useTranslation(i18n); + + return ( + + } + > + {strings.threeWViewDetails} + + } + > + {strings.threeWEdit} + + + )} + /> + ); +} + +export default AllThreeWProjectTableActions; diff --git a/src/views/AllThreeWProject/i18n.json b/app/src/views/AllThreeWProject/i18n.json similarity index 100% rename from src/views/AllThreeWProject/i18n.json rename to app/src/views/AllThreeWProject/i18n.json diff --git a/app/src/views/AllThreeWProject/index.tsx b/app/src/views/AllThreeWProject/index.tsx new file mode 100644 index 000000000..4d76c66b6 --- /dev/null +++ b/app/src/views/AllThreeWProject/index.tsx @@ -0,0 +1,248 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Container, + Pager, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createElementColumn, + createNumberColumn, + createStringColumn, + formatNumber, + numericIdSelector, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import Page from '#components/Page'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import useUrlSearchState from '#hooks/useUrlSearchState'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import ThreeWProjectTableActions, { type Props as ThreeWProjectTableActionsProps } from './AllThreeWProjectTableActions'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type ProjectsResponse = GoApiResponse<'/api/v2/project/'>; +type ProjectListItem = NonNullable[number]; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const [filterCountry] = useUrlSearchState( + 'country', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (country) => country, + ); + + const [filterReportingNS] = useUrlSearchState( + 'reporting_ns', + (searchValue) => { + const potentialValue = isDefined(searchValue) ? Number(searchValue) : undefined; + return potentialValue; + }, + (reportingNs) => reportingNs, + ); + const alert = useAlert(); + + const { + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 15, + }); + + const query = useMemo(() => ({ + limit, + offset, + country: isDefined(filterCountry) ? [filterCountry] : undefined, + reporting_ns: isDefined(filterReportingNS) ? [filterReportingNS] : undefined, + }), [ + limit, + offset, + filterCountry, + filterReportingNS, + ]); + + const { + response: projectResponse, + pending: projectResponsePending, + } = useRequest({ + url: '/api/v2/project/', + preserveResponse: true, + query, + }); + + const projectColumns = useMemo( + () => ([ + createStringColumn( + 'country', + strings.allThreeWCountry, + (item) => item.project_country_detail?.name, + ), + createStringColumn( + 'ns', + strings.allThreeWNS, + (item) => item.reporting_ns_detail?.society_name, + ), + createStringColumn( + 'name', + strings.allThreeWProjectName, + (item) => item.name, + ), + createStringColumn( + 'sector', + strings.allThreeWSector, + (item) => item.primary_sector_display, + ), + createNumberColumn( + 'budget', + strings.allThreeWTotalBudget, + (item) => item.budget_amount, + undefined, + ), + createStringColumn( + 'programmeType', + strings.allThreeWProgrammeType, + (item) => item.programme_type_display, + ), + createStringColumn( + 'disasterType', + strings.allThreeWDisasterType, + (item) => item.dtype_detail?.name, + ), + createNumberColumn( + 'peopleTargeted', + strings.allThreeWPeopleTargeted, + (item) => item.target_total, + undefined, + ), + createNumberColumn( + 'peopleReached', + strings.allThreeWPeopleReached, + (item) => item.reached_total, + undefined, + ), + createElementColumn< + ProjectListItem, + number, + ThreeWProjectTableActionsProps + >( + 'actions', + '', + ThreeWProjectTableActions, + (projectId) => ({ + projectId, + }), + ), + ]), + [ + strings.allThreeWCountry, + strings.allThreeWNS, + strings.allThreeWProjectName, + strings.allThreeWSector, + strings.allThreeWTotalBudget, + strings.allThreeWProgrammeType, + strings.allThreeWDisasterType, + strings.allThreeWPeopleTargeted, + strings.allThreeWPeopleReached, + ], + ); + + const heading = resolveToComponent( + strings.allThreeWHeading, + { numThreeWs: formatNumber(projectResponse?.count) ?? '--' }, + ); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, 'all-3w-projects.csv'); + }, + }); + + const handleExportClick = useCallback(() => { + if (!projectResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/project/', + projectResponse?.count, + query, + ); + }, [ + query, + triggerExportStart, + projectResponse?.count, + ]); + + return ( + + + )} + footerActions={( + + )} + > +
+ + + ); +} + +Component.displayName = 'AllThreeWProject'; diff --git a/src/views/AllThreeWProject/styles.module.css b/app/src/views/AllThreeWProject/styles.module.css similarity index 100% rename from src/views/AllThreeWProject/styles.module.css rename to app/src/views/AllThreeWProject/styles.module.css diff --git a/src/views/Country/i18n.json b/app/src/views/Country/i18n.json similarity index 100% rename from src/views/Country/i18n.json rename to app/src/views/Country/i18n.json diff --git a/app/src/views/Country/index.tsx b/app/src/views/Country/index.tsx new file mode 100644 index 000000000..bb2d40a81 --- /dev/null +++ b/app/src/views/Country/index.tsx @@ -0,0 +1,232 @@ +import { + useContext, + useMemo, +} from 'react'; +import { + generatePath, + Navigate, + Outlet, + useParams, +} from 'react-router-dom'; +import { PencilFillIcon } from '@ifrc-go/icons'; +import { + Breadcrumbs, + Message, + NavigationTabList, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import NavigationTab from '#components/NavigationTab'; +import Page from '#components/Page'; +import { adminUrl } from '#config'; +import RouteContext from '#contexts/route'; +import useAuth from '#hooks/domain/useAuth'; +import useCountry from '#hooks/domain/useCountry'; +import useRegion from '#hooks/domain/useRegion'; +import { + countryIdToRegionIdMap, + isCountryIdRegion, +} from '#utils/domain/country'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { resolveUrl } from '#utils/resolveUrl'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId } = useParams<{ countryId: string }>(); + const { regionIndex } = useContext(RouteContext); + + const strings = useTranslation(i18n); + const country = useCountry({ id: Number(countryId) }); + const region = useRegion({ id: country?.region }); + + const numericCountryId = isDefined(countryId) ? Number(countryId) : undefined; + const isRegion = isCountryIdRegion(numericCountryId); + + const { + pending: countryResponsePending, + response: countryResponse, + error: countryResponseError, + } = useRequest({ + skip: isNotDefined(countryId) || isRegion, + url: '/api/v2/country/{id}/', + pathVariables: { + id: Number(countryId), + }, + }); + + const { isAuthenticated } = useAuth(); + + const outletContext = useMemo( + () => ({ + countryId, + countryResponse, + countryResponsePending, + }), + [countryResponse, countryId, countryResponsePending], + ); + + const additionalInfoTabName = isTruthyString(countryResponse?.additional_tab_name) + ? countryResponse?.additional_tab_name + : strings.countryAdditionalInfoTab; + + const hasAdditionalInfoData = !!countryResponse && ( + isTruthyString(countryResponse.additional_tab_name) + || (countryResponse.links && countryResponse.links.length > 0) + || (countryResponse.contacts && countryResponse.contacts.length > 0) + ); + + const pageTitle = resolveToString( + strings.countryPageTitle, + { countryName: country?.name ?? strings.countryPageTitleFallbackCountry }, + ); + + if (isDefined(numericCountryId) && isRegion) { + const regionId = countryIdToRegionIdMap[numericCountryId]; + + const regionPath = generatePath( + regionIndex.absoluteForwardPath, + { regionId }, + ); + + return ( + + ); + } + + if (isDefined(countryResponseError)) { + return ( + + + + ); + } + + if (isDefined(countryResponse) && isFalsyString(countryResponse.iso3)) { + return ( + + + + ); + } + + return ( + + + {strings.home} + + + {region?.region_name} + + + {country?.name} + + + )} + description={ + isDefined(countryResponse) + && isDefined(countryResponse.regions_details?.id) + && ( + + {countryResponse?.regions_details?.region_name} + + ) + } + actions={isAuthenticated && ( + } + > + {strings.editCountryLink} + + )} + > + + + {strings.ongoingActivitiesTabTitle} + + + {strings.nsOverviewTabTitle} + + + {strings.countryProfileTabTitle} + + {hasAdditionalInfoData && ( + + {additionalInfoTabName} + + )} + + + + ); +} + +Component.displayName = 'Country'; diff --git a/src/views/Country/styles.module.css b/app/src/views/Country/styles.module.css similarity index 100% rename from src/views/Country/styles.module.css rename to app/src/views/Country/styles.module.css diff --git a/src/views/CountryAdditionalInfo/i18n.json b/app/src/views/CountryAdditionalInfo/i18n.json similarity index 100% rename from src/views/CountryAdditionalInfo/i18n.json rename to app/src/views/CountryAdditionalInfo/i18n.json diff --git a/app/src/views/CountryAdditionalInfo/index.tsx b/app/src/views/CountryAdditionalInfo/index.tsx new file mode 100644 index 000000000..db12cd470 --- /dev/null +++ b/app/src/views/CountryAdditionalInfo/index.tsx @@ -0,0 +1,173 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + useOutletContext, + useParams, +} from 'react-router-dom'; +import { + Container, + HtmlOutput, + HtmlOutputProps, + List, + Message, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; + +import Link, { type Props as LinkProps } from '#components/Link'; +import { + type CountryOutletContext, + type CountryResponse, +} from '#utils/outletContext'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountrySnippetType = NonNullable['results']>[number]; +type CountryContactsType = NonNullable[number]; +type CountryLinksType = NonNullable[number]; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId } = useParams<{ countryId: string }>(); + const { countryResponse } = useOutletContext(); + + const { + pending: countrySnippetPending, + response: countrySnippetResponse, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/country_snippet/', + query: { country: Number(countryId) }, + }); + + const hasCountrySnippet = countrySnippetResponse?.results + && countrySnippetResponse.results.length > 0; + + const hasCountryContacts = countryResponse?.contacts + && countryResponse.contacts.length > 0; + + const hasCountryLinks = countryResponse?.links + && countryResponse.links.length > 0; + + const strings = useTranslation(i18n); + + const countrySnippetRendererParams = useCallback( + (_: number, data: CountrySnippetType): HtmlOutputProps => ({ + value: data.snippet, + }), + [], + ); + + const countryLinkRendererParams = useCallback( + (_: number, data: CountryLinksType): LinkProps => ({ + href: data.url, + external: true, + withLinkIcon: true, + children: data.title, + }), + [], + ); + + const contactTableColumns = useMemo( + () => ([ + createStringColumn( + 'name', + strings.columnName, + (item) => item.name, + ), + createStringColumn( + 'title', + strings.columnTitle, + (item) => item.title, + ), + createStringColumn( + 'ctype', + strings.columnType, + (item) => item.ctype, + ), + createStringColumn( + 'email', + strings.columnEmail, + (item) => item.email, + ), + ]), + [ + strings.columnName, + strings.columnTitle, + strings.columnType, + strings.columnEmail, + ], + ); + + const isDataAvailable = hasCountryLinks || hasCountryContacts || hasCountrySnippet; + + return ( +
+ {hasCountrySnippet && ( + + )} + {hasCountryContacts && ( + +
+ + )} + {hasCountryLinks && ( + + + + )} + {!isDataAvailable && ( + + )} + + ); +} + +Component.displayName = 'CountryAdditionalInfo'; diff --git a/src/views/CountryAdditionalInfo/styles.module.css b/app/src/views/CountryAdditionalInfo/styles.module.css similarity index 100% rename from src/views/CountryAdditionalInfo/styles.module.css rename to app/src/views/CountryAdditionalInfo/styles.module.css diff --git a/src/views/CountryNsOverview/i18n.json b/app/src/views/CountryNsOverview/i18n.json similarity index 100% rename from src/views/CountryNsOverview/i18n.json rename to app/src/views/CountryNsOverview/i18n.json diff --git a/app/src/views/CountryNsOverview/index.tsx b/app/src/views/CountryNsOverview/index.tsx new file mode 100644 index 000000000..bc5aa587a --- /dev/null +++ b/app/src/views/CountryNsOverview/index.tsx @@ -0,0 +1,53 @@ +import { + Outlet, + useOutletContext, +} from 'react-router-dom'; +import { NavigationTabList } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import NavigationTab from '#components/NavigationTab'; +import { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const outletContext = useOutletContext(); + const { countryId } = outletContext; + const strings = useTranslation(i18n); + + return ( +
+ + + {strings.nsActivitiesTabTitle} + + + {strings.contextAndStructureTabTitle} + + + {strings.strategicPrioritiesTabTitle} + + + {strings.capacityTabTitle} + + + +
+ ); +} + +Component.displayName = 'CountryNsOverview'; diff --git a/src/views/CountryNsOverview/styles.module.css b/app/src/views/CountryNsOverview/styles.module.css similarity index 100% rename from src/views/CountryNsOverview/styles.module.css rename to app/src/views/CountryNsOverview/styles.module.css diff --git a/src/views/CountryNsOverviewActivities/Filters/i18n.json b/app/src/views/CountryNsOverviewActivities/Filters/i18n.json similarity index 100% rename from src/views/CountryNsOverviewActivities/Filters/i18n.json rename to app/src/views/CountryNsOverviewActivities/Filters/i18n.json diff --git a/app/src/views/CountryNsOverviewActivities/Filters/index.tsx b/app/src/views/CountryNsOverviewActivities/Filters/index.tsx new file mode 100644 index 000000000..4a05686b1 --- /dev/null +++ b/app/src/views/CountryNsOverviewActivities/Filters/index.tsx @@ -0,0 +1,119 @@ +import { MultiSelectInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + numericIdSelector, + numericKeySelector, + stringLabelSelector, + stringValueSelector, +} from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; +import { EntriesAsList } from '@togglecorp/toggle-form'; + +import useCountryRaw, { Country } from '#hooks/domain/useCountryRaw'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function countryNameSelector(country: Country) { + return country.name ?? country.id.toString(); +} + +export interface FilterValue { + project_country?: number[]; + operation_type?: number[]; + programme_type?: number[]; + primary_sector?: number[]; + secondary_sectors?: number[]; +} + +interface Props { + className?: string; + value: FilterValue; + onChange: (...args: EntriesAsList) => void; + disabled?: boolean; +} + +function Filters(props: Props) { + const { + className, + value, + onChange, + disabled, + } = props; + + const { + deployments_project_operation_type: projectOperationTypeOptions, + deployments_project_programme_type: programmeTypeOptions, + } = useGlobalEnums(); + + const strings = useTranslation(i18n); + + const { response: primarySectorResponse } = useRequest({ + url: '/api/v2/primarysector', + }); + + const { response: secondarySectorResponse } = useRequest({ + url: '/api/v2/secondarysector', + }); + + const countries = useCountryRaw(); + + return ( +
+ + + + + +
+ ); +} + +export default Filters; diff --git a/src/views/CountryNsOverviewActivities/Filters/styles.module.css b/app/src/views/CountryNsOverviewActivities/Filters/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewActivities/Filters/styles.module.css rename to app/src/views/CountryNsOverviewActivities/Filters/styles.module.css diff --git a/src/views/CountryNsOverviewActivities/Map/i18n.json b/app/src/views/CountryNsOverviewActivities/Map/i18n.json similarity index 100% rename from src/views/CountryNsOverviewActivities/Map/i18n.json rename to app/src/views/CountryNsOverviewActivities/Map/i18n.json diff --git a/app/src/views/CountryNsOverviewActivities/Map/index.tsx b/app/src/views/CountryNsOverviewActivities/Map/index.tsx new file mode 100644 index 000000000..afb45ea26 --- /dev/null +++ b/app/src/views/CountryNsOverviewActivities/Map/index.tsx @@ -0,0 +1,487 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + LegendItem, + Message, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + isTruthyString, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import getBbox from '@turf/bbox'; +import { + LineLayout, + LinePaint, + SymbolLayout, + SymbolPaint, +} from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import Link from '#components/Link'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import useCountry from '#hooks/domain/useCountry'; +import useCountryRaw, { Country } from '#hooks/domain/useCountryRaw'; +import { + COLOR_BLACK, + COLOR_BLUE, + COLOR_RED, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; +import { + adminFillLayerOptions, + getPointCircleHaloPaint, + getPointCirclePaint, +} from '#utils/map'; +import { CountryOutletContext } from '#utils/outletContext'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type Project = NonNullable['results']>[number]; + +const linePaint: LinePaint = { + 'line-color': COLOR_BLACK, + 'line-opacity': 0.4, + 'line-width': 1, +}; +const lineLayout: LineLayout = { + visibility: 'visible', + 'line-join': 'round', + 'line-cap': 'round', +}; + +const arrowPaint: SymbolPaint = { + 'icon-color': COLOR_BLACK, + 'icon-opacity': 0.6, +}; + +const arrowLayout: SymbolLayout = { + visibility: 'visible', + 'icon-allow-overlap': true, + 'symbol-placement': 'line-center', + 'icon-image': 'triangle-11', + 'icon-size': 1, + 'icon-rotate': 90, +}; + +const redPointCirclePaint = getPointCirclePaint(COLOR_RED); +const bluePointCirclePaint = getPointCirclePaint(COLOR_BLUE); +const sourceOption: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +interface GeoJsonProps { + countryName: string | null | undefined; + countryId: number; + numProjects: number; +} + +interface ClickedPoint { + countryId: number, + countryName: string; + lngLat: mapboxgl.LngLatLike; +} + +type ProjectGeoJson = GeoJSON.FeatureCollection; +type ProjectLineGeoJson = GeoJSON.FeatureCollection; + +function generateProjectsLineGeoJson( + countries: Country[], + projectList: Project[], +): ProjectLineGeoJson | undefined { + const relationsMap = listToMap( + projectList, + (project) => `${project.project_country}-${project.reporting_ns}`, + (project) => ({ + id: `${project.project_country}-${project.reporting_ns}`, + projectCountry: project.project_country, + reportingNS: project.reporting_ns, + }), + ); + + const countriesMap = listToMap( + countries, + (country) => country.id, + (country) => country, + ); + + const relationsList = mapToList(relationsMap); + + if (relationsList.length < 1) { + return undefined; + } + + return { + type: 'FeatureCollection' as const, + features: relationsList.map((relation) => { + const from = countriesMap[relation.reportingNS] + ?.centroid.coordinates as [number, number] | undefined; + const to = countriesMap[relation.projectCountry] + ?.centroid.coordinates as [number, number] | undefined; + if (isNotDefined(from) || isNotDefined(to)) { + return undefined; + } + return { + id: relation.id, + type: 'Feature' as const, + geometry: { + type: 'LineString' as const, + coordinates: [ + from, + to, + ], + }, + properties: { + projectCountry: relation.projectCountry, + reportingNS: relation.reportingNS, + }, + }; + }).filter(isDefined), + }; +} + +function generateProjectGeoJson( + countries: Country[], + projectList: Project[], + keySelector: (item: Project) => number, +): ProjectGeoJson | undefined { + const groupedProjects = listToGroupList(projectList, keySelector); + + if (countries.length < 1) { + return undefined; + } + + return { + type: 'FeatureCollection' as const, + features: countries.map((country) => { + const { centroid } = country; + if (isNotDefined(centroid)) { + return undefined; + } + + const countryProjects = groupedProjects?.[country.id]; + if (isNotDefined(countryProjects)) { + return undefined; + } + + return { + id: country.id, + type: 'Feature' as const, + properties: { + countryName: country.name, + countryId: country.id, + numProjects: countryProjects.length, + }, + geometry: { + type: 'Point' as const, + coordinates: centroid.coordinates as [number, number], + }, + }; + }).filter(isDefined), + }; +} + +interface Props { + className?: string; + projectList: Project[]; + sidebarContent?: React.ReactNode; +} +function CountryThreeWNationalSocietyProjectsMap(props: Props) { + const { + className, + projectList, + sidebarContent, + } = props; + + const strings = useTranslation(i18n); + const countries = useCountryRaw(); + const { + countryResponse, + } = useOutletContext(); + + const [ + clickedPointProperties, + setClickedPointProperties, + ] = useState(); + + const projectCountryId = projectList.find( + (project) => project.project_country === clickedPointProperties?.countryId, + ); + + const clickedPointCountry = useCountry({ + id: projectCountryId?.project_country ?? -1, + }); + + const iso3 = clickedPointCountry?.iso3; + + const { + response: clickedPointProjectsResponse, + pending: clickedPointProjectsResponsePending, + } = useRequest({ + skip: isNotDefined(clickedPointCountry?.iso3), + url: '/api/v2/project/', + query: { + country_iso3: isTruthyString(iso3) + ? [iso3] + : undefined, + }, + }); + + const { + receivingCountryProjectGeoJson, + reportingNSProjectGeoJson, + projectsLineGeoJson, + } = useMemo( + () => ({ + receivingCountryProjectGeoJson: generateProjectGeoJson( + countries ?? [], + projectList, + (project) => project.project_country, + ), + reportingNSProjectGeoJson: generateProjectGeoJson( + countries ?? [], + projectList, + (project) => project.reporting_ns, + ), + projectsLineGeoJson: generateProjectsLineGeoJson( + countries ?? [], + projectList, + ), + }), + [countries, projectList], + ); + + const bounds = useMemo(() => { + if (isDefined(projectsLineGeoJson)) { + return getBbox(projectsLineGeoJson); + } + return undefined; + }, [projectsLineGeoJson]); + + const maxScaleValue = projectList?.length ?? 0; + + const { + redPointHaloCirclePaint, + bluePointHaloCirclePaint, + } = useMemo( + () => ({ + redPointHaloCirclePaint: getPointCircleHaloPaint(COLOR_RED, 'numProjects', maxScaleValue), + bluePointHaloCirclePaint: getPointCircleHaloPaint(COLOR_BLUE, 'numProjects', maxScaleValue), + }), + [maxScaleValue], + ); + + const handleCountryClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + countryId: feature.properties?.country_id, + countryName: feature.properties?.name, + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + countryId: feature.properties?.countryId, + countryName: feature.properties?.countryName, + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClose = useCallback( + () => { + setClickedPointProperties(undefined); + }, + [setClickedPointProperties], + ); + + return ( +
+
+ + )} + > + + + +
+ )} + /> + {receivingCountryProjectGeoJson && ( + + + + + )} + {reportingNSProjectGeoJson && ( + + + + + )} + {projectsLineGeoJson && ( + + + + + )} + {/* eslint-disable-next-line max-len */} + {clickedPointProperties?.lngLat + && isDefined(projectCountryId) + && ( + + {(clickedPointProjectsResponsePending + || clickedPointProjectsResponse?.count === 0) + && ( + + + )} + {clickedPointProjectsResponse?.results?.map( + (project) => ( + + {project.name} + + ), + )} + + )} + {isDefined(bounds) && ( + + )} + +
+ {sidebarContent && ( +
+ {sidebarContent} +
+ )} + + ); +} + +export default CountryThreeWNationalSocietyProjectsMap; diff --git a/src/views/CountryNsOverviewActivities/Map/styles.module.css b/app/src/views/CountryNsOverviewActivities/Map/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewActivities/Map/styles.module.css rename to app/src/views/CountryNsOverviewActivities/Map/styles.module.css diff --git a/src/views/CountryNsOverviewActivities/i18n.json b/app/src/views/CountryNsOverviewActivities/i18n.json similarity index 100% rename from src/views/CountryNsOverviewActivities/i18n.json rename to app/src/views/CountryNsOverviewActivities/i18n.json diff --git a/app/src/views/CountryNsOverviewActivities/index.tsx b/app/src/views/CountryNsOverviewActivities/index.tsx new file mode 100644 index 000000000..c2194dc47 --- /dev/null +++ b/app/src/views/CountryNsOverviewActivities/index.tsx @@ -0,0 +1,524 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { AddFillIcon } from '@ifrc-go/icons'; +import { + BlockLoading, + Container, + ExpandableContainer, + KeyFigure, + Message, + PieChart, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateRangeColumn, + createElementColumn, + createNumberColumn, + createStringColumn, + numericIdSelector, + numericValueSelector, + resolveToString, + stringLabelSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, + unique, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import ProjectActions, { Props as ProjectActionsProps } from '#components/domain/ProjectActions'; +import Link from '#components/Link'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { PROJECT_STATUS_ONGOING } from '#utils/constants'; +import type { CountryOutletContext } from '#utils/outletContext'; +import { type GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import Filter, { FilterValue } from './Filters'; +import Map from './Map'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface LabelValue { + label: string; + value: number; +} + +type Project = NonNullable['results']>[number]; +type ProjectKey = 'project_country' | 'operation_type' | 'programme_type' | 'primary_sector' | 'secondary_sectors'; +const emptyProjectList: Project[] = []; + +const primaryRedColorShades = [ + 'var(--go-ui-color-red-90)', + 'var(--go-ui-color-red-60)', + 'var(--go-ui-color-red-40)', + 'var(--go-ui-color-red-20)', + 'var(--go-ui-color-red-10)', +]; + +function filterProjects(projectList: Project[], filters: Partial>) { + return projectList.filter((project) => ( + Object.entries(filters).every(([filterKey, filterValue]) => { + const projectValue = project[filterKey as ProjectKey]; + + if (isNotDefined(filterValue) || filterValue.length === 0) { + return true; + } + + if (isNotDefined(projectValue)) { + return false; + } + + if (Array.isArray(projectValue)) { + return projectValue.some((v) => filterValue.includes(v)); + } + + return filterValue.includes(projectValue); + }) + )); +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const alert = useAlert(); + + const { + countryResponse, + countryResponsePending, + } = useOutletContext(); + + const { + rawFilter, + filter, + filtered, + setFilterField, + } = useFilterState({ + filter: {}, + }); + + const { + pending: projectListPending, + response: projectListResponse, + retrigger: reTriggerProjectListRequest, + } = useRequest({ + skip: isNotDefined(countryResponse?.id), + url: '/api/v2/project/', + query: { + limit: 9999, + reporting_ns: isDefined(countryResponse) ? [countryResponse.id] : undefined, + }, + }); + + const projectList = projectListResponse?.results ?? emptyProjectList; + const filteredProjectList = filterProjects(projectList, filter); + + const { + ongoingProjects, + targetedPopulation, + ongoingProjectBudget, + programmeTypeStats, + projectStatusTypeStats, + countryCountWithNSProjects, + } = useMemo(() => { + const projectsOngoing = filteredProjectList + .filter((p) => p.status === PROJECT_STATUS_ONGOING); + + const ongoingBudget = sumSafe(projectsOngoing?.map((d) => d.budget_amount)) ?? 0; + + const peopleTargeted = sumSafe(filteredProjectList?.map((d) => d.target_total)) ?? 0; + + const programmeTypeGrouped = ( + listToGroupList( + filteredProjectList, + (d) => d.programme_type_display, + (d) => d, + ) ?? {} + ); + + const programmeTypes: LabelValue[] = mapToList( + programmeTypeGrouped, + (d, k) => ({ label: String(k), value: d.length }), + ); + + const statusGrouped = ( + listToGroupList( + filteredProjectList, + (d) => d.status_display, + (d) => d, + ) ?? {} + ); + + const projectStatusTypes: LabelValue[] = mapToList( + statusGrouped, + (d, k) => ({ label: String(k), value: d.length }), + ); + + const numCountriesWithNSProjects = unique(projectsOngoing, (d) => d.project_country).length; + + return { + ongoingProjects: projectsOngoing, + targetedPopulation: peopleTargeted, + ongoingProjectBudget: ongoingBudget, + programmeTypeStats: programmeTypes, + projectStatusTypeStats: projectStatusTypes, + countryCountWithNSProjects: numCountriesWithNSProjects, + }; + }, [filteredProjectList]); + + const countryGroupedProjects = useMemo(() => ( + listToGroupList(ongoingProjects, (project) => project.project_country) + ), [ongoingProjects]); + + const tableColumns = useMemo(() => ([ + createStringColumn( + 'ns', + strings.nSTableCountries, + (item) => item.project_country_detail.name, + ), + createDateRangeColumn( + 'startDate', + strings.nSStartDate, + (item) => ({ + startDate: item.start_date, + endDate: item.end_date, + }), + ), + createStringColumn( + 'disasterType', + strings.nSTableDisasterType, + (item) => item.dtype_detail?.name, + ), + createStringColumn( + 'sector', + strings.nSTableSector, + (item) => item.primary_sector_display, + ), + createStringColumn( + 'name', + strings.nSTableProjectName, + (item) => item.name, + ), + + createNumberColumn( + 'budget', + strings.nSTableTotalBudget, + (item) => item.budget_amount, + undefined, + ), + createNumberColumn( + 'peopleTargeted', + strings.nSTablePeopleTargeted, + (item) => item.target_total, + undefined, + ), + createNumberColumn( + 'peopleReached', + strings.nSTablePeopleReached, + (item) => item.reached_total, + undefined, + ), + createStringColumn( + 'contact', + strings.nSContactPerson, + (item) => ([ + item.reporting_ns_contact_name, + item.reporting_ns_contact_email, + ].filter(isDefined).join(', ')), + ), + createElementColumn( + 'actions', + '', + ProjectActions, + (_, project) => ({ + onProjectDeletionSuccess: reTriggerProjectListRequest, + className: styles.actions, + project, + }), + ), + ]), [ + reTriggerProjectListRequest, + strings.nSTableCountries, + strings.nSTableProjectName, + strings.nSTableSector, + strings.nSTableTotalBudget, + strings.nSTableDisasterType, + strings.nSTablePeopleTargeted, + strings.nSTablePeopleReached, + strings.nSContactPerson, + strings.nSStartDate, + ]); + + const countryIdList = Object.keys(countryGroupedProjects); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.nSFailedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, `${countryResponse?.society_name}-international-works.csv`); + }, + }); + + const handleExportClick = useCallback(() => { + if (!projectListResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/project/', + projectListResponse?.count, + { + reporting_ns: isDefined(countryResponse) ? [countryResponse.id] : undefined, + }, + ); + }, [ + countryResponse, + triggerExportStart, + projectListResponse?.count, + ]); + + const showCard1 = countryCountWithNSProjects > 0 || targetedPopulation > 0; + const showCard2 = filteredProjectList.length > 0 || programmeTypeStats.length > 0; + const showCard3 = ongoingProjectBudget > 0 || projectStatusTypeStats.length > 0; + + const showCardsSection = showCard1 || showCard2 || showCard3; + + return ( + } + > + {strings.addNSActivity} + + )} + > + {projectListPending && } + {!projectListPending && showCardsSection && ( +
+ {showCard1 && ( +
+ +
+ +
+ )} + {showCard2 && ( +
+ +
+ + + +
+ )} + {showCard3 && ( +
+ +
+ + + +
+ )} +
+ )} + + )} + actions={( + + )} + > + + {countryIdList.map((countryId) => { + const projectsInCountry = countryGroupedProjects[countryId]; + + if ( + isNotDefined(projectsInCountry) + || projectsInCountry.length === 0 + ) { + return null; + } + + // NOTE: we will always have at least one project as it is + // project list is aggregated using listToGroupList + const countryName = projectsInCountry[0] + .project_country_detail.name; + + return ( + + {/* NOTE: projects array will always have an element + * as we are using listToGroupList to get it. + */} + {projectsInCountry.map((project) => ( +
+
+ {project.name} +
+ +
+ ))} +
+ ); + })} + {/* FIXME: Show empty message for when filter is applied */} + {/* FIXME: Show empty message for when filter is not applied */} + {/* FIXME: Use List component instead? */} + {countryIdList.length === 0 && ( + + )} +
+ )} + /> + + + {strings.nSAllProjects} + + )} + > +
+ + + ); +} + +Component.displayName = 'CountryNsOverviewActivities'; diff --git a/src/views/CountryNsOverviewActivities/styles.module.css b/app/src/views/CountryNsOverviewActivities/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewActivities/styles.module.css rename to app/src/views/CountryNsOverviewActivities/styles.module.css diff --git a/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/i18n.json b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/i18n.json similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/i18n.json rename to app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/i18n.json diff --git a/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/index.tsx b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/index.tsx new file mode 100644 index 000000000..c4dbb5635 --- /dev/null +++ b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/index.tsx @@ -0,0 +1,74 @@ +import { ArrowRightUpLineIcon } from '@ifrc-go/icons'; +import { + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import Link from '#components/Link'; +import { components } from '#generated/types'; +import { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CapacityItem = NonNullable['capacity']>[number]; +type AssessmentTypeEnum = components<'read'>['schemas']['AssessmentTypeEnum']; + +const TYPE_OCAC = 0 satisfies AssessmentTypeEnum; +const TYPE_BOCA = 1 satisfies AssessmentTypeEnum; + +interface Props { + capacity: CapacityItem; +} + +function CapacityListItem(props: Props) { + const { + capacity, + } = props; + + const strings = useTranslation(i18n); + + return ( + } + external + > + {strings.capacityListItemViewDetails} + + )} + > + {capacity?.assessment_type === TYPE_OCAC && ( + + )} + {capacity?.assessment_type === TYPE_BOCA && ( + + )} + + ); +} + +export default CapacityListItem; diff --git a/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/styles.module.css b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/styles.module.css rename to app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/CapacityListItem/styles.module.css diff --git a/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/i18n.json b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/i18n.json similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/i18n.json rename to app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/i18n.json diff --git a/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/index.tsx b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/index.tsx new file mode 100644 index 000000000..c83e39f43 --- /dev/null +++ b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/index.tsx @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Container, + List, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { CountryOutletContext } from '#utils/outletContext'; + +import CapacityListItem from './CapacityListItem'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CapacityItem = NonNullable['capacity']>[number]; + +function capacityKeySelector(option: CapacityItem) { + return option.id; +} + +function CountryNsCapacityStrengthening() { + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + + const rendererParams = useCallback( + (_: number, capacity: CapacityItem) => ({ + capacity, + }), + [], + ); + + return ( + + + + ); +} + +export default CountryNsCapacityStrengthening; diff --git a/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/styles.module.css b/app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/styles.module.css rename to app/src/views/CountryNsOverviewCapacity/CountryNsCapacityStrengthening/styles.module.css diff --git a/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/i18n.json b/app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/i18n.json similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/i18n.json rename to app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/i18n.json diff --git a/app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/index.tsx b/app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/index.tsx new file mode 100644 index 000000000..943ede4a7 --- /dev/null +++ b/app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/index.tsx @@ -0,0 +1,71 @@ +import { useOutletContext } from 'react-router-dom'; +import { Container } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; + +import { type CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function CountryNsOrganisationalCapacity() { + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + + const organizationalCapacity = countryResponse?.organizational_capacity; + + return ( + + {isDefined(organizationalCapacity?.leadership_capacity) && ( + + {organizationalCapacity?.leadership_capacity} + + )} + {isDefined(organizationalCapacity?.youth_capacity) && ( + + {organizationalCapacity?.youth_capacity} + + )} + {isDefined(organizationalCapacity?.volunteer_capacity) && ( + + {organizationalCapacity?.volunteer_capacity} + + )} + {isDefined(organizationalCapacity?.financial_capacity) && ( + + {organizationalCapacity?.financial_capacity} + + )} + + ); +} + +export default CountryNsOrganisationalCapacity; diff --git a/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/styles.module.css b/app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/styles.module.css rename to app/src/views/CountryNsOverviewCapacity/CountryNsOrganisationalCapacity/styles.module.css diff --git a/src/views/CountryNsOverviewCapacity/i18n.json b/app/src/views/CountryNsOverviewCapacity/i18n.json similarity index 100% rename from src/views/CountryNsOverviewCapacity/i18n.json rename to app/src/views/CountryNsOverviewCapacity/i18n.json diff --git a/app/src/views/CountryNsOverviewCapacity/index.tsx b/app/src/views/CountryNsOverviewCapacity/index.tsx new file mode 100644 index 000000000..14ca0e13b --- /dev/null +++ b/app/src/views/CountryNsOverviewCapacity/index.tsx @@ -0,0 +1,136 @@ +import { useOutletContext } from 'react-router-dom'; +import { + BlockLoading, + Container, + Message, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { REGION_ASIA } from '#utils/constants'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import CountryNsCapacityStrengthening from './CountryNsCapacityStrengthening'; +import CountryNsOrganisationalCapacity from './CountryNsOrganisationalCapacity'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId, countryResponse } = useOutletContext(); + + const strings = useTranslation(i18n); + + const { + pending: countryStatusPending, + response: countryStatusResponse, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/per-process-status/', + query: { + country: isDefined(countryId) ? [Number(countryId)] : undefined, + limit: 9999, + }, + }); + + const hasPer = isDefined(countryStatusResponse) + && isDefined(countryStatusResponse.results) + && countryStatusResponse.results.length > 0; + + return ( + + {strings.nsOverviewCapacityLink} + + )} + > + {countryResponse?.region === REGION_ASIA && ( + + )} + + {countryStatusPending && } + + {strings.perStartPerProcess} + + )} + > + {!hasPer && ( + + )} + {hasPer && countryStatusResponse?.results?.map( + (perProcess) => ( + + {strings.perViewLink} + + )} + > + + + + ), + )} + + + ); +} + +Component.displayName = 'CountryNsOverviewCapacity'; diff --git a/src/views/CountryNsOverviewCapacity/styles.module.css b/app/src/views/CountryNsOverviewCapacity/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewCapacity/styles.module.css rename to app/src/views/CountryNsOverviewCapacity/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/index.tsx new file mode 100644 index 000000000..c2f974125 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/index.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Container, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; + +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { type CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + className?: string; +} +function NationalSocietyContacts(props: Props) { + const { className } = props; + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + type ContactListItem = NonNullable['contacts']>[number]; + + const contacts = countryResponse?.contacts; + + const columns = useMemo( + () => ([ + createStringColumn( + 'name', + '', + (item) => item.name, + { + cellRendererClassName: styles.name, + }, + ), + createStringColumn( + 'title', + '', + (item) => item.title, + ), + createLinkColumn( + 'email', + '', + (item) => item.email, + (item) => ({ + href: `mailto:${item.email}`, + external: true, + }), + ), + ]), + [], + ); + + return ( + +
+ + ); +} + +export default NationalSocietyContacts; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyContacts/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/index.tsx new file mode 100644 index 000000000..c7502361d --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/index.tsx @@ -0,0 +1,61 @@ +import { useOutletContext } from 'react-router-dom'; +import { + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + className?: string; +} + +function NationalSocietyDirectory(props: Props) { + const { className } = props; + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + const directoryList = countryResponse?.directory; + + return ( + + {countryResponse?.society_name} + + )} + /> + )} + withHeaderBorder + > + {directoryList?.map((directory) => ( + + ))} + + ); +} + +export default NationalSocietyDirectory; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyDirectory/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/index.tsx new file mode 100644 index 000000000..e1c251b12 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/index.tsx @@ -0,0 +1,110 @@ +import { useOutletContext } from 'react-router-dom'; +import { + BlockLoading, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { sumSafe } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function NationalSocietyIndicators() { + const strings = useTranslation(i18n); + + const { countryId } = useOutletContext(); + + const { + pending: indicatorPending, + response: indicatorResponse, + } = useRequest({ + url: '/api/v2/country/{id}/databank/', + skip: isNotDefined(countryId), + pathVariables: isDefined(countryId) ? { + id: Number(countryId), + } : undefined, + }); + + const youthValue = sumSafe([ + indicatorResponse?.volunteer_age_6_12, + indicatorResponse?.volunteer_age_13_17, + indicatorResponse?.volunteer_age_18_29, + indicatorResponse?.staff_age_18_29, + ]); + + return ( + + {indicatorPending && } + + + + + + + + + + + ); +} + +export default NationalSocietyIndicators; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyIndicators/styles.module.css diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/index.tsx new file mode 100644 index 000000000..2bfe5efa5 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/index.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { DownloadFillIcon } from '@ifrc-go/icons'; +import { + Container, + Table, +} from '@ifrc-go/ui'; +import { + createDateColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; + +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { GoApiResponse } from '#utils/restRequest'; + +import styles from './styles.module.css'; + +type GetKeyDocumentResponse = GoApiResponse<'/api/v2/country-document/'>; +type KeyDocumentItem = NonNullable[number]; + +interface Props { + label: string; + documents: KeyDocumentItem[]; +} + +function documentKeySelector(document: KeyDocumentItem) { + return document.id; +} + +function DocumentListCard(props: Props) { + const { + label, + documents, + } = props; + + const columns = useMemo( + () => ([ + createStringColumn( + 'name', + '', + (item) => item.name, + ), + createDateColumn( + 'date', + '', + (item) => item.year, + { + columnClassName: styles.date, + }, + ), + createLinkColumn( + 'url', + '', + () => , + (item) => ({ + external: true, + href: item.url, + }), + ), + ]), + [ + ], + ); + + return ( + +
+ + ); +} + +export default DocumentListCard; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/DocumentListCard/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/index.tsx new file mode 100644 index 000000000..dac3e3ef8 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyKeyDocuments/index.tsx @@ -0,0 +1,135 @@ +import { useCallback } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { + Container, + DateInput, + Grid, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import useFilterState from '#hooks/useFilterState'; +import { CountryOutletContext } from '#utils/outletContext'; +import { + GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import DocumentListCard from './DocumentListCard'; + +import i18n from './i18n.json'; + +type GetKeyDocumentResponse = GoApiResponse<'/api/v2/country-document/'>; +type KeyDocumentItem = NonNullable[number]; + +interface GroupedDocuments { + label: string; + documents: KeyDocumentItem[]; +} + +function groupedDocumentsListKeySelector(groupedDocuments: GroupedDocuments) { + return groupedDocuments.label; +} + +function NationalSocietyKeyDocuments() { + const strings = useTranslation(i18n); + + const { countryId } = useOutletContext(); + const { + filter, + rawFilter, + filtered, + setFilterField, + } = useFilterState<{ + searchText?: string, + startDateAfter?: string, + startDateBefore?: string, + }>({ + filter: {}, + }); + const { + response: documentResponse, + pending: documentResponsePending, + error: documentResponseError, + } = useRequest({ + url: '/api/v2/country-document/', + skip: isNotDefined(countryId), + query: { + country: isDefined(countryId) ? Number(countryId) : undefined, + search: filter.searchText, + year__gte: filter.startDateAfter, + year__lte: filter.startDateBefore, + }, + preserveResponse: true, + }); + + const groupedDocumentsByType = ( + listToGroupList( + documentResponse?.results, + (item) => item.document_type, + (item) => item, + ) + ); + + const groupedDocumentsList = mapToList( + groupedDocumentsByType, + (documents, documentType) => ({ label: documentType, documents }), + ); + + const rendererParams = useCallback((label: string, groupedDocuments: GroupedDocuments) => ({ + label, + documents: groupedDocuments.documents, + }), []); + + return ( + + } + /> + + + + )} + > + + + ); +} + +export default NationalSocietyKeyDocuments; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/index.tsx new file mode 100644 index 000000000..93f6f77c8 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/index.tsx @@ -0,0 +1,361 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { SearchLineIcon } from '@ifrc-go/icons'; +import { + Button, + Container, + SelectInput, + TextInput, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + stringLabelSelector, + stringNameSelector, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import getBbox from '@turf/bbox'; +import type { + CircleLayer, + CirclePaint, + FillLayer, +} from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import Link from '#components/Link'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import { type components } from '#generated/types'; +import useFilterState from '#hooks/useFilterState'; +import { + COLOR_RED, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const basePointPaint: CirclePaint = { + 'circle-radius': 5, + 'circle-color': COLOR_RED, +}; + +const basePointLayerOptions: Omit = { + type: 'circle', + paint: basePointPaint, +}; +const sourceOption: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +type LocalUnitType = components<'read'>['schemas']['LocalUnitType']; + +const localUnitCodeSelector = (localUnit: LocalUnitType) => localUnit.code; + +interface Validation { + label: string; +} + +interface ClickedPoint { + id: string; + lngLat: mapboxgl.LngLatLike; +} + +interface Props { + className?: string; +} + +function NationalSocietyLocalUnitsMap(props: Props) { + const { + className, + } = props; + + const strings = useTranslation(i18n); + const { countryResponse } = useOutletContext(); + const [ + clickedPointProperties, + setClickedPointProperties, + ] = useState(); + + const countryBounds = useMemo(() => ( + countryResponse ? getBbox(countryResponse.bbox) : undefined + ), [countryResponse]); + + const { + rawFilter, + filter, + filtered, + setFilter, + setFilterField, + } = useFilterState<{ + type?: number; + search?: string; + isValidated?: string; + }>({ + filter: {}, + pageSize: 9999, + }); + + const { + response: localUnitListResponse, + } = useRequest({ + skip: isNotDefined(countryResponse?.iso3), + url: '/api/v2/local-units/', + query: { + limit: 9999, + type__code: filter.type, + validated: isDefined(filter.isValidated) + ? filter.isValidated === strings.validated : undefined, + search: filter.search, + country__iso3: isDefined(countryResponse?.iso3) ? countryResponse?.iso3 : undefined, + }, + }); + + const { + response: localUnitsOptionsResponse, + pending: localUnitsOptionsResponsePending, + } = useRequest({ + url: '/api/v2/local-units/options/', + }); + + const localUnitsGeoJson = useMemo((): GeoJSON.FeatureCollection => ({ + type: 'FeatureCollection' as const, + features: isDefined(localUnitListResponse) + && isDefined(localUnitListResponse.results) + ? localUnitListResponse?.results?.map((localUnit) => ({ + type: 'Feature' as const, + geometry: localUnit.location as unknown as { + type: 'Point', + coordinates: [number, number], + }, + properties: { + id: localUnit.local_branch_name, + type: localUnit.type.code, + }, + })) : [], + }), [localUnitListResponse]); + + const adminZeroHighlightLayerOptions = useMemo>( + () => ({ + type: 'fill', + layout: { visibility: 'visible' }, + filter: isDefined(countryResponse) ? [ + '!in', + 'country_id', + countryResponse.id, + ] : [], + }), + [countryResponse], + ); + + const selectedLocalUnitDetail = useMemo( + () => { + const id = clickedPointProperties?.id; + if (isNotDefined(id)) { + return undefined; + } + + const selectedLocalUnit = localUnitListResponse?.results?.find( + (localUnit) => localUnit.local_branch_name === id, + ); + + if (isNotDefined(selectedLocalUnit)) { + return undefined; + } + + return selectedLocalUnit; + }, + [clickedPointProperties, localUnitListResponse?.results], + ); + + const validationOptions: Validation[] = useMemo(() => ([ + { + label: strings.validated, + }, + { + label: strings.notValidated, + }, + ]), [strings.validated, strings.notValidated]); + + const handlePointClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + id: feature.properties?.id, + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClose = useCallback( + () => { + setClickedPointProperties(undefined); + }, + [setClickedPointProperties], + ); + + const handleClearFilter = useCallback( + () => { + setFilter({}); + }, + [setFilter], + ); + + return ( + + + + } + /> +
+ +
+ + )} + > + + )} + > + + + + + + {clickedPointProperties?.lngLat && selectedLocalUnitDetail && ( + + {selectedLocalUnitDetail.english_branch_name} + + )} + childrenContainerClassName={styles.popupContent} + contentViewType="vertical" + > + + + + + + {selectedLocalUnitDetail.email} + + )} + /> + + )} + +
+ ); +} + +export default NationalSocietyLocalUnitsMap; diff --git a/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnitsMap/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/index.tsx new file mode 100644 index 000000000..d594c1880 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/index.tsx @@ -0,0 +1,89 @@ +import { + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import { GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryResponse = NonNullable> +interface Props { + className?: string; + initiative: NonNullable[number]; +} + +function InitiativeCard(props: Props) { + const { + className, + initiative, + } = props; + + const strings = useTranslation(i18n); + const categories = initiative.categories?.join(', '); + + return ( + + )} + footerContent={( +
+
+ + +
+ )} + > + + + + + + ); +} + +export default InitiativeCard; diff --git a/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/InitiativeCard/styles.module.css diff --git a/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/index.tsx new file mode 100644 index 000000000..0e1aea0e9 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NsDirectoryInitiatives/index.tsx @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Container, + Grid, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { type CountryOutletContext } from '#utils/outletContext'; + +import InitiativeCard from './InitiativeCard'; + +import i18n from './i18n.json'; + +interface Props { + className?: string; +} + +type CountryInitiative = NonNullable['initiatives']>[number]; + +const keySelector = (initiative: CountryInitiative) => initiative.id; + +function NationalSocietyDirectoryInitiatives(props: Props) { + const { className } = props; + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + + const rendererParams = useCallback( + (_: number, data: CountryInitiative) => ({ + initiative: data, + }), + [], + ); + + return ( + + + + ); +} + +export default NationalSocietyDirectoryInitiatives; diff --git a/src/views/CountryNsOverviewContextAndStructure/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/i18n.json similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/i18n.json rename to app/src/views/CountryNsOverviewContextAndStructure/i18n.json diff --git a/app/src/views/CountryNsOverviewContextAndStructure/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/index.tsx new file mode 100644 index 000000000..e18125baa --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/index.tsx @@ -0,0 +1,96 @@ +import { useOutletContext } from 'react-router-dom'; +import { Container } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type CountryOutletContext } from '#utils/outletContext'; + +import NationalSocietyContacts from './NationalSocietyContacts'; +import NationalSocietyDirectory from './NationalSocietyDirectory'; +import NationalSocietyIndicators from './NationalSocietyIndicators'; +import NationalSocietyKeyDocuments from './NationalSocietyKeyDocuments'; +import NationalSocietyLocalUnitsMap from './NationalSocietyLocalUnitsMap'; +import NationalSocietyDirectoryInitiatives from './NsDirectoryInitiatives'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryResponse } = useOutletContext(); + const strings = useTranslation(i18n); + + return ( +
+ +
+ + +
+ + + + {isDefined(countryResponse) && ( + + {isTruthyString(countryResponse.fdrs) && ( + + {strings.nationalSocietyPageOnFDRS} + + )} + {isTruthyString(countryResponse.url_ifrc) && ( + + {resolveToString( + strings.countryOnIFRC, + { countryName: countryResponse?.name ?? '-' }, + )} + + )} + {isTruthyString(countryResponse.iso3) && ( + + {resolveToString( + strings.countryOnReliefWeb, + { countryName: countryResponse?.name ?? '-' }, + )} + + )} + {isTruthyString(countryResponse.society_url) && ( + + {resolveToString( + strings.countryRCHomepage, + { countryName: countryResponse?.name ?? '-' }, + )} + + )} + + )} +
+ ); +} + +Component.displayName = 'CountryNsOverviewContextAndStructure'; diff --git a/src/views/CountryNsOverviewContextAndStructure/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewContextAndStructure/styles.module.css rename to app/src/views/CountryNsOverviewContextAndStructure/styles.module.css diff --git a/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/i18n.json b/app/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/i18n.json similarity index 100% rename from src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/i18n.json rename to app/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/i18n.json diff --git a/app/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/index.tsx b/app/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/index.tsx new file mode 100644 index 000000000..840e42a2a --- /dev/null +++ b/app/src/views/CountryNsOverviewStrategicPriorities/StrategicPrioritiesTable/index.tsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { Table } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createNumberColumn, + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; + +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type GetCountryPlanResponse = GoApiResponse<'/api/v2/country-plan/{country}/'>; + +interface Props { + className?: string, + priorityData?: GetCountryPlanResponse['strategic_priorities']; +} + +// eslint-disable-next-line import/prefer-default-export +function StrategicPrioritiesTable(props: Props) { + const { + priorityData, + className, + } = props; + + const strings = useTranslation(i18n); + type StrategicPriority = NonNullable<(typeof priorityData)>[number]; + + const columns = useMemo( + () => ([ + createStringColumn( + 'title', + strings.countryPlanStrategicPriority, + (strategic) => strategic?.type_display, + ), + createNumberColumn( + 'priority', + strings.countryPlanKeyFigurePeopleTargeted, + (strategic) => strategic?.people_targeted, + ), + ]), + [ + strings.countryPlanStrategicPriority, + strings.countryPlanKeyFigurePeopleTargeted, + ], + ); + + return ( +
+ ); +} + +export default StrategicPrioritiesTable; diff --git a/src/views/CountryNsOverviewStrategicPriorities/i18n.json b/app/src/views/CountryNsOverviewStrategicPriorities/i18n.json similarity index 100% rename from src/views/CountryNsOverviewStrategicPriorities/i18n.json rename to app/src/views/CountryNsOverviewStrategicPriorities/i18n.json diff --git a/app/src/views/CountryNsOverviewStrategicPriorities/index.tsx b/app/src/views/CountryNsOverviewStrategicPriorities/index.tsx new file mode 100644 index 000000000..4419edc41 --- /dev/null +++ b/app/src/views/CountryNsOverviewStrategicPriorities/index.tsx @@ -0,0 +1,303 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + CheckboxFillIcon, + DownloadLineIcon, +} from '@ifrc-go/icons'; +import { + BlockLoading, + Container, + KeyFigure, + Message, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import StrategicPrioritiesTable from './StrategicPrioritiesTable'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId, countryResponse } = useOutletContext(); + const strings = useTranslation(i18n); + + const { + pending: latestPerPending, + response: latestPerResponse, + // error: latestPerResponseError, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/latest-per-overview/', + query: { country_id: Number(countryId) }, + }); + + // const countryHasNoPer = latestPerResponse?.results?.length === 0; + const perId = latestPerResponse?.results?.[0]?.id; + + const { + pending: perProcessStatusPending, + response: processStatusResponse, + } = useRequest({ + skip: isNotDefined(perId), + url: '/api/v2/per-process-status/{id}/', + pathVariables: { + id: Number(perId), + }, + }); + + const { + pending: assessmentResponsePending, + response: assessmentResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.assessment), + url: '/api/v2/per-assessment/{id}/', + pathVariables: { + id: Number(processStatusResponse?.assessment), + }, + }); + + /* + const { + pending: prioritizationResponsePending, + response: prioritizationResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.prioritization), + url: '/api/v2/per-prioritization/{id}/', + pathVariables: { + id: Number(processStatusResponse?.prioritization), + }, + }); + */ + + const perPending = assessmentResponsePending + || perProcessStatusPending + // || prioritizationResponsePending + || latestPerPending; + + /* + const componentMap = useMemo( + () => { + if (isNotDefined(assessmentResponse)) { + return undefined; + } + + const componentResponses = assessmentResponse.area_responses?.map( + (areaResponse) => ( + areaResponse.component_responses + ), + ).flat().filter(isDefined); + + return listToMap( + componentResponses, + (componentResponse) => componentResponse?.component, + + ); + }, + [assessmentResponse], + ); + */ + + const strengthComponents = useMemo( + () => { + if ( + isNotDefined(assessmentResponse) + || isNotDefined(assessmentResponse.area_responses) + ) { + return undefined; + } + + const componentResponses = assessmentResponse.area_responses.map( + (areaResponse) => ( + areaResponse.component_responses + ), + ).flat().filter(isDefined).sort( + (a, b) => compareNumber(a?.rating_details?.value, b?.rating_details?.value, -1), + ); + + return componentResponses.slice(0, 5); + }, + [assessmentResponse], + ); + + const keyDevelopmentComponents = useMemo( + () => { + if ( + isNotDefined(assessmentResponse) + || isNotDefined(assessmentResponse.area_responses) + ) { + return undefined; + } + + const componentResponses = assessmentResponse.area_responses.map( + (areaResponse) => ( + areaResponse.component_responses + ), + ).flat().filter(isDefined).sort( + (a, b) => compareNumber(a?.rating_details?.value, b?.rating_details?.value), + ); + + return componentResponses.slice(0, 5); + }, + [assessmentResponse], + ); + + const { + pending: countryPlanPending, + response: countryPlanResponse, + } = useRequest({ + skip: isNotDefined(countryId) || !countryResponse?.has_country_plan, + url: '/api/v2/country-plan/{country}/', + pathVariables: { + country: Number(countryId), + }, + }); + + const hasStrengthComponents = isDefined(strengthComponents) && strengthComponents.length > 0; + const hasKeyDevelopmentComponents = isDefined(keyDevelopmentComponents) + && keyDevelopmentComponents.length > 0; + const perContentsDefined = hasStrengthComponents || hasKeyDevelopmentComponents; + + const hasCountryPlan = countryResponse?.has_country_plan; + + return ( + + {(perPending || countryPlanPending) && ( + + )} + {!hasCountryPlan && !perContentsDefined && ( + + )} + {hasCountryPlan && isDefined(countryPlanResponse) && ( + +
+
+ {isDefined(countryPlanResponse.public_plan_file) && ( + } + > + {resolveToString( + strings.countryPlanDownloadPlan, + { countryName: countryResponse?.name ?? '--' }, + )} + + )} + {isTruthyString(countryPlanResponse.internal_plan_file) && ( + } + > + {resolveToString( + strings.countryPlanDownloadPlanInternal, + { countryName: countryResponse?.name ?? '--' }, + )} + + )} +
+
+ + +
+
+ +
+ )} + {perContentsDefined && ( +
+ {hasStrengthComponents && ( + + {strengthComponents?.map( + (strengthComponent) => ( + } + withoutWrapInHeading + className={styles.strengthComponent} + > + {strengthComponent?.rating_details?.title} + + ), + )} + + )} + {hasKeyDevelopmentComponents && ( + + {keyDevelopmentComponents?.map( + (keyDevelopmentComponent) => ( + } + withoutWrapInHeading + className={styles.priorityComponent} + > + {keyDevelopmentComponent?.rating_details?.title} + + ), + )} + + )} +
+ )} +
+ ); +} + +Component.displayName = 'CountryNsOverviewStrategicPriorities'; diff --git a/src/views/CountryNsOverviewStrategicPriorities/styles.module.css b/app/src/views/CountryNsOverviewStrategicPriorities/styles.module.css similarity index 100% rename from src/views/CountryNsOverviewStrategicPriorities/styles.module.css rename to app/src/views/CountryNsOverviewStrategicPriorities/styles.module.css diff --git a/src/views/CountryOngoingActivities/i18n.json b/app/src/views/CountryOngoingActivities/i18n.json similarity index 100% rename from src/views/CountryOngoingActivities/i18n.json rename to app/src/views/CountryOngoingActivities/i18n.json diff --git a/app/src/views/CountryOngoingActivities/index.tsx b/app/src/views/CountryOngoingActivities/index.tsx new file mode 100644 index 000000000..94cfcefc1 --- /dev/null +++ b/app/src/views/CountryOngoingActivities/index.tsx @@ -0,0 +1,47 @@ +import { + Outlet, + useOutletContext, +} from 'react-router-dom'; +import { NavigationTabList } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import NavigationTab from '#components/NavigationTab'; +import { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const outletContext = useOutletContext(); + const { countryId } = outletContext; + const strings = useTranslation(i18n); + + return ( +
+ + + {strings.ongoingEmergenciesTabTitle} + + + {strings.threeWActivitiesTabTitle} + + + {strings.threeWProjectsTabTitle} + + + +
+ ); +} + +Component.displayName = 'CountryOngoingActivities'; diff --git a/src/views/CountryOngoingActivities/styles.module.css b/app/src/views/CountryOngoingActivities/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivities/styles.module.css rename to app/src/views/CountryOngoingActivities/styles.module.css diff --git a/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/i18n.json b/app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/i18n.json rename to app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/index.tsx b/app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/index.tsx new file mode 100644 index 000000000..7e399cdea --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/index.tsx @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; +import { + Container, + Pager, + Table, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createStringColumn, +} from '@ifrc-go/ui/utils'; +import { + _cs, + encodeDate, + isNotDefined, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { createCountryListColumn } from '#utils/domain/tableHelpers'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type EmergencyAlertResponse = GoApiResponse<'/api/v2/gdacs-event/'>; +type EmergencyAlertListItem = NonNullable[number]; + +const emergencyAlertKeySelector = (option: EmergencyAlertListItem) => option.id; + +// FIXME: use a separate utility +const thirtyDaysAgo = new Date(); +thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); +thirtyDaysAgo.setHours(0, 0, 0, 0); + +interface Props { + countryId: number; + className?: string; +} + +function EmergencyAlertsTable(props: Props) { + const { + className, + countryId, + } = props; + + const { + page, + setPage, + limit, + offset, + } = useFilterState({ + filter: {}, + pageSize: 5, + }); + + const strings = useTranslation(i18n); + + const columns = useMemo( + () => ([ + createDateColumn( + 'start_date', + strings.emergencyAlertsTablePublicationDate, + (item) => item.publication_date, + { + columnClassName: styles.pulicationDate, + }, + ), + createStringColumn( + 'disaster_type', + strings.emergencyAlertsDisasterType, + (item) => item.disaster_type.name, + ), + createStringColumn( + 'description', + strings.emergencyAlertsTableDisasterDescription, + (item) => item.title, + ), + createStringColumn( + 'population', + strings.emergencyAlertsTableExposedPopulation, + (item) => `${item.population_value} ${item.population_unit}`, + ), + createCountryListColumn( + 'countries', + strings.emergencyAlertsTableExposedCountries, + (item) => item.countries, + ), + ]), + [ + strings.emergencyAlertsTablePublicationDate, + strings.emergencyAlertsDisasterType, + strings.emergencyAlertsTableDisasterDescription, + strings.emergencyAlertsTableExposedPopulation, + strings.emergencyAlertsTableExposedCountries, + ], + ); + + const { + pending: emergencyAlertsPending, + response: emergencyAlertsResponse, + } = useRequest({ + url: '/api/v2/gdacs-event/', + skip: isNotDefined(countryId), + preserveResponse: true, + query: { + limit, + offset, + publication_date__gte: encodeDate(thirtyDaysAgo), + countries: countryId ? [countryId] : undefined, + }, + }); + + return ( + + {strings.emergencyAlertsTableGdacs} + + )} + /> + )} + footerActions={( + + )} + contentViewType="vertical" + > +
+ + ); +} + +export default EmergencyAlertsTable; diff --git a/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/styles.module.css b/app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/styles.module.css rename to app/src/views/CountryOngoingActivitiesEmergencies/EmergencyAlerts/styles.module.css diff --git a/src/views/CountryOngoingActivitiesEmergencies/i18n.json b/app/src/views/CountryOngoingActivitiesEmergencies/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesEmergencies/i18n.json rename to app/src/views/CountryOngoingActivitiesEmergencies/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesEmergencies/index.tsx b/app/src/views/CountryOngoingActivitiesEmergencies/index.tsx new file mode 100644 index 000000000..e42c046ee --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesEmergencies/index.tsx @@ -0,0 +1,158 @@ +import { useOutletContext } from 'react-router-dom'; +import { + AppealsIcon, + DrefIcon, + FundingCoverageIcon, + FundingIcon, + PencilFillIcon, + TargetedPopulationIcon, +} from '@ifrc-go/icons'; +import { + BlockLoading, + Container, + InfoPopup, + KeyFigure, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { getPercentage } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import getBbox from '@turf/bbox'; + +import ActiveOperationMap from '#components/domain/ActiveOperationMap'; +import AppealsTable from '#components/domain/AppealsTable'; +import HighlightedOperations from '#components/domain/HighlightedOperations'; +import Link from '#components/Link'; +import { adminUrl } from '#config'; +import useAuth from '#hooks/domain/useAuth'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { resolveUrl } from '#utils/resolveUrl'; +import { useRequest } from '#utils/restRequest'; + +import EmergencyAlertsTable from './EmergencyAlerts'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { + countryId, + countryResponse, + } = useOutletContext(); + + const { isAuthenticated } = useAuth(); + + const { + pending: aggregatedAppealPending, + response: aggregatedAppealResponse, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/appeal/aggregated', + query: { country: Number(countryId) }, + }); + + const bbox = isDefined(countryResponse) ? getBbox(countryResponse.bbox) : undefined; + + return ( + } + > + {strings.editCountryLink} + + )} + > + {aggregatedAppealPending && } + {!aggregatedAppealPending && aggregatedAppealResponse && ( +
+ } + className={styles.keyFigure} + value={aggregatedAppealResponse.active_drefs} + info={( + + )} + label={strings.countryOngoingActivitiesDREFOperations} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.active_appeals} + info={( + + )} + label={strings.countryOngoingActivitiesKeyFiguresActiveAppeals} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse.target_population} + compactValue + label={strings.countryOngoingActivitiesKeyFiguresTargetPop} + /> + } + className={styles.keyFigure} + value={aggregatedAppealResponse?.amount_requested_dref_included} + compactValue + label={strings.countryOngoingActivitiesKeyFiguresBudget} + /> + } + className={styles.keyFigure} + value={getPercentage( + aggregatedAppealResponse?.amount_funded, + aggregatedAppealResponse?.amount_requested, + )} + suffix="%" + compactValue + label={strings.countryOngoingActivitiesKeyFiguresAppealsFunding} + /> +
+ )} + {isDefined(countryId) && ( + + )} + {isDefined(countryId) && ( + + )} + {isDefined(countryId) && ( + + )} + {isDefined(countryId) && ( + + )} +
+ ); +} diff --git a/src/views/CountryOngoingActivitiesEmergencies/styles.module.css b/app/src/views/CountryOngoingActivitiesEmergencies/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesEmergencies/styles.module.css rename to app/src/views/CountryOngoingActivitiesEmergencies/styles.module.css diff --git a/src/views/CountryOngoingActivitiesThreeWActivities/Filters/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWActivities/Filters/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWActivities/Filters/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWActivities/Filters/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWActivities/Filters/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWActivities/Filters/index.tsx new file mode 100644 index 000000000..916bace99 --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWActivities/Filters/index.tsx @@ -0,0 +1,132 @@ +import { + useCallback, + useState, +} from 'react'; +import { MultiSelectInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + numericIdSelector, + stringTitleSelector, + stringValueSelector, +} from '@ifrc-go/ui/utils'; +import { isNotDefined } from '@togglecorp/fujs'; +import { + EntriesAsList, + type SetValueArg, +} from '@togglecorp/toggle-form'; + +import DistrictMultiCountrySearchMultiSelectInput, { type DistrictItem } from '#components/domain/DistrictMultiCountrySearchMultiSelectInput'; +import NationalSocietyMultiSelectInput from '#components/domain/NationalSocietyMultiSelectInput'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type DeploymentsEmergencyProjectStatus = NonNullable['deployments_emergency_project_status']>[number]; +export type FilterValue = Partial<{ + reporting_ns: number[]; + deployed_eru: number[]; + sector: number[]; + status: string[]; + districts: number[]; +}> + +function emergencyProjectStatusSelector(option: DeploymentsEmergencyProjectStatus) { + return option.key; +} + +interface Props { + value: FilterValue; + onChange: (value: SetValueArg) => void; + disabled?: boolean; + countryId?: string; +} + +function Filters(props: Props) { + const { + value, + countryId, + onChange, + disabled, + } = props; + + const [districtOptions, setDistrictOptions] = useState(); + const { + response: emergencyProjectOptions, + pending: emergencyProjectOptionsPending, + } = useRequest({ + url: '/api/v2/emergency-project/options/', + preserveResponse: true, + }); + const { + deployments_emergency_project_status: emergencyProjectStatusOptions, + } = useGlobalEnums(); + + const strings = useTranslation(i18n); + + const handleInputChange = useCallback((...args: EntriesAsList) => { + const [val, key] = args; + if (onChange) { + onChange((oldFilterValue) => { + const newFilterValue = { + ...oldFilterValue, + [key]: val, + }; + + return newFilterValue; + }); + } + }, [onChange]); + + return ( + <> + + + + + + + ); +} + +export default Filters; diff --git a/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/index.tsx new file mode 100644 index 000000000..9b577aeed --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/index.tsx @@ -0,0 +1,190 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { LegendItem } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isDefined, + isNotDefined, + mapToList, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, +} from '@togglecorp/re-map'; +import getBbox from '@turf/bbox'; +import type { FillLayer } from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import { + COLOR_LIGHT_GREY, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, +} from '#utils/constants'; +import type { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const COLOR_SEVERITY_LOW = '#ccd2d9'; +const COLOR_SEVERITY_MEDIUM = '#99a5b4'; +const COLOR_SEVERITY_HIGH = '#67788d'; +const COLOR_SEVERITY_SEVERE = '#344b67'; + +const SEVERITY_LOW = 2; +const SEVERITY_MEDIUM = 5; +const SEVERITY_HIGH = 10; + +interface Props { + className?: string; + sidebarContent?: React.ReactNode; + emergencyProjectCountByDistrict: Record; +} + +function ResponseActivitiesMap(props: Props) { + const { + className, + sidebarContent, + emergencyProjectCountByDistrict, + } = props; + + const strings = useTranslation(i18n); + const { countryResponse } = useOutletContext(); + + const bounds = useMemo( + () => (countryResponse ? getBbox(countryResponse?.bbox) : undefined), + [countryResponse], + ); + + const emergencyProjectCountByDistrictList = mapToList( + emergencyProjectCountByDistrict, + (value, key) => ({ district: key, count: value }), + ); + + const districtIdList = useMemo( + () => emergencyProjectCountByDistrictList.map( + (list) => Number(list.district), + ), + [emergencyProjectCountByDistrictList], + ); + + const adminOneLabelSelectedLayerOptions = useMemo>( + () => ({ + type: 'fill', + layout: { visibility: 'visible' }, + filter: [ + 'in', + 'district_id', + ...districtIdList, + ], + }), + [districtIdList], + ); + + const adminOneHighlightLayerOptions = useMemo>( + () => { + if (isNotDefined((emergencyProjectCountByDistrictList)) + || emergencyProjectCountByDistrictList.length < 1) { + return { + type: 'fill', + layout: { visibility: 'visible' }, + paint: { + 'fill-color': COLOR_LIGHT_GREY, + }, + }; + } + + return { + type: 'fill', + layout: { visibility: 'visible' }, + paint: { + 'fill-color': [ + 'match', + ['get', 'district_id'], + ...(emergencyProjectCountByDistrictList).flatMap(({ district, count }) => [ + Number(district), + [ + 'interpolate', + ['exponential', 1], + ['number', count], + 0, + COLOR_SEVERITY_LOW, + SEVERITY_LOW, + COLOR_SEVERITY_MEDIUM, + SEVERITY_MEDIUM, + COLOR_SEVERITY_HIGH, + SEVERITY_HIGH, + COLOR_SEVERITY_SEVERE, + ], + ]), + COLOR_LIGHT_GREY, + ], + }, + }; + }, + [emergencyProjectCountByDistrictList], + ); + + return ( +
+
+ + + + + )} + > + +
+ {strings.countryResponseActivitiesNumberOfProjects} +
+ + + + +
+ )} + /> + {isDefined(bounds) && ( + + )} + +
+ {sidebarContent && ( +
+ {sidebarContent} +
+ )} + + ); +} + +export default ResponseActivitiesMap; diff --git a/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/styles.module.css b/app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/styles.module.css rename to app/src/views/CountryOngoingActivitiesThreeWActivities/ResponseActivitiesMap/styles.module.css diff --git a/src/views/CountryOngoingActivitiesThreeWActivities/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWActivities/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWActivities/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWActivities/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWActivities/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWActivities/index.tsx new file mode 100644 index 000000000..9202ecb3d --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWActivities/index.tsx @@ -0,0 +1,484 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { InformationLineIcon } from '@ifrc-go/icons'; +import { + BlockLoading, + Container, + InfoPopup, + KeyFigure, + Message, + Pager, + PieChart, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createElementColumn, + createListDisplayColumn, + createNumberColumn, + createStringColumn, + numericCountSelector, + numericIdSelector, + stringTitleSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, + mapToList, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import Link from '#components/Link'; +import useAlert from '#hooks/useAlert'; +import useFilterState from '#hooks/useFilterState'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; +import { type GoApiResponse } from '#utils/restRequest'; +import ActivityActions, { type Props as ActivityActionsProps } from '#views/EmergencyActivities/ActivityActions'; +import ActivityDetail from '#views/EmergencyActivities/ActivityDetail'; +import useEmergencyProjectStats, { getPeopleReached } from '#views/EmergencyActivities/useEmergencyProjectStats'; + +import Filters, { type FilterValue } from './Filters'; +import ResponseActivitiesMap from './ResponseActivitiesMap'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type EmergencyProjectResponse = GoApiResponse<'/api/v2/emergency-project/'>; +type EmergencyProject = NonNullable[number]; +type DistrictDetails = EmergencyProject['districts_details'][number]; + +type ProjectKey = 'reporting_ns' | 'deployed_eru' | 'status' | 'country' | 'districts'; +type FilterKey = ProjectKey | 'sector'; +const ITEM_PER_PAGE = 10; +const MAX_ITEMS = 4; + +const primaryRedColorShades = [ + 'var(--go-ui-color-red-90)', + 'var(--go-ui-color-red-60)', + 'var(--go-ui-color-red-40)', + 'var(--go-ui-color-red-20)', + 'var(--go-ui-color-red-10)', +]; + +function filterEmergencyProjects( + emergencyProjectList: EmergencyProject[], + filters: Partial>, +) { + return emergencyProjectList.filter((emergencyProject) => ( + Object.entries(filters).every(([filterKey, filterValue]) => { + if (isNotDefined(filterValue) || filterValue.length === 0) { + return true; + } + if (filterKey === 'sector') { + const projectValue = emergencyProject.activities + ?.map((activity) => activity.sector) ?? undefined; + return projectValue?.some((v) => filterValue.includes(v)); + } + const projectValue = emergencyProject[filterKey as ProjectKey]; + + if (isNotDefined(projectValue)) { + return false; + } + + if (Array.isArray(projectValue)) { + return projectValue.some((v) => filterValue.includes(v)); + } + + return filterValue.includes(projectValue); + }) + )); +} + +function DistrictNameOutput({ districtName }: { districtName: string }) { + return districtName; +} + +function getAggregatedValues(values: { title: string, count: number }[]) { + const sortedValues = [...values].sort((a, b) => compareNumber(b.count, a.count)); + + if (sortedValues.length <= MAX_ITEMS) { + return sortedValues; + } + + const remains = sortedValues.splice( + MAX_ITEMS - 1, + sortedValues.length - (MAX_ITEMS - 1), + ); + const otherCount = sumSafe(remains.map((d) => d.count)); + if (isDefined(otherCount) && otherCount > 0) { + sortedValues.push({ + title: 'Others', + count: otherCount, + }); + } + + return sortedValues; +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId, countryResponse } = useOutletContext(); + const strings = useTranslation(i18n); + const alert = useAlert(); + + const { + rawFilter, + filter: filters, + setFilter: setFilters, + page: activePage, + setPage: setActivePage, + filtered: isFiltered, + limit, + offset, + } = useFilterState({ + filter: { + reporting_ns: [], + deployed_eru: [], + sector: [], + status: [], + districts: [], + }, + pageSize: ITEM_PER_PAGE, + }); + + const { + response: emergencyProjectListResponse, + pending: emergencyProjectListResponsePending, + } = useRequest({ + url: '/api/v2/emergency-project/', + preserveResponse: true, + skip: isNotDefined(countryId), + query: isDefined(countryId) ? { + country: [Number(countryId)], + limit: 9999, + } : undefined, + }); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, `${countryResponse?.name}-emergency-activities.csv`); + }, + }); + + const handleExportClick = useCallback(() => { + if (!emergencyProjectListResponse?.count || !countryId) { + return; + } + triggerExportStart( + '/api/v2/emergency-project/', + emergencyProjectListResponse.count, + { + country: [Number(countryId)], + }, + ); + }, [ + triggerExportStart, + countryId, + emergencyProjectListResponse?.count, + ]); + + const filteredProjectList = filterEmergencyProjects( + emergencyProjectListResponse?.results ?? [], + filters, + ); + + const { + emergencyProjectCountByDistrict, + emergencyProjectCountListBySector, + emergencyProjectCountListByStatus, + sectorGroupedEmergencyProjects, + peopleReached, + uniqueEruCount, + uniqueNsCount, + uniqueSectorCount, + } = useEmergencyProjectStats( + emergencyProjectListResponse?.results, + filteredProjectList, + ); + + const aggregatedProjectCountListBySector = useMemo(() => ( + getAggregatedValues(emergencyProjectCountListBySector) + ), [emergencyProjectCountListBySector]); + + const aggregatedProjectCountListByStatus = useMemo(() => ( + getAggregatedValues(emergencyProjectCountListByStatus) + ), [emergencyProjectCountListByStatus]); + + const paginatedEmergencyProjectList = useMemo(() => ( + filteredProjectList.slice(offset, offset + limit) + ), [filteredProjectList, offset, limit]); + + const sectorGroupedEmergencyProjectList = useMemo(() => ( + mapToList( + sectorGroupedEmergencyProjects, + (value, key) => ({ + sector: key, + projects: value.projects, + sectorDetails: value.sectorDetails, + }), + ) + ), [sectorGroupedEmergencyProjects]); + + const columns = useMemo( + () => ([ + createStringColumn( + 'national_society_eru', + strings.emergencyProjectNationalSociety, + (item) => ( + item.activity_lead === 'deployed_eru' + ? item.deployed_eru_details + ?.eru_owner_details + ?.national_society_country_details + ?.society_name + : item.reporting_ns_details?.society_name + ), + ), + createStringColumn( + 'title', + strings.emergencyProjectTitle, + (item) => item.title, + ), + createDateColumn( + 'start_date', + strings.emergencyProjectStartDate, + (item) => item.start_date, + ), + createStringColumn( + 'country', + strings.emergencyProjectCountry, + (item) => item.country_details?.name, + ), + createListDisplayColumn< + EmergencyProject, + number, + DistrictDetails, + { districtName: string } + >( + 'districts', + strings.emergencyProjectDistrict, + (activity) => ({ + list: activity.districts_details, + renderer: DistrictNameOutput, + rendererParams: (districtDetail) => ({ districtName: districtDetail.name }), + keySelector: (districtDetail) => districtDetail.id, + }), + ), + createStringColumn( + 'status', + strings.emergencyProjectStatus, + (item) => item.status_display, + ), + createNumberColumn( + 'people_reached', + strings.emergencyProjectPeopleReached, + (item) => getPeopleReached(item), + ), + createElementColumn( + 'actions', + '', + ActivityActions, + (_, item) => ({ + activityId: item.id, + className: styles.activityActions, + }), + ), + ]), + [ + strings.emergencyProjectNationalSociety, + strings.emergencyProjectTitle, + strings.emergencyProjectStartDate, + strings.emergencyProjectCountry, + strings.emergencyProjectDistrict, + strings.emergencyProjectStatus, + strings.emergencyProjectPeopleReached, + ], + ); + + const noActivitiesBySector = (isNotDefined(sectorGroupedEmergencyProjectList) + || (isDefined(sectorGroupedEmergencyProjectList) + && (sectorGroupedEmergencyProjectList.length < 1))); + + return ( +
+ + + {strings.chartDescription} +
+ )} + actions={( + + {strings.addThreeWActivity} + + )} + > + {emergencyProjectListResponsePending && } + {!emergencyProjectListResponsePending && ( +
+
+ + + )} + compactValue + /> +
+
+ + +
+
+ + +
+
+ )} + + + )} + actions={( + + {strings.threeWViewAllActivityLabel} + + )} + footerActions={( + + )} + > + + {noActivitiesBySector && ( + + )} + {sectorGroupedEmergencyProjectList.map((sectorGroupedProject) => ( + + ))} + + )} + /> + + )} + > +
+ + + + ); +} + +Component.displayName = 'CountryOngoingActivitiesThreeWActivities'; diff --git a/src/views/CountryOngoingActivitiesThreeWActivities/styles.module.css b/app/src/views/CountryOngoingActivitiesThreeWActivities/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWActivities/styles.module.css rename to app/src/views/CountryOngoingActivitiesThreeWActivities/styles.module.css diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/Filters/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/Filters/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/index.tsx new file mode 100644 index 000000000..62f62bce5 --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/index.tsx @@ -0,0 +1,151 @@ +import { useCallback } from 'react'; +import { MultiSelectInput } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + numericIdSelector, + numericKeySelector, + stringLabelSelector, + stringNameSelector, + stringValueSelector, +} from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; +import { EntriesAsList } from '@togglecorp/toggle-form'; + +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useNationalSociety, { NationalSociety } from '#hooks/domain/useNationalSociety'; +import type { GoApiResponse } from '#utils/restRequest'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type DistrictListItem = NonNullable['results']>[number]; + +export interface FilterValue { + reporting_ns: number[]; + project_districts: number[]; + operation_type: number[]; + programme_type: number[]; + primary_sector: number[]; + secondary_sectors: number[]; +} + +function countrySocietyNameSelector(country: NationalSociety) { + return country.society_name; +} + +interface Props { + className?: string; + value: FilterValue; + onChange: React.Dispatch>; + disabled?: boolean; + districtList: DistrictListItem[]; +} + +function Filters(props: Props) { + const { + className, + value, + onChange, + disabled, + districtList, + } = props; + + const { + deployments_project_operation_type: projectOperationTypeOptions, + deployments_project_programme_type: programmeTypeOptions, + } = useGlobalEnums(); + + const strings = useTranslation(i18n); + + const { response: primarySectorResponse } = useRequest({ + url: '/api/v2/primarysector', + }); + + const { response: secondarySectorResponse } = useRequest({ + url: '/api/v2/secondarysector', + }); + + const nsList = useNationalSociety(); + + const handleInputChange = useCallback((...args: EntriesAsList) => { + const [val, key] = args; + if (onChange) { + onChange((oldFilterValue) => { + const newFilterValue = { + ...oldFilterValue, + [key]: val, + }; + + return newFilterValue; + }); + } + }, [onChange]); + + return ( +
+ + + + + + +
+ ); +} + +export default Filters; diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/Filters/styles.module.css b/app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/Filters/styles.module.css rename to app/src/views/CountryOngoingActivitiesThreeWProjects/Filters/styles.module.css diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/Map/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWProjects/Map/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/Map/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWProjects/Map/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWProjects/Map/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWProjects/Map/index.tsx new file mode 100644 index 000000000..88d019c7d --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWProjects/Map/index.tsx @@ -0,0 +1,493 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Container, + LegendItem, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + denormalizeList, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + listToGroupList, + unique, +} from '@togglecorp/fujs'; +import { + MapBounds, + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import getBbox from '@turf/bbox'; +import type { FillLayer } from 'mapbox-gl'; + +import BaseMap from '#components/domain/BaseMap'; +import MapContainerWithDisclaimer from '#components/MapContainerWithDisclaimer'; +import MapPopup from '#components/MapPopup'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import { + COLOR_BLUE, + COLOR_ORANGE, + COLOR_RED, + DEFAULT_MAP_PADDING, + DURATION_MAP_ZOOM, + OPERATION_TYPE_EMERGENCY, + OPERATION_TYPE_MULTI, + OPERATION_TYPE_PROGRAMME, +} from '#utils/constants'; +import { + adminFillLayerOptions, + getPointCircleHaloPaint, + getPointCirclePaint, + pointColorMap, +} from '#utils/map'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type District = NonNullable['results']>[number]; +type Project = NonNullable['results']>[number]; + +const redPointCirclePaint = getPointCirclePaint(COLOR_RED); +const bluePointCirclePaint = getPointCirclePaint(COLOR_BLUE); +const orangePointCirclePaint = getPointCirclePaint(COLOR_ORANGE); +const sourceOption: mapboxgl.GeoJSONSourceRaw = { + type: 'geojson', +}; + +interface GeoJsonProps { + districtId: number; + numProjects: number; +} + +interface ClickedPoint { + districtId: number; + lngLat: mapboxgl.LngLatLike; +} + +type ProjectGeoJson = GeoJSON.FeatureCollection; + +function getOperationType(projectList: Project[]) { + const operationTypeList = unique( + projectList + .map((d) => { + if (isNotDefined(d.operation_type)) { + return undefined; + } + return { + id: d.operation_type, + }; + }) + .filter(isDefined), + (d) => d.id, + ) ?? []; + + if (operationTypeList.length === 0) { + return undefined; + } + + if (operationTypeList.length === 1) { + return operationTypeList[0]; + } + + return { + id: OPERATION_TYPE_MULTI, + }; +} + +function getGeoJson( + districtList: District[], + districtDenormalizedProjectList: (Project & { + project_district_detail: Pick< + District, + | 'id' + | 'name' + >; + })[], + requiredOperationTypeId: number, +): ProjectGeoJson { + return { + type: 'FeatureCollection' as const, + features: districtList.map((district) => { + if (isNotDefined(district.centroid)) { + return undefined; + } + const projects = districtDenormalizedProjectList + .filter((project) => project.project_district_detail.id === district.id); + if (projects.length === 0) { + return undefined; + } + const operationType = getOperationType(projects); + if (isNotDefined(operationType) || operationType.id !== requiredOperationTypeId) { + return undefined; + } + + return { + id: district.id, + type: 'Feature' as const, + properties: { + districtId: district.id, + numProjects: projects.length, + }, + geometry: { + type: 'Point' as const, + coordinates: district.centroid.coordinates as [number, number], + }, + }; + }).filter(isDefined), + }; +} + +interface Props { + className?: string; + projectList: Project[]; + districtList: District[]; + sidebarContent?: React.ReactNode; +} + +function CountryThreeWMap(props: Props) { + const { + className, + projectList, + districtList, + sidebarContent, + } = props; + + const strings = useTranslation(i18n); + const { countryResponse } = useOutletContext(); + const { + deployments_project_operation_type: operationTypeOptions, + } = useGlobalEnums(); + + const countryBounds = useMemo(() => ( + countryResponse ? getBbox(countryResponse.bbox) : undefined + ), [countryResponse]); + + const [ + clickedPointProperties, + setClickedPointProperties, + ] = useState(); + + const { + districtDenormalizedProjectList, + } = useMemo(() => ({ + districtDenormalizedProjectList: denormalizeList( + projectList ?? [], + (project) => project.project_districts_detail, + (project, district) => ({ + ...project, + project_district_detail: district, + }), + ), + }), [projectList]); + + const districtGroupedProjects = listToGroupList( + districtDenormalizedProjectList, + (d) => d.project_district_detail.id, + ); + + const selectedDistrictProjectDetail = useMemo( + () => { + const id = clickedPointProperties?.districtId; + if (isNotDefined(id)) { + return undefined; + } + + // eslint-disable-next-line max-len + const selectedDistrictProjectList = districtGroupedProjects[id]; + + if (isNotDefined(selectedDistrictProjectList)) { + return undefined; + } + + return selectedDistrictProjectList; + }, + [clickedPointProperties, districtGroupedProjects], + ); + + const { + programmesGeo, + emergencyGeo, + multiTypeGeo, + } = useMemo( + () => (districtList ? { + programmesGeo: getGeoJson( + districtList, + districtDenormalizedProjectList, + OPERATION_TYPE_PROGRAMME, + ), + emergencyGeo: getGeoJson( + districtList, + districtDenormalizedProjectList, + OPERATION_TYPE_EMERGENCY, + ), + multiTypeGeo: getGeoJson( + districtList, + districtDenormalizedProjectList, + OPERATION_TYPE_MULTI, + ), + } : {}), + [districtList, districtDenormalizedProjectList], + ); + + const districtIdList = useMemo( + () => districtList.map( + (district) => district.id, + ), + [districtList], + ); + + const maxScaleValue = projectList?.length ?? 0; + + const adminOneLabelSelectedLayerOptions = useMemo>( + () => ({ + type: 'fill', + layout: { visibility: 'visible' }, + filter: [ + 'in', + 'district_id', + ...districtIdList, + ], + }), + [districtIdList], + ); + + const adminZeroHighlightLayerOptions = useMemo>( + () => ({ + type: 'fill', + layout: { visibility: 'visible' }, + filter: isDefined(countryResponse) ? [ + '!in', + 'country_id', + countryResponse.id, + ] : [], + }), + [countryResponse], + ); + + const { + redPointHaloCirclePaint, + bluePointHaloCirclePaint, + orangePointHaloCirclePaint, + } = useMemo( + () => ({ + redPointHaloCirclePaint: getPointCircleHaloPaint(COLOR_RED, 'numProjects', maxScaleValue), + bluePointHaloCirclePaint: getPointCircleHaloPaint(COLOR_BLUE, 'numProjects', maxScaleValue), + orangePointHaloCirclePaint: getPointCircleHaloPaint(COLOR_ORANGE, 'numProjects', maxScaleValue), + }), + [maxScaleValue], + ); + + const handleDistrictClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + districtId: feature.properties?.district_id, + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClick = useCallback( + (feature: mapboxgl.MapboxGeoJSONFeature, lngLat: mapboxgl.LngLat) => { + setClickedPointProperties({ + districtId: feature.properties?.districtId, + lngLat, + }); + return true; + }, + [setClickedPointProperties], + ); + + const handlePointClose = useCallback( + () => { + setClickedPointProperties(undefined); + }, + [setClickedPointProperties], + ); + + return ( +
+
+ + + + + + )} + > + 0 && ( +
+ {operationTypeOptions.map((d) => ( + + ))} + +
+ )} + /> + {programmesGeo && ( + + + + + )} + {emergencyGeo && ( + + + + + )} + + {multiTypeGeo && ( + + + + + )} + {clickedPointProperties?.lngLat && selectedDistrictProjectDetail && ( + + {/* FIXME: use List */} + {selectedDistrictProjectDetail.map((project) => ( + + + + + + + ))} + + )} +
+
+ {sidebarContent && ( +
+ {sidebarContent} +
+ )} +
+ ); +} + +export default CountryThreeWMap; diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/Map/styles.module.css b/app/src/views/CountryOngoingActivitiesThreeWProjects/Map/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/Map/styles.module.css rename to app/src/views/CountryOngoingActivitiesThreeWProjects/Map/styles.module.css diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/i18n.json b/app/src/views/CountryOngoingActivitiesThreeWProjects/i18n.json similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/i18n.json rename to app/src/views/CountryOngoingActivitiesThreeWProjects/i18n.json diff --git a/app/src/views/CountryOngoingActivitiesThreeWProjects/index.tsx b/app/src/views/CountryOngoingActivitiesThreeWProjects/index.tsx new file mode 100644 index 000000000..3afdb256b --- /dev/null +++ b/app/src/views/CountryOngoingActivitiesThreeWProjects/index.tsx @@ -0,0 +1,622 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { PencilFillIcon } from '@ifrc-go/icons'; +import { + BlockLoading, + Container, + ExpandableContainer, + KeyFigure, + Message, + PieChart, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createElementColumn, + createNumberColumn, + createStringColumn, + denormalizeList, + hasSomeDefinedValue, + numericIdSelector, + numericValueSelector, + resolveToString, + stringLabelSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + listToMap, + mapToList, + unique, +} from '@togglecorp/fujs'; +import { saveAs } from 'file-saver'; +import Papa from 'papaparse'; + +import ExportButton from '#components/domain/ExportButton'; +import ProjectActions, { Props as ProjectActionsProps } from '#components/domain/ProjectActions'; +import Link from '#components/Link'; +import useUserMe from '#hooks/domain/useUserMe'; +import useAlert from '#hooks/useAlert'; +import useRecursiveCsvExport from '#hooks/useRecursiveCsvRequest'; +import { PROJECT_STATUS_ONGOING } from '#utils/constants'; +import type { CountryOutletContext } from '#utils/outletContext'; +import { + GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import Filter, { FilterValue } from './Filters'; +import Map from './Map'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface LabelValue { + label: string; + value: number; +} + +type District = NonNullable['results']>[number]; +type Project = NonNullable['results']>[number]; + +type ProjectKey = 'reporting_ns' | 'project_districts' | 'programme_type' | 'operation_type' | 'programme_type' | 'primary_sector' | 'secondary_sectors'; + +const emptyDistrictList: District[] = []; +const emptyProjectList: Project[] = []; + +const primaryRedColorShades = [ + 'var(--go-ui-color-red-90)', + 'var(--go-ui-color-red-60)', + 'var(--go-ui-color-red-40)', + 'var(--go-ui-color-red-20)', + 'var(--go-ui-color-red-10)', +]; + +function filterProjects(projectList: Project[], filters: Partial>) { + return projectList.filter((project) => ( + Object.entries(filters).every(([filterKey, filterValue]) => { + const projectValue = project[filterKey as ProjectKey]; + + if (isNotDefined(filterValue) || filterValue.length === 0) { + return true; + } + + if (isNotDefined(projectValue)) { + return false; + } + + if (Array.isArray(projectValue)) { + return projectValue.some((v) => filterValue.includes(v)); + } + + return filterValue.includes(projectValue); + }) + )); +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const alert = useAlert(); + const userMe = useUserMe(); + const strings = useTranslation(i18n); + const { countryId, countryResponse } = useOutletContext(); + + const [filters, setFilters] = useState({ + reporting_ns: [], + project_districts: [], + operation_type: [], + programme_type: [], + primary_sector: [], + secondary_sectors: [], + }); + + const isFiltered = hasSomeDefinedValue(filters); + + const { + pending: projectListPending, + response: projectListResponse, + retrigger: reTriggerProjectListRequest, + } = useRequest({ + skip: isNotDefined(countryResponse?.iso), + url: '/api/v2/project/', + query: { + limit: 9999, + country: isDefined(countryId) + ? [Number(countryId)] + : undefined, + }, + }); + + const { + response: districtListResponse, + } = useRequest({ + skip: isNotDefined(countryResponse?.id), + url: '/api/v2/district/', + query: { + country: countryResponse?.id, + limit: 9999, + }, + }); + + const districtList = districtListResponse?.results ?? emptyDistrictList; + const projectList = projectListResponse?.results ?? emptyProjectList; + const filteredProjectList = filterProjects(projectList, filters); + + const districtIdToDetailMap = useMemo( + () => listToMap( + districtList, + (district) => district.id, + ), + [districtList], + ); + const { + ongoingProjects, + targetedPopulation, + ongoingProjectBudget, + programmeTypeStats, + projectStatusTypeStats, + activeNSCount, + } = useMemo(() => { + const projectsOngoing = filteredProjectList + .filter((p) => p.status === PROJECT_STATUS_ONGOING); + + const ongoingBudget = sumSafe(projectsOngoing?.map((d) => d.budget_amount)) ?? 0; + + const peopleTargeted = sumSafe(filteredProjectList?.map((d) => d.target_total)) ?? 0; + + const programmeTypeGrouped = ( + listToGroupList( + filteredProjectList, + (d) => d.programme_type_display, + (d) => d, + ) ?? {} + ); + + const programmeTypes: LabelValue[] = mapToList( + programmeTypeGrouped, + (d, k) => ({ label: String(k), value: d.length }), + ); + + const statusGrouped = ( + listToGroupList( + filteredProjectList, + (d) => d.status_display, + (d) => d, + ) ?? {} + ); + + const projectStatusTypes: LabelValue[] = mapToList( + statusGrouped, + (d, k) => ({ label: String(k), value: d.length }), + ); + + const numberOfActiveNS = unique(projectsOngoing, (d) => d.reporting_ns)?.length ?? 0; + + return { + ongoingProjects: projectsOngoing, + targetedPopulation: peopleTargeted, + ongoingProjectBudget: ongoingBudget, + programmeTypeStats: programmeTypes, + projectStatusTypeStats: projectStatusTypes, + activeNSCount: numberOfActiveNS, + }; + }, [filteredProjectList]); + + const districtGroupedProject = useMemo(() => { + const districtDenormalizedProjectList = denormalizeList( + ongoingProjects ?? [], + (project) => project.project_districts_detail, + (project, district) => ({ + ...project, + project_district: district, + }), + ); + + return listToGroupList( + districtDenormalizedProjectList, + (d) => d.project_district.id, + ); + }, [ongoingProjects]); + + const { + localNSProjects, + otherNSProjects, + } = useMemo(() => ({ + localNSProjects: ongoingProjects.filter( + (project) => project.reporting_ns === project.project_country, + ), + otherNSProjects: ongoingProjects.filter( + (project) => project.reporting_ns !== project.project_country, + ), + }), [ongoingProjects]); + + const tableColumns = useMemo(() => ([ + createStringColumn( + 'ns', + strings.threeWTableNS, + (item) => item.reporting_ns_detail?.society_name, + ), + createStringColumn( + 'name', + strings.threeWTableProjectName, + (item) => item.name, + ), + createStringColumn( + 'sector', + strings.threeWTableSector, + (item) => item.primary_sector_display, + ), + createNumberColumn( + 'budget', + strings.threeWTableTotalBudget, + (item) => item.budget_amount, + undefined, + ), + createStringColumn( + 'programmeType', + strings.threeWTableProgrammeType, + (item) => item.programme_type_display, + ), + createStringColumn( + 'disasterType', + strings.threeWTableDisasterType, + (item) => item.dtype_detail?.name, + ), + createNumberColumn( + 'peopleTargeted', + strings.threeWTablePeopleTargeted, + (item) => item.target_total, + undefined, + ), + createNumberColumn( + 'peopleReached', + strings.threeWTablePeopleReached, + (item) => item.reached_total, + undefined, + ), + createElementColumn( + 'actions', + '', + ProjectActions, + (_, project) => ({ + onProjectDeletionSuccess: reTriggerProjectListRequest, + project, + className: styles.projectActions, + }), + ), + ]), [ + strings.threeWTableNS, + strings.threeWTableProjectName, + strings.threeWTableSector, + strings.threeWTableTotalBudget, + strings.threeWTableProgrammeType, + strings.threeWTableDisasterType, + strings.threeWTablePeopleTargeted, + strings.threeWTablePeopleReached, + reTriggerProjectListRequest, + ]); + + const districtIdList = Object.keys(districtGroupedProject); + + const [ + pendingExport, + progress, + triggerExportStart, + ] = useRecursiveCsvExport({ + onFailure: () => { + alert.show( + strings.failedToCreateExport, + { variant: 'danger' }, + ); + }, + onSuccess: (data) => { + const unparseData = Papa.unparse(data); + const blob = new Blob( + [unparseData], + { type: 'text/csv' }, + ); + saveAs(blob, `${countryResponse?.name}-data-export.csv`); + }, + }); + + const handleExportClick = useCallback(() => { + if (!projectListResponse?.count) { + return; + } + triggerExportStart( + '/api/v2/project/', + projectListResponse?.count, + { + country: isDefined(countryId) + ? [countryId] + : undefined, + }, + ); + }, [ + countryId, + triggerExportStart, + projectListResponse?.count, + ]); + + const showCard1 = activeNSCount > 0 || targetedPopulation > 0; + const showCard2 = filteredProjectList.length > 0 || programmeTypeStats.length > 0; + const showCard3 = ongoingProjectBudget > 0 || projectStatusTypeStats.length > 0; + const showCardsSection = showCard1 || showCard2 || showCard3; + + return ( + + + {strings.addThreeWProject} + + + ) + )} + > + {projectListPending && } + {!projectListPending && showCardsSection && ( +
+ {showCard1 && ( +
+ +
+ +
+ )} + {showCard2 && ( +
+ +
+ + + +
+ )} + {showCard3 && ( +
+ +
+ + + +
+ )} +
+ )} + + + )} + actions={( + <> + + + {strings.viewAllProjects} + + + )} + > + + {districtIdList.map((districtId) => { + const projectsInDistrict = districtGroupedProject[districtId]; + + if (isNotDefined(projectsInDistrict) + || projectsInDistrict.length === 0 + ) { + return null; + } + + const district = districtIdToDetailMap[+districtId]; + + if (isNotDefined(district)) { + return ( + + {/* NOTE: projects array will always have an element + * as we are using listToGroupList to get it. + */} + {projectsInDistrict.map((project) => ( +
+
+ {project.name} +
+ } + className={styles.action} + > + {strings.projectEdit} + +
+ ))} +
+ ); + } + + return ( + + {/* NOTE: projects array will always have an element + * as we are using listToGroupList to get it. + */} + {projectsInDistrict.map((project) => ( +
+
+ {project.name} +
+ +
+ ))} +
+ ); + })} + {districtIdList.length === 0 && ( + + )} +
+ )} + /> + +
+ +
+ + +
+ + + + ); +} + +Component.displayName = 'CountryOngoingActivitiesThreeWProjects'; diff --git a/src/views/CountryOngoingActivitiesThreeWProjects/styles.module.css b/app/src/views/CountryOngoingActivitiesThreeWProjects/styles.module.css similarity index 100% rename from src/views/CountryOngoingActivitiesThreeWProjects/styles.module.css rename to app/src/views/CountryOngoingActivitiesThreeWProjects/styles.module.css diff --git a/src/views/CountryOperations/AppealOperationTable/i18n.json b/app/src/views/CountryOperations/AppealOperationTable/i18n.json similarity index 100% rename from src/views/CountryOperations/AppealOperationTable/i18n.json rename to app/src/views/CountryOperations/AppealOperationTable/i18n.json diff --git a/app/src/views/CountryOperations/AppealOperationTable/index.tsx b/app/src/views/CountryOperations/AppealOperationTable/index.tsx new file mode 100644 index 000000000..101a0ab03 --- /dev/null +++ b/app/src/views/CountryOperations/AppealOperationTable/index.tsx @@ -0,0 +1,229 @@ +import { useMemo } from 'react'; +import { + Container, + DateInput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createNumberColumn, + createStringColumn, + formatNumber, + resolveToComponent, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + type ListResponseItem, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type AppealTableItem = ListResponseItem>; +function keySelector(appeal: AppealTableItem) { + return appeal.id; +} + +const now = new Date().toISOString(); + +interface Props { + countryId: number; + countryName?: string; +} + +function AppealOperationTable(props: Props) { + const { + countryId, + countryName, + } = props; + + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + startDateAfter?: string, + startDateBefore?: string, + dType?: number, + }>({ + filter: {}, + pageSize: 10, + }); + + const strings = useTranslation(i18n); + + const { + pending: countryAppealPending, + response: countryAppealResponse, + } = useRequest({ + skip: isNotDefined(countryId), + preserveResponse: true, + url: '/api/v2/appeal/', + query: { + end_date__gt: now, + limit, + offset, + ordering, + country: [countryId], + dtype: filter.dType, + start_date__gte: filter.startDateAfter, + start_date__lte: filter.startDateBefore, + }, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'start_date', + strings.appealsTableStartDate, + (item) => item.start_date, + { sortable: true }, + ), + createStringColumn( + 'appeal__name', + strings.appealsTableName, + (item) => item.name, + { sortable: true }, + ), + createLinkColumn( + 'event', + strings.appealsTableEmergency, + (item) => (isDefined(item.event) ? strings.appealsTableLink : undefined), + (item) => ({ + to: 'emergenciesLayout', + urlParams: { + emergencyId: item.event, + }, + }), + ), + createStringColumn( + 'dtype', + strings.appealsTableDisastertype, + (item) => item.dtype.name, + { sortable: true }, + ), + createNumberColumn( + 'amount_requested', + strings.appealsTableRequestedAmount, + (item) => item.amount_requested, + { + sortable: true, + suffix: ' CHF', + }, + ), + createNumberColumn( + 'amount_funded', + strings.appealsTableFundedAmount, + (item) => item.amount_funded, + { sortable: true }, + ), + createStringColumn( + 'status', + strings.appealsTableStatus, + (item) => item.status_display, + { sortable: true }, + ), + ]), + [ + strings.appealsTableLink, + strings.appealsTableStartDate, + strings.appealsTableName, + strings.appealsTableEmergency, + strings.appealsTableDisastertype, + strings.appealsTableRequestedAmount, + strings.appealsTableFundedAmount, + strings.appealsTableStatus, + ], + ); + + const viewAllOperationsLinkLabel = resolveToComponent( + strings.appealsTableAllOperationsLinkLabel, + { name: countryName }, + ); + + const heading = resolveToString( + strings.appealsTableHeading, + { numOperations: formatNumber(countryAppealResponse?.count) ?? '--' }, + ); + + return ( + + + + + + )} + actions={( + + {viewAllOperationsLinkLabel} + + )} + footerActions={( + + )} + > + +
+ + + ); +} + +export default AppealOperationTable; diff --git a/src/views/CountryOperations/EmergenciesOperationTable/i18n.json b/app/src/views/CountryOperations/EmergenciesOperationTable/i18n.json similarity index 100% rename from src/views/CountryOperations/EmergenciesOperationTable/i18n.json rename to app/src/views/CountryOperations/EmergenciesOperationTable/i18n.json diff --git a/app/src/views/CountryOperations/EmergenciesOperationTable/index.tsx b/app/src/views/CountryOperations/EmergenciesOperationTable/index.tsx new file mode 100644 index 000000000..76ab78428 --- /dev/null +++ b/app/src/views/CountryOperations/EmergenciesOperationTable/index.tsx @@ -0,0 +1,222 @@ +import { useMemo } from 'react'; +import { + Container, + DateInput, + Pager, + Table, +} from '@ifrc-go/ui'; +import { SortContext } from '@ifrc-go/ui/contexts'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createDateColumn, + createNumberColumn, + createStringColumn, + formatNumber, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + encodeDate, + max, +} from '@togglecorp/fujs'; + +import DisasterTypeSelectInput from '#components/domain/DisasterTypeSelectInput'; +import Link from '#components/Link'; +import useFilterState from '#hooks/useFilterState'; +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { + type GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; + +type EmergenciesTableItem = NonNullable['results']>[number]; +function keySelector(emergency: EmergenciesTableItem) { + return emergency.id; +} + +// FIXME: use a separate utility +const thirtyDaysAgo = new Date(); +thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); +thirtyDaysAgo.setHours(0, 0, 0, 0); + +function getMostRecentAffectedValue(fieldReport: EmergenciesTableItem['field_reports']) { + const latestReport = max(fieldReport, (item) => new Date(item.updated_at).getTime()); + return latestReport?.num_affected; +} + +interface Props { + countryId: number; + countryName?: string; +} + +function EmergenciesOperationTable(props: Props) { + const { + countryId, + countryName, + } = props; + + const strings = useTranslation(i18n); + const { + sortState, + ordering, + page, + setPage, + limit, + offset, + rawFilter, + filter, + setFilterField, + filtered, + } = useFilterState<{ + startDateAfter?: string, + startDateBefore?: string, + dType?: number, + }>({ + filter: { + startDateAfter: encodeDate(thirtyDaysAgo), + }, + pageSize: 10, + }); + + const columns = useMemo( + () => ([ + createDateColumn( + 'disaster_start_date', + strings.emergenciesTableStartDate, + (item) => item.disaster_start_date, + { sortable: true }, + ), + createLinkColumn( + 'name', + strings.emergenciesTableName, + (item) => item.name, + (item) => ({ + to: 'emergenciesLayout', + urlParams: { emergencyId: item.id }, + }), + { sortable: true }, + ), + createStringColumn( + 'dtype', + strings.emergenciesTableDisasterType, + (item) => item.dtype?.name, + ), + createStringColumn( + 'glide', + strings.emergenciesTableGlide, + (item) => item.glide, + { sortable: true }, + ), + createNumberColumn( + 'amount_requested', + strings.emergenciesTableRequestedAmount, + (item) => item.appeals[0]?.amount_requested, + { + suffix: ' CHF', + }, + ), + createNumberColumn( + 'num_affected', + strings.emergenciesTableAffected, + (item) => item.num_affected ?? getMostRecentAffectedValue(item.field_reports), + ), + ]), + [ + strings.emergenciesTableStartDate, + strings.emergenciesTableName, + strings.emergenciesTableDisasterType, + strings.emergenciesTableGlide, + strings.emergenciesTableRequestedAmount, + strings.emergenciesTableAffected, + ], + ); + + const { + pending: countryEmergenciesPending, + response: countryEmergenciesResponse, + } = useRequest({ + url: '/api/v2/event/', + preserveResponse: true, + query: { + limit, + offset, + countries__in: countryId, + ordering, + disaster_start_date__gte: filter.startDateAfter, + disaster_start_date__lte: filter.startDateBefore, + dtype: filter.dType, + }, + }); + + const viewAllEmergenciesLinkLabel = resolveToComponent( + strings.emergenciesTableAllEmergenciesLinkLabel, + { name: countryName }, + ); + + const emergenciesHeading = resolveToComponent( + strings.emergenciesTableHeading, + { count: formatNumber(countryEmergenciesResponse?.count) ?? '--' }, + ); + + return ( + + + + + + )} + actions={( + + {viewAllEmergenciesLinkLabel} + + )} + footerActions={( + + )} + > + +
+ + + ); +} + +export default EmergenciesOperationTable; diff --git a/app/src/views/CountryOperations/index.tsx b/app/src/views/CountryOperations/index.tsx new file mode 100644 index 000000000..1468328af --- /dev/null +++ b/app/src/views/CountryOperations/index.tsx @@ -0,0 +1,40 @@ +import { + useOutletContext, + useParams, +} from 'react-router-dom'; +import { isFalsyString } from '@togglecorp/fujs'; + +import { type CountryOutletContext } from '#utils/outletContext'; + +import AppealOperationTable from './AppealOperationTable'; +import EmergenciesOperationTable from './EmergenciesOperationTable'; + +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId } = useParams<{ countryId: string }>(); + const { countryResponse } = useOutletContext(); + + // FIXME: show proper error message + if (isFalsyString(countryId)) { + return null; + } + + const numericCountryId = Number(countryId); + + return ( +
+ + +
+ ); +} + +Component.displayName = 'CountryOperations'; diff --git a/src/views/CountryOperations/styles.module.css b/app/src/views/CountryOperations/styles.module.css similarity index 100% rename from src/views/CountryOperations/styles.module.css rename to app/src/views/CountryOperations/styles.module.css diff --git a/src/views/CountryPreparedness/PreviousAssessmentChart/i18n.json b/app/src/views/CountryPreparedness/PreviousAssessmentChart/i18n.json similarity index 100% rename from src/views/CountryPreparedness/PreviousAssessmentChart/i18n.json rename to app/src/views/CountryPreparedness/PreviousAssessmentChart/i18n.json diff --git a/app/src/views/CountryPreparedness/PreviousAssessmentChart/index.tsx b/app/src/views/CountryPreparedness/PreviousAssessmentChart/index.tsx new file mode 100644 index 000000000..52ebc1c2e --- /dev/null +++ b/app/src/views/CountryPreparedness/PreviousAssessmentChart/index.tsx @@ -0,0 +1,142 @@ +import { + ElementRef, + useRef, +} from 'react'; +import { ChartAxes } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + formatDate, + getDiscretePathDataList, + resolveToString, +} from '@ifrc-go/ui/utils'; + +import useChartData from '#hooks/useChartData'; +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type LatestPerResponse = GoApiResponse<'/api/v2/latest-per-overview/'>; +type PreviousRatings = NonNullable[number]['assessment_ratings']; + +const X_AXIS_HEIGHT = 20; +const Y_AXIS_WIDTH = 20; +const DEFAULT_CHART_MARGIN = 40; + +const chartMargin = { + left: DEFAULT_CHART_MARGIN, + top: DEFAULT_CHART_MARGIN, + right: DEFAULT_CHART_MARGIN, + bottom: DEFAULT_CHART_MARGIN, +}; + +const chartPadding = { + left: 20, + top: 10, + right: 20, + bottom: 10, +}; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 0, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +interface Props { + data: PreviousRatings; +} + +function PreviousAssessmentCharts(props: Props) { + // FIXME: we need rating_display for average_rating + const strings = useTranslation(i18n); + const { data } = props; + const containerRef = useRef>(null); + + const { + dataPoints, + xAxisTicks, + yAxisTicks, + chartSize, + } = useChartData( + data, + { + containerRef, + chartOffset, + chartMargin, + chartPadding, + keySelector: (datum) => datum.assessment_number, + xValueSelector: (datum) => datum.assessment_number, + type: 'categorical', + xAxisLabelSelector: (datum) => resolveToString( + strings.cycleLabel, + { + assessmentNumber: datum.assessment_number, + assessmentDate: formatDate(datum.date_of_assessment, 'yyyy') ?? '', + }, + ), + yValueSelector: (datum) => datum.average_rating ?? 0, + }, + ); + + return ( +
+ + + {dataPoints && ( + + )} + {dataPoints.map( + (point) => ( + + + {Number(point.originalData.average_rating?.toFixed(2)) ?? '-'} + + + + {resolveToString( + strings.assessmentLabel, + { + xValue: point.originalData.assessment_number, + yValue: point.originalData.average_rating ?? '-', + }, + )} + + + + ), + )} + +
+ ); +} + +export default PreviousAssessmentCharts; diff --git a/src/views/CountryPreparedness/PreviousAssessmentChart/styles.module.css b/app/src/views/CountryPreparedness/PreviousAssessmentChart/styles.module.css similarity index 100% rename from src/views/CountryPreparedness/PreviousAssessmentChart/styles.module.css rename to app/src/views/CountryPreparedness/PreviousAssessmentChart/styles.module.css diff --git a/src/views/CountryPreparedness/PublicCountryPreparedness/i18n.json b/app/src/views/CountryPreparedness/PublicCountryPreparedness/i18n.json similarity index 100% rename from src/views/CountryPreparedness/PublicCountryPreparedness/i18n.json rename to app/src/views/CountryPreparedness/PublicCountryPreparedness/i18n.json diff --git a/app/src/views/CountryPreparedness/PublicCountryPreparedness/index.tsx b/app/src/views/CountryPreparedness/PublicCountryPreparedness/index.tsx new file mode 100644 index 000000000..5c06f0f24 --- /dev/null +++ b/app/src/views/CountryPreparedness/PublicCountryPreparedness/index.tsx @@ -0,0 +1,194 @@ +import { + Fragment, + useMemo, +} from 'react'; +import { + Container, + Heading, + Message, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import useAuth from '#hooks/domain/useAuth'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +interface Props { + perId?: number; +} + +function PublicCountryPreparedness(props: Props) { + const { + perId, + } = props; + const { isAuthenticated } = useAuth(); + const strings = useTranslation(i18n); + const { + pending: processStatusPending, + response: processStatusResponse, + } = useRequest({ + skip: isNotDefined(perId) || isAuthenticated, + url: '/api/v2/public-per-process-status/{id}/', + pathVariables: { + id: Number(perId), + }, + }); + + const { + pending: assessmentResponsePending, + response: assessmentResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.assessment) || isAuthenticated, + url: '/api/v2/public-per-assessment/{id}/', + pathVariables: { + id: Number(processStatusResponse?.assessment), + }, + }); + + const { + pending: prioritizationResponsePending, + response: prioritizationResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.prioritization) || isAuthenticated, + url: '/api/v2/public-per-prioritization/{id}/', + pathVariables: { + id: Number(processStatusResponse?.prioritization), + }, + }); + + const topFiveRatedComponents = useMemo( + () => { + if (isNotDefined(assessmentResponse) + || isNotDefined(assessmentResponse.area_responses) + || assessmentResponse.area_responses.length === 0 + ) { + return undefined; + } + + const componentList = assessmentResponse.area_responses.flatMap( + (areaResponse) => ( + areaResponse.component_responses?.map( + (componentResponse) => ({ + rating: componentResponse.rating_details, + details: componentResponse.component_details, + }), + ) + ), + ).filter(isDefined) ?? []; + + const topFiveComponents = [...componentList].sort( + (a, b) => ( + compareNumber(a.rating?.value ?? 0, b.rating?.value ?? 0, -1) + ), + ).slice(0, 5); + + return topFiveComponents; + }, + [assessmentResponse], + ); + + const componentsToBeStrengthened = useMemo( + () => { + if (isNotDefined(prioritizationResponse)) { + return undefined; + } + + const componentsWithRating = prioritizationResponse.prioritized_action_responses?.map( + (componentResponse) => ({ + id: componentResponse.id, + details: componentResponse.component_details, + }), + ) ?? []; + + const components = componentsWithRating.map( + (component) => ({ + id: component.id, + label: component.details.title, + componentNumber: component.details.component_num, + componentLetter: component.details.component_letter, + }), + ); + + return components; + }, + [prioritizationResponse], + ); + + const pending = processStatusPending + || assessmentResponsePending + || prioritizationResponsePending; + + if (pending) { + return ( + + ); + } + + return ( + + {isDefined(topFiveRatedComponents) && ( + + {topFiveRatedComponents.map( + (component) => ( + + {component.details.title} + + ), + )} + + )} + {isDefined(componentsToBeStrengthened) && ( + + {componentsToBeStrengthened.map( + (priorityComponent) => ( + + + {resolveToString(strings.publicPriorityComponentHeading, { + componentNumber: priorityComponent.componentNumber, + componentLetter: priorityComponent.componentLetter, + componentName: priorityComponent.label, + })} + + + ), + )} + + )} + + ); +} + +export default PublicCountryPreparedness; diff --git a/src/views/CountryPreparedness/PublicCountryPreparedness/styles.module.css b/app/src/views/CountryPreparedness/PublicCountryPreparedness/styles.module.css similarity index 100% rename from src/views/CountryPreparedness/PublicCountryPreparedness/styles.module.css rename to app/src/views/CountryPreparedness/PublicCountryPreparedness/styles.module.css diff --git a/app/src/views/CountryPreparedness/RatingByAreaChart/index.tsx b/app/src/views/CountryPreparedness/RatingByAreaChart/index.tsx new file mode 100644 index 000000000..fe5de32b0 --- /dev/null +++ b/app/src/views/CountryPreparedness/RatingByAreaChart/index.tsx @@ -0,0 +1,130 @@ +import { + ElementRef, + useRef, +} from 'react'; +import { ChartAxes } from '@ifrc-go/ui'; +import { listToMap } from '@togglecorp/fujs'; + +import useChartData from '#hooks/useChartData'; +import { + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import { type GoApiResponse } from '#utils/restRequest'; + +import styles from './styles.module.css'; + +type PerOptionsResponse = GoApiResponse<'/api/v2/per-options/'>; +// type PerFormAreaResponse = GoApiResponse<'/api/v2/per-formarea/'>; + +const X_AXIS_HEIGHT = 50; +const Y_AXIS_WIDTH = 90; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 10, + right: 30, + bottom: X_AXIS_HEIGHT, +}; + +interface Props { + data: { + id: number; + areaNum: number | undefined; + title: string; + value: number; + }[] | undefined; + ratingOptions: PerOptionsResponse['componentratings'] | undefined; + // formAreaOptions: PerFormAreaResponse['results'] | undefined; +} + +function RatingByAreaChart(props: Props) { + const { + data, + ratingOptions, + // formAreaOptions, + } = props; + + const containerRef = useRef>(null); + const ratingTitleMap = listToMap( + ratingOptions, + (option) => option.value, + (option) => option.title, + ); + + const { + dataPoints, + chartSize, + xAxisTicks, + yAxisTicks, + } = useChartData( + data, + { + containerRef, + chartOffset, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + keySelector: (datum) => datum.id, + xValueSelector: (datum) => datum.areaNum ?? 0, + yValueSelector: (datum) => datum.value, + xAxisLabelSelector: (datum) => datum.title, + yAxisLabelSelector: (rating) => ratingTitleMap?.[rating], + type: 'categorical', + yDomain: { min: 0, max: 5 }, + }, + ); + + const barWidth = 10; + + return ( +
+ + + {dataPoints.map( + (point) => ( + + {point.originalData.value !== 0 && ( + + {Number(point.originalData.value.toFixed(2)) ?? '-'} + + )} + + + ), + )} + +
+ ); +} + +export default RatingByAreaChart; diff --git a/src/views/CountryPreparedness/RatingByAreaChart/styles.module.css b/app/src/views/CountryPreparedness/RatingByAreaChart/styles.module.css similarity index 100% rename from src/views/CountryPreparedness/RatingByAreaChart/styles.module.css rename to app/src/views/CountryPreparedness/RatingByAreaChart/styles.module.css diff --git a/src/views/CountryPreparedness/i18n.json b/app/src/views/CountryPreparedness/i18n.json similarity index 100% rename from src/views/CountryPreparedness/i18n.json rename to app/src/views/CountryPreparedness/i18n.json diff --git a/app/src/views/CountryPreparedness/index.tsx b/app/src/views/CountryPreparedness/index.tsx new file mode 100644 index 000000000..becfeace2 --- /dev/null +++ b/app/src/views/CountryPreparedness/index.tsx @@ -0,0 +1,683 @@ +import { + Fragment, + useCallback, + useMemo, +} from 'react'; +import { useParams } from 'react-router-dom'; +import { + AnalyzingIcon, + ArrowLeftLineIcon, + CheckboxFillIcon, +} from '@ifrc-go/icons'; +import { + BlockLoading, + Button, + Container, + Heading, + KeyFigure, + Message, + PieChart, + ProgressBar, + StackedProgressBar, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + numericCountSelector, + numericIdSelector, + resolveToString, + stringLabelSelector, + stringTitleSelector, + sumSafe, +} from '@ifrc-go/ui/utils'; +import { + compareNumber, + isDefined, + isNotDefined, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import WikiLink from '#components/WikiLink'; +import useRouting from '#hooks/useRouting'; +import { useRequest } from '#utils/restRequest'; + +import PreviousAssessmentCharts from './PreviousAssessmentChart'; +import PublicCountryPreparedness from './PublicCountryPreparedness'; +import RatingByAreaChart from './RatingByAreaChart'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const primaryRedColorShades = [ + 'var(--go-ui-color-red-90)', + 'var(--go-ui-color-red-70)', + 'var(--go-ui-color-red-50)', + 'var(--go-ui-color-red-30)', + 'var(--go-ui-color-red-20)', + 'var(--go-ui-color-red-10)', +]; + +function primaryRedColorShadeSelector(_: unknown, i: number) { + return primaryRedColorShades[i]; +} + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { perId, countryId } = useParams<{ perId: string, countryId: string }>(); + // const { countryId } = useParams<{ countryId: string }>(); + + const { + pending: pendingLatestPerResponse, + response: latestPerResponse, + // error: latestPerResponseError, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/latest-per-overview/', + query: { country_id: Number(countryId) }, + }); + + // const countryHasNoPer = latestPerResponse?.results?.length === 0; + + // FIXME: add feature on server (low priority) + // we get a list form the server because we are using a filter on listing api + // const perId = latestPerResponse?.results?.[0]?.id; + + const latestPerOverview = latestPerResponse?.results?.[0]; + const prevAssessmentRatings = latestPerOverview?.assessment_ratings; + + const { + pending: formAnswerPending, + response: formAnswerResponse, + } = useRequest({ + skip: isNotDefined(perId), + url: '/api/v2/per-formanswer/', + }); + + const { + pending: perOptionsPending, + response: perOptionsResponse, + } = useRequest({ + skip: isNotDefined(perId), + url: '/api/v2/per-options/', + }); + + const { + pending: perFormAreaPending, + response: perFormAreaResponse, + } = useRequest({ + url: '/api/v2/per-formarea/', + }); + + const { + pending: perProcessStatusPending, + response: processStatusResponse, + } = useRequest({ + skip: isNotDefined(perId), + url: '/api/v2/per-process-status/{id}/', + pathVariables: isDefined(perId) ? { + id: Number(perId), + } : undefined, + }); + + const { + pending: overviewPending, + response: overviewResponse, + } = useRequest({ + skip: isNotDefined(perId), + url: '/api/v2/per-overview/{id}/', + pathVariables: isDefined(perId) ? { + id: Number(perId), + } : undefined, + }); + + const { + pending: assessmentResponsePending, + response: assessmentResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.assessment), + url: '/api/v2/per-assessment/{id}/', + pathVariables: { + id: Number(processStatusResponse?.assessment), + }, + }); + + const { + pending: prioritizationResponsePending, + response: prioritizationResponse, + } = useRequest({ + skip: isNotDefined(processStatusResponse?.prioritization), + url: '/api/v2/per-prioritization/{id}/', + pathVariables: { + id: Number(processStatusResponse?.prioritization), + }, + }); + + const formAnswerMap = useMemo( + () => ( + listToMap( + formAnswerResponse?.results ?? [], + (answer) => answer.id, + (answer) => answer.text, + ) + ), + [formAnswerResponse], + ); + + const assessmentStats = useMemo( + () => { + if (isNotDefined(assessmentResponse) + || isNotDefined(assessmentResponse.area_responses) + || assessmentResponse.area_responses.length === 0 + ) { + return undefined; + } + + const componentList = assessmentResponse.area_responses.flatMap( + (areaResponse) => ( + areaResponse.component_responses?.map( + (componentResponse) => ({ + area: areaResponse.area_details, + rating: componentResponse.rating_details, + details: componentResponse.component_details, + notes: componentResponse.notes, + }), + ) + ), + ).filter(isDefined) ?? []; + + const topRatedComponents = [...componentList].sort( + (a, b) => ( + compareNumber(a.rating?.value ?? 0, b.rating?.value ?? 0, -1) + ), + ); + + const topFiveRatedComponents = topRatedComponents.filter( + (component) => isDefined(component.rating), + ).slice(0, 5); + + // FIXME: let's use avgSafe + function getAverage(list: number[]) { + if (list.length === 0) { + return 0; + } + + const total = sumSafe(list); + if (isNotDefined(total)) { + return 0; + } + + return total / list.length; + } + + /* NOTE: The calculation of the average rating is done omitting null or + * "0"(not - reviewed") component values + */ + const filteredComponents = componentList.filter( + (component) => isDefined(component) + && isDefined(component.rating) && component.rating.value !== 0, + ); + + const ratingByArea = mapToList( + listToGroupList( + filteredComponents, + (component) => component.area.id, + ), + (groupedComponentList) => ({ + id: groupedComponentList[0].area.id, + areaNum: groupedComponentList[0].area.area_num, + title: groupedComponentList[0].area.title, + value: getAverage( + groupedComponentList.map( + (component) => ( + isDefined(component.rating) + ? component.rating.value + : undefined + ), + ).filter(isDefined), + ), + }), + ).filter(isDefined); + + const averageRating = getAverage( + filteredComponents.map( + (component) => ( + isDefined(component.rating) + ? component.rating.value + : undefined + ), + ).filter(isDefined), + ); + + const ratingCounts = mapToList( + listToGroupList( + componentList.map( + (component) => ( + isDefined(component.rating) + ? { ...component, rating: component.rating } + : undefined + ), + ).filter(isDefined), + (component) => component.rating.value, + ), + (ratingList) => ({ + id: ratingList[0].rating?.id, + value: ratingList[0].rating?.value, + count: ratingList.length, + title: ratingList[0].rating?.title, + }), + ).sort((a, b) => ( + compareNumber(a.value, b.value, -1) + )); + + const componentAnswerList = assessmentResponse.area_responses.flatMap( + (areaResponse) => ( + areaResponse.component_responses?.flatMap( + (componentResponse) => componentResponse.question_responses, + ) + ), + ).filter(isDefined) ?? []; + + const answerCounts = mapToList( + listToGroupList( + componentAnswerList.map( + (componentAnswer) => { + const { answer } = componentAnswer; + if (isNotDefined(answer)) { + return null; + } + + return { + ...componentAnswer, + answer, + }; + }, + ).filter(isDefined), + (questionResponse) => questionResponse.answer, + ), + (answerList) => ({ + id: answerList[0].answer, + // FIXME: use strings + label: `${formAnswerMap[answerList[0].answer]} ${answerList.length}`, + count: answerList.length, + }), + ); + + return { + ratingCounts, + averageRating, + answerCounts, + ratingByArea, + topRatedComponents, + topFiveRatedComponents, + componentList, + }; + }, + [assessmentResponse, formAnswerMap], + ); + + const prioritizationStats = useMemo( + () => { + if (isNotDefined(prioritizationResponse) || isNotDefined(assessmentStats)) { + return undefined; + } + + const ratingByComponentId = listToMap( + assessmentStats.componentList, + (component) => component.details.id, + (component) => component.rating, + ); + + const componentsWithRating = prioritizationResponse.prioritized_action_responses?.map( + (componentResponse) => ({ + id: componentResponse.id, + details: componentResponse.component_details, + rating: ratingByComponentId[componentResponse.component], + }), + ) ?? []; + + const componentsToBeStrengthened = componentsWithRating.map( + (component) => ({ + id: component.id, + value: component.rating?.value, + label: component.details.title, + num: component.details.component_num, + letter: component.details.component_letter, + rating: component.rating, + }), + ).sort((a, b) => compareNumber(b.rating?.value ?? 0, a.rating?.value ?? 0)); + + return { + componentsWithRating, + componentsToBeStrengthened, + }; + }, + [prioritizationResponse, assessmentStats], + ); + + const { goBack } = useRouting(); + const handleBackButtonClick = useCallback(() => { + goBack(); + }, [goBack]); + + const hasPer = isDefined(perId); + // const hasPrevAssessments = true; + + // const hasPer = isDefined(latestPerResponse); + const limitedAccess = hasPer && isNotDefined(processStatusResponse); + + const hasAssessmentStats = hasPer && isDefined(assessmentStats); + const hasPrioritizationStats = hasPer && isDefined(prioritizationStats); + + const hasRatingCounts = hasAssessmentStats && assessmentStats.ratingCounts.length > 0; + const hasAnswerCounts = hasAssessmentStats && assessmentStats.answerCounts.length > 0; + const hasRatedComponents = hasAssessmentStats && assessmentStats.topRatedComponents.length > 0; + const hasRatingsByArea = hasAssessmentStats && assessmentStats.ratingByArea.length > 0; + const hasPriorityComponents = hasPrioritizationStats + && prioritizationStats.componentsWithRating.length > 0; + const hasPrevAssessments = prevAssessmentRatings && prevAssessmentRatings.length > 1; + const showComponentsByArea = hasRatingsByArea + && perOptionsResponse + && perFormAreaResponse; + + const pending = formAnswerPending + || pendingLatestPerResponse + || perOptionsPending + || perFormAreaPending + || perProcessStatusPending + || overviewPending + || assessmentResponsePending + || prioritizationResponsePending; + + /* + if (pendingLatestPerResponse) { + return ( + + ); + } + + if (isNotDefined(countryId) + || (!pendingLatestPerResponse && isDefined(latestPerResponseError))) { + return ( + } + title={strings.nsPreparednessAndResponseCapacityHeading} + description={strings.componentMessageDescription} + /> + ); + } + + if (countryHasNoPer) { + return ( + } + title={strings.nsPreparednessAndResponseCapacityHeading} + description={strings.componentMessagePERDataNotAvailable} + /> + ); + } + */ + + return ( + + + + + )} + icons={( + + )} + > +
+ + + + + + +
+ + {strings.componentContactPERTeam} + +
+
+ {pending && ( + + )} + {hasRatingCounts && ( + + + + )} + {hasAnswerCounts && ( + + + + + )} + {showComponentsByArea && ( + + + + )} + {!pending && hasPrevAssessments && ( + + + + )} + {hasRatedComponents && ( + + {assessmentStats.topFiveRatedComponents.map( + (component) => ( + } + withoutWrapInHeading + > + {component.details.title} + + ), + )} + + )} + {hasPriorityComponents && ( + + {prioritizationStats.componentsToBeStrengthened.map( + (priorityComponent) => ( +
+ + {resolveToString(strings.priorityComponentHeading, { + componentNumber: priorityComponent.num, + componentLetter: priorityComponent.letter, + componentName: priorityComponent.label, + })} + + +
+ ), + )} +
+ )} + {hasRatedComponents && ( + + {assessmentStats.topRatedComponents.map( + (component) => ( + + + {resolveToString(strings.priorityComponentHeading, { + componentNumber: component.details.component_num, + componentLetter: component.details.component_letter, + componentName: component.details.title, + })} + + +
+ {component.notes} +
+
+ + ), + )} + + )} + {!pending && !limitedAccess && !hasAssessmentStats && ( + } + title={strings.componentChartNotAvailable} + description={strings.componentChartNotAvailableDescription} + /> + )} + {!pending && limitedAccess && isDefined(perId) && ( +
+ + + {strings.componentRequestSeeMore} + + )} + /> +
+ )} + + ); +} +Component.displayName = 'CountryPreparedness'; diff --git a/src/views/CountryPreparedness/styles.module.css b/app/src/views/CountryPreparedness/styles.module.css similarity index 100% rename from src/views/CountryPreparedness/styles.module.css rename to app/src/views/CountryPreparedness/styles.module.css diff --git a/src/views/CountryProfile/i18n.json b/app/src/views/CountryProfile/i18n.json similarity index 100% rename from src/views/CountryProfile/i18n.json rename to app/src/views/CountryProfile/i18n.json diff --git a/app/src/views/CountryProfile/index.tsx b/app/src/views/CountryProfile/index.tsx new file mode 100644 index 000000000..d3ea2e9a7 --- /dev/null +++ b/app/src/views/CountryProfile/index.tsx @@ -0,0 +1,53 @@ +import { + Outlet, + useOutletContext, +} from 'react-router-dom'; +import { NavigationTabList } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import NavigationTab from '#components/NavigationTab'; +import { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const outletContext = useOutletContext(); + const { countryId } = outletContext; + const strings = useTranslation(i18n); + + return ( +
+ + + {strings.overviewTabTitle} + + + {strings.supportingPartnersTabTitle} + + + {strings.previousEventsTabTitle} + + + {strings.riskWatchTabTitle} + + + +
+ ); +} + +Component.displayName = 'CountryProfile'; diff --git a/src/views/CountryProfile/styles.module.css b/app/src/views/CountryProfile/styles.module.css similarity index 100% rename from src/views/CountryProfile/styles.module.css rename to app/src/views/CountryProfile/styles.module.css diff --git a/src/views/CountryProfileOverview/i18n.json b/app/src/views/CountryProfileOverview/i18n.json similarity index 100% rename from src/views/CountryProfileOverview/i18n.json rename to app/src/views/CountryProfileOverview/i18n.json diff --git a/app/src/views/CountryProfileOverview/index.tsx b/app/src/views/CountryProfileOverview/index.tsx new file mode 100644 index 000000000..b7680649d --- /dev/null +++ b/app/src/views/CountryProfileOverview/index.tsx @@ -0,0 +1,91 @@ +import { useOutletContext } from 'react-router-dom'; +import { + BlockLoading, + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + + const { countryId } = useOutletContext(); + + const { + pending: indicatorPending, + response: indicatorResponse, + } = useRequest({ + url: '/api/v2/country/{id}/databank/', + skip: isNotDefined(countryId), + pathVariables: isDefined(countryId) ? { + id: Number(countryId), + } : undefined, + }); + + return ( + + {indicatorPending && } + + + + + + + + + ); +} + +Component.displayName = 'CountryProfileOverview'; diff --git a/src/views/CountryProfileOverview/styles.module.css b/app/src/views/CountryProfileOverview/styles.module.css similarity index 100% rename from src/views/CountryProfileOverview/styles.module.css rename to app/src/views/CountryProfileOverview/styles.module.css diff --git a/app/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/index.tsx b/app/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/index.tsx new file mode 100644 index 000000000..6ac18f641 --- /dev/null +++ b/app/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/index.tsx @@ -0,0 +1,144 @@ +import { + type ElementRef, + useRef, +} from 'react'; +import { + ChartAxes, + ChartPoint, + DateOutput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import useChartData from '#hooks/useChartData'; +import { + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import styles from './styles.module.css'; + +const currentYear = new Date().getFullYear(); +const firstDayOfYear = new Date(currentYear, 0, 1); +const lastDayOfYear = new Date(currentYear, 11, 31); + +const X_AXIS_HEIGHT = 20; +const Y_AXIS_WIDTH = 40; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 0, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +interface Props { + startDate: string | undefined; + countryId: string | undefined; +} + +function EmergenciesOverMonth(props: Props) { + const { + startDate, + countryId, + } = props; + + const containerRef = useRef>(null); + + const { + // pending: disasterMonthlyCountPending, + response: disasterMonthlyCountResponse, + } = useRequest({ + skip: isNotDefined(countryId) || isNotDefined(startDate), + url: '/api/v2/country/{id}/disaster-monthly-count/', + pathVariables: { id: countryId }, + query: { start_date: startDate }, + }); + + const { + dataPoints, + xAxisTicks, + yAxisTicks, + chartSize, + } = useChartData( + disasterMonthlyCountResponse?.filter( + (countItem) => isDefined(countItem.targeted_population), + ), + { + containerRef, + chartOffset, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + keySelector: (datum, i) => `${datum.date}-${i}`, + xValueSelector: (datum) => { + const date = new Date(datum.date); + date.setFullYear(currentYear); + return date.getTime(); + }, + type: 'temporal', + xAxisLabelSelector: (timestamp) => ( + new Date(timestamp).toLocaleString( + navigator.language, + { month: 'short' }, + ) + ), + yValueSelector: (datum) => datum.targeted_population, + xDomain: { + min: firstDayOfYear.getTime(), + max: lastDayOfYear.getTime(), + }, + yAxisStartsFromZero: true, + }, + ); + + return ( +
+ + {dataPoints.map( + (dataPoint) => ( + + + + + + )} + /> + + ), + )} + + +
+ ); +} + +export default EmergenciesOverMonth; diff --git a/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/styles.module.css b/app/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/styles.module.css similarity index 100% rename from src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/styles.module.css rename to app/src/views/CountryProfilePreviousEvents/EmergenciesOverMonth/styles.module.css diff --git a/app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx b/app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx new file mode 100644 index 000000000..40c51b17c --- /dev/null +++ b/app/src/views/CountryProfilePreviousEvents/PastEventsChart/index.tsx @@ -0,0 +1,158 @@ +import { + type ElementRef, + useMemo, + useRef, +} from 'react'; +import { + ChartAxes, + ChartPoint, + Container, + DateOutput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { + encodeDate, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import useChartData from '#hooks/useChartData'; +import { + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import styles from './styles.module.css'; + +const today = new Date(); + +const X_AXIS_HEIGHT = 20; +const Y_AXIS_WIDTH = 40; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 0, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +interface Props { + countryId: string | undefined; +} + +function PastEventsChart(props: Props) { + const { + countryId, + } = props; + + const containerRef = useRef>(null); + + const startDate = useMemo( + () => { + const startOfThisYear = new Date(today.getFullYear(), 0, 1); + startOfThisYear.setHours(0, 0, 0, 0); + const tenYearsAgo = new Date(startOfThisYear.getFullYear() - 10, 0, 1); + tenYearsAgo.setHours(0, 0, 0, 0); + + return encodeDate(tenYearsAgo); + }, + [], + ); + + const { + // pending: historicalDisastersPending, + response: historicalDisastersResponse, + } = useRequest({ + skip: isNotDefined(countryId) || isNotDefined(startDate), + url: '/api/v2/country/{id}/historical-disaster/', + pathVariables: { id: countryId }, + query: { start_date: startDate }, + }); + + const { + dataPoints, + xAxisTicks, + yAxisTicks, + chartSize, + } = useChartData( + historicalDisastersResponse?.filter( + (event) => isDefined(event.targeted_population), + ), + { + containerRef, + chartOffset, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + keySelector: (datum, i) => `${datum.date}-${i}`, + xValueSelector: (datum) => { + const date = new Date(datum.date); + return date.getTime(); + }, + type: 'temporal', + xAxisLabelSelector: (timestamp) => ( + new Date(timestamp).toLocaleString( + navigator.language, + { year: 'numeric', month: 'short' }, + ) + ), + yValueSelector: (datum) => datum.targeted_population, + }, + ); + + return ( + +
+ + {dataPoints.map( + (dataPoint) => ( + + + + + + )} + /> + + ), + )} + + +
+
+ ); +} + +export default PastEventsChart; diff --git a/src/views/CountryProfilePreviousEvents/PastEventsChart/styles.module.css b/app/src/views/CountryProfilePreviousEvents/PastEventsChart/styles.module.css similarity index 100% rename from src/views/CountryProfilePreviousEvents/PastEventsChart/styles.module.css rename to app/src/views/CountryProfilePreviousEvents/PastEventsChart/styles.module.css diff --git a/src/views/CountryProfilePreviousEvents/i18n.json b/app/src/views/CountryProfilePreviousEvents/i18n.json similarity index 100% rename from src/views/CountryProfilePreviousEvents/i18n.json rename to app/src/views/CountryProfilePreviousEvents/i18n.json diff --git a/app/src/views/CountryProfilePreviousEvents/index.tsx b/app/src/views/CountryProfilePreviousEvents/index.tsx new file mode 100644 index 000000000..104df50ad --- /dev/null +++ b/app/src/views/CountryProfilePreviousEvents/index.tsx @@ -0,0 +1,100 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + BarChart, + Container, + Message, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + encodeDate, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import EmergenciesOverMonth from './EmergenciesOverMonth'; +import PastEventsChart from './PastEventsChart'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const outletContext = useOutletContext(); + const { countryId } = outletContext; + + const startDate = useMemo( + () => { + const today = new Date(); + const startOfThisYear = new Date(today.getFullYear(), 0, 1); + startOfThisYear.setHours(0, 0, 0, 0); + const tenYearsAgo = new Date(startOfThisYear.getFullYear() - 10, 0, 1); + tenYearsAgo.setHours(0, 0, 0, 0); + + return encodeDate(tenYearsAgo); + }, + [], + ); + + const { + pending: disasterCountPending, + response: disasterCountResponse, + error: disasterCountError, + } = useRequest({ + skip: isNotDefined(countryId), + url: '/api/v2/country/{id}/disaster-count/', + pathVariables: { id: countryId }, + query: { start_date: startDate }, + }); + + return ( +
+ {(disasterCountPending || isDefined(disasterCountError)) && ( + + )} + {!(disasterCountPending || isDefined(disasterCountError)) && ( + + + disasterCountItem.disaster_name} + labelSelector={(disasterCountItem) => disasterCountItem.disaster_name} + valueSelector={(disasterCountItem) => disasterCountItem.count} + maxRows={8} + /> + + + + + + )} + +
+ ); +} + +Component.displayName = 'CountryProfilePreviousEvents'; diff --git a/src/views/CountryProfilePreviousEvents/styles.module.css b/app/src/views/CountryProfilePreviousEvents/styles.module.css similarity index 100% rename from src/views/CountryProfilePreviousEvents/styles.module.css rename to app/src/views/CountryProfilePreviousEvents/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/i18n.json b/app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/MultiMonthSelectInput/i18n.json rename to app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/index.tsx b/app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/index.tsx new file mode 100644 index 000000000..38cc91c5a --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/index.tsx @@ -0,0 +1,176 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { RawButton } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + _cs, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; +import type { SetValueArg } from '@togglecorp/toggle-form'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const keyList = Array.from(Array(12).keys()); +const defaultValue = listToMap( + keyList, + (key) => key, + () => false, +); + +interface Props { + className?: string; + value: Record | undefined; + name: NAME; + onChange: ( + newValue: SetValueArg | undefined>, + name: NAME, + ) => void; +} + +function MultiMonthSelectInput(props: Props) { + const { + className, + name, + value, + onChange, + } = props; + + const strings = useTranslation(i18n); + + const shiftPressedRef = useRef(false); + + useEffect( + () => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + shiftPressedRef.current = true; + } + }; + + const handleKeyUp = () => { + shiftPressedRef.current = false; + }; + + document.addEventListener('keyup', handleKeyUp); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keyup', handleKeyUp); + document.removeEventListener('keydown', handleKeyDown); + }; + }, + [], + ); + + const handleClick = useCallback( + (month: number) => { + if (isNotDefined(onChange)) { + return; + } + + onChange( + (prevValue) => { + const prevValueList = Object.values(prevValue ?? {}); + const numTruthyValues = prevValueList.filter(Boolean).length; + + if (month === 12 + || !shiftPressedRef.current + || numTruthyValues === 0 + || prevValue?.[12] + // Clicked on previously selected month + || (numTruthyValues === 1 && prevValue?.[month]) + ) { + // Selecting only single value + return { + ...defaultValue, + [month]: true, + }; + } + + const truthyValueStartIndex = prevValueList.findIndex(Boolean); + const newValueList = [...prevValueList]; + const lengthDiff = Math.abs(month - truthyValueStartIndex); + // Fill selection start to end with true + newValueList.splice( + Math.min(truthyValueStartIndex, month), + lengthDiff, + ...Array(lengthDiff + 1).fill(true), + ); + const maxIndex = Math.max(truthyValueStartIndex, month) + 1; + const remaining = newValueList.length - maxIndex; + // Fill remaining trailing value with false + newValueList.splice( + maxIndex, + remaining, + ...Array(remaining).fill(false), + ); + // Make sure that yearly average is always false when selecting a range + newValueList.splice(12, 1, false); + + return listToMap( + newValueList, + (_, key) => key, + (currentValue) => currentValue, + ); + }, + name, + ); + }, + [onChange, name], + ); + + return ( +
+
+ {keyList.map( + (key) => { + const date = new Date(); + date.setDate(1); + date.setMonth(key); + date.setHours(0, 0, 0, 0); + + const monthName = date.toLocaleString( + navigator.language, + { month: 'short' }, + ); + + return ( + + + {monthName} + + + + + + + + ); + }, + )} +
+
+ + + {strings.multiMonthYearlyAverage} + + +
+ ); +} + +export default MultiMonthSelectInput; diff --git a/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/styles.module.css b/app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/MultiMonthSelectInput/styles.module.css rename to app/src/views/CountryProfileRiskWatch/MultiMonthSelectInput/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/i18n.json b/app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/i18n.json rename to app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/index.tsx b/app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/index.tsx new file mode 100644 index 000000000..6b6da7e50 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/index.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react'; +import { + Container, + Pager, + SelectInput, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createStringColumn, + numericIdSelector, + stringKeySelector, + stringNameSelector, + stringValueSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; + +import type { components as riskApiComponents } from '#generated/riskTypes'; +import useFilterState from '#hooks/useFilterState'; +import type { + GoApiResponse, + RiskApiResponse, +} from '#utils/restRequest'; +import { useRiskRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type HazardType = riskApiComponents['schemas']['HazardTypeEnum']; + +type PossibleEarlyActionsResponse = RiskApiResponse<'/api/v1/early-actions/'>; +type ResponseItem = NonNullable[number]; +type CountryResponse = GoApiResponse<'/api/v2/country/{id}/'>; + +interface Props { + countryId: number; + countryResponse: CountryResponse | undefined; +} + +function PossibleEarlyActionTable(props: Props) { + const { + countryId, + countryResponse, + } = props; + const strings = useTranslation(i18n); + const { + page, + setPage, + rawFilter, + filter, + filtered, + setFilterField, + limit, + offset, + } = useFilterState<{ + // FIXME hazardType should be HazardType + // hazardType?: HazardType, + hazardType?: string, + sector?: string, + }>({ + filter: {}, + pageSize: 5, + }); + + const columns = useMemo( + () => ([ + createStringColumn( + 'hazard_type_display', + strings.earlyActionTableHazardTitle, + (item) => item?.hazard_type_display, + ), + createStringColumn( + 'early_actions', + strings.earlyActionTablePossibleActionTitle, + (item) => item?.early_actions, + ), + createStringColumn( + 'location', + strings.earlyActionTableLocationTitle, + (item) => item?.location, + ), + createStringColumn( + 'sector', + strings.earlyActionTableSectorTitle, + (item) => item?.sectors_details?.map((d) => d.name).join(', '), + ), + createStringColumn( + 'intended_purpose', + strings.earlyActionTablePurposeTitle, + (item) => item?.intended_purpose, + ), + createStringColumn( + 'organization', + strings.earlyActionTableOrganisationTitle, + (item) => item?.organization, + ), + createStringColumn( + 'implementation_date_raw', + strings.earlyActionTableDateTitle, + (item) => item?.implementation_date_raw, + ), + createStringColumn( + 'impact_actions', + strings.earlyActionTableImpactTitle, + (item) => item?.impact_action, + ), + createStringColumn( + 'evidence_of_success', + strings.earlyActionTableEvidenceTitle, + (item) => item?.evidence_of_sucess, + ), + ]), + [ + strings.earlyActionTableHazardTitle, + strings.earlyActionTablePossibleActionTitle, + strings.earlyActionTableLocationTitle, + strings.earlyActionTableSectorTitle, + strings.earlyActionTablePurposeTitle, + strings.earlyActionTableOrganisationTitle, + strings.earlyActionTableDateTitle, + strings.earlyActionTableImpactTitle, + strings.earlyActionTableEvidenceTitle, + ], + ); + + const { + response: earlyActionsOptionsResponse, + } = useRiskRequest({ + apiType: 'risk', + url: '/api/v1/early-actions/options/', + }); + + const { + pending: pendingPossibleEarlyAction, + response: possibleEarlyActionResponse, + } = useRiskRequest({ + skip: isNotDefined(countryId), + apiType: 'risk', + url: '/api/v1/early-actions/', + query: { + limit, + offset, + iso3: countryResponse?.iso3 ?? undefined, + hazard_type: isDefined(filter.hazardType) + ? [filter.hazardType] as HazardType[] + : undefined, + sectors: filter.sector, + }, + }); + + if (!filtered && possibleEarlyActionResponse?.count === 0) { + return null; + } + + return ( + + + + + )} + footerActions={( + + )} + > +
+ + ); +} + +export default PossibleEarlyActionTable; diff --git a/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/styles.module.css b/app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/styles.module.css rename to app/src/views/CountryProfileRiskWatch/PossibleEarlyActionTable/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/ReturnPeriodTable/i18n.json b/app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/ReturnPeriodTable/i18n.json rename to app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/index.tsx b/app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/index.tsx new file mode 100644 index 000000000..df258f7ab --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/index.tsx @@ -0,0 +1,217 @@ +import { useMemo } from 'react'; +import { + Container, + SelectInput, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createNumberColumn, + createStringColumn, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, + unique, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import type { + components, + paths, +} from '#generated/riskTypes'; +import useInputState from '#hooks/useInputState'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetCountryRisk = paths['/api/v1/country-seasonal/']['get']; +type CountryRiskResponse = GetCountryRisk['responses']['200']['content']['application/json']; +type HazardType = components<'read'>['schemas']['HazardTypeEnum']; +interface HazardTypeOption { + hazard_type: HazardType; + hazard_type_display: string; +} +interface TransformedReturnPeriodData { + frequencyDisplay: string; + displacement: number | undefined; + exposure: number | undefined; + economicLosses: number | undefined; +} + +function hazardTypeKeySelector(option: HazardTypeOption) { + return option.hazard_type; +} +function hazardTypeLabelSelector(option: HazardTypeOption) { + return option.hazard_type_display; +} +function returnPeriodKeySelector(option: TransformedReturnPeriodData) { + return option.frequencyDisplay; +} + +interface Props { + data: CountryRiskResponse[number]['return_period_data'] | undefined; +} + +function ReturnPeriodTable(props: Props) { + const { data } = props; + const strings = useTranslation(i18n); + + const [hazardType, setHazardType] = useInputState('FL'); + const hazardOptions = useMemo( + () => ( + unique( + data?.map( + (datum) => { + if (isFalsyString(datum.hazard_type)) { + return undefined; + } + + return { + hazard_type: datum.hazard_type, + hazard_type_display: datum.hazard_type_display, + }; + }, + ).filter(isDefined) ?? [], + (datum) => datum.hazard_type_display, + ) + ), + [data], + ); + + const columns = useMemo( + () => ([ + createStringColumn( + 'frequency', + strings.returnPeriodTableReturnPeriodTitle, + (item) => item.frequencyDisplay, + ), + createNumberColumn( + 'numRiskOfDisplacement', + strings.returnPeriodTableDisplacementTitle, + (item) => item.displacement, + { + headerInfoTitle: strings.returnPeriodTableDisplacementTitle, + headerInfoDescription: resolveToComponent( + strings.returnPeriodTableDisplacementDescription, + { + source: ( + + {strings.returnPeriodTableDisplacementSource} + + ), + }, + ), + maximumFractionDigits: 0, + }, + ), + createNumberColumn( + 'economicLosses', + strings.returnPeriodTableEconomicLossesTitle, + (item) => item.economicLosses, + { + headerInfoTitle: strings.returnPeriodTableEconomicLossesTitle, + headerInfoDescription: resolveToComponent( + strings.returnPeriodTableEconomicLossesDescription, + { + source: ( + + {strings.returnPeriodTableEconomicLossesSource} + + ), + }, + ), + }, + ), + ]), + [ + strings.returnPeriodTableReturnPeriodTitle, + strings.returnPeriodTableEconomicLossesTitle, + strings.returnPeriodTableEconomicLossesDescription, + strings.returnPeriodTableDisplacementDescription, + strings.returnPeriodTableDisplacementSource, + strings.returnPeriodTableEconomicLossesSource, + strings.returnPeriodTableDisplacementTitle, + ], + ); + + const transformedReturnPeriods = useMemo( + () => { + const value = data?.find( + (d) => d.hazard_type === hazardType, + ); + + return [ + { + frequencyDisplay: '1-in-20-year event', + displacement: value?.twenty_years?.population_displacement, + economicLosses: value?.twenty_years?.economic_loss, + exposure: value?.twenty_years?.economic_loss, + }, + { + frequencyDisplay: '1-in-50-year event', + displacement: value?.fifty_years?.population_displacement, + economicLosses: value?.fifty_years?.economic_loss, + exposure: value?.fifty_years?.population_exposure, + }, + { + frequencyDisplay: '1-in-100-year event', + displacement: value?.hundred_years?.population_displacement, + economicLosses: value?.hundred_years?.economic_loss, + exposure: value?.hundred_years?.population_exposure, + }, + { + frequencyDisplay: '1-in-250-year event', + displacement: value?.two_hundred_fifty_years?.population_displacement, + economicLosses: value?.two_hundred_fifty_years?.economic_loss, + exposure: value?.two_hundred_fifty_years?.population_exposure, + }, + { + frequencyDisplay: '1-in-500-year event', + displacement: value?.five_hundred_years?.population_displacement, + economicLosses: value?.five_hundred_years?.economic_loss, + exposure: value?.five_hundred_years?.population_exposure, + }, + ]; + }, + [data, hazardType], + ); + + return ( + + )} + > +
+ + ); +} + +export default ReturnPeriodTable; diff --git a/src/views/CountryProfileRiskWatch/ReturnPeriodTable/styles.module.css b/app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/ReturnPeriodTable/styles.module.css rename to app/src/views/CountryProfileRiskWatch/ReturnPeriodTable/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/i18n.json b/app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/i18n.json rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/index.tsx b/app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/index.tsx new file mode 100644 index 000000000..66ea6ac59 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/index.tsx @@ -0,0 +1,391 @@ +import { + Fragment, + useCallback, + useMemo, + useRef, +} from 'react'; +import { + ChartAxes, + DateOutput, + NumberOutput, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + listToMap, + mapToMap, +} from '@togglecorp/fujs'; + +import { type components } from '#generated/riskTypes'; +import useChartData, { ChartOptions } from '#hooks/useChartData'; +import { + CATEGORY_RISK_HIGH, + CATEGORY_RISK_LOW, + CATEGORY_RISK_MEDIUM, + CATEGORY_RISK_VERY_HIGH, + CATEGORY_RISK_VERY_LOW, + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import { + getDataWithTruthyHazardType, + getFiRiskDataItem, + getWfRiskDataItem, + hazardTypeToColorMap, + monthNumberToNameMap, + RiskMetricOption, + riskScoreToCategory, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; +type HazardType = components<'read'>['schemas']['HazardTypeEnum']; + +const selectedMonths = { + 0: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + 8: true, + 9: true, + 10: true, + 11: true, +}; + +const today = new Date(); + +const X_AXIS_HEIGHT = 24; +const Y_AXIS_WIDTH = 56; +const BAR_GAP = 2; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 10, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +interface Props { + riskData: RiskData | undefined; + selectedRiskMetricDetail: RiskMetricOption; + selectedHazardType: HazardType | undefined; + hazardListForDisplay: { + hazard_type: HazardType; + hazard_type_display: string; + }[]; +} + +function CombinedChart(props: Props) { + const { + riskData, + selectedHazardType, + selectedRiskMetricDetail, + hazardListForDisplay, + } = props; + const strings = useTranslation(i18n); + + const containerRef = useRef(null); + + const riskCategoryToLabelMap: Record = useMemo( + () => ({ + [CATEGORY_RISK_VERY_LOW]: strings.riskBarChartVeryLowLabel, + [CATEGORY_RISK_LOW]: strings.riskBarChartLowLabel, + [CATEGORY_RISK_MEDIUM]: strings.riskBarChartMediumLabel, + [CATEGORY_RISK_HIGH]: strings.riskBarChartHighLabel, + [CATEGORY_RISK_VERY_HIGH]: strings.riskBarChartVeryHighLabel, + }), + [ + strings.riskBarChartVeryLowLabel, + strings.riskBarChartLowLabel, + strings.riskBarChartMediumLabel, + strings.riskBarChartHighLabel, + strings.riskBarChartVeryHighLabel, + ], + ); + + const fiRiskDataItem = useMemo( + () => getFiRiskDataItem(riskData?.ipc_displacement_data), + [riskData], + ); + const wfRiskDataItem = useMemo( + () => getWfRiskDataItem(riskData?.gwis), + [riskData], + ); + + const selectedRiskData = useMemo( + () => { + if (selectedRiskMetricDetail.key === 'displacement') { + return listToMap( + riskData?.idmc + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) ?? [], + (data) => data.hazard_type, + ); + } + + if (selectedRiskMetricDetail.key === 'riskScore') { + return { + ...listToMap( + riskData?.inform_seasonal + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) + ?.map((riskItem) => ({ + ...riskItem, + ...mapToMap( + monthNumberToNameMap, + (_, monthName) => monthName, + (monthName) => riskScoreToCategory( + riskItem?.[monthName], + riskItem?.hazard_type, + ), + ), + })) ?? [], + (data) => data.hazard_type, + ), + WF: { + ...wfRiskDataItem, + ...mapToMap( + monthNumberToNameMap, + (_, monthName) => monthName, + (monthName) => riskScoreToCategory( + wfRiskDataItem?.[monthName], + 'WF', + ), + ), + }, + }; + } + + const rasterDisplacementData = listToMap( + riskData?.raster_displacement_data + ?.map(getDataWithTruthyHazardType) + ?.filter(isDefined) ?? [], + (datum) => datum.hazard_type, + ); + + if (isNotDefined(fiRiskDataItem)) { + return rasterDisplacementData; + } + + return { + ...rasterDisplacementData, + FI: fiRiskDataItem, + }; + }, + [riskData, selectedRiskMetricDetail, fiRiskDataItem, wfRiskDataItem], + ); + + const filteredRiskData = useMemo( + () => { + if (isNotDefined(selectedHazardType)) { + return selectedRiskData; + } + + const riskDataItem = selectedRiskData[selectedHazardType]; + + return { + [selectedHazardType]: riskDataItem, + }; + }, + [selectedRiskData, selectedHazardType], + ); + + const hazardData = useMemo( + () => { + const monthKeys = Object.keys(selectedMonths) as unknown as ( + keyof typeof selectedMonths + )[]; + + const hazardKeysFromSelectedRisk = Object.keys(filteredRiskData ?? {}) as HazardType[]; + const currentYear = new Date().getFullYear(); + + return ( + monthKeys.map( + (monthKey) => { + const month = monthNumberToNameMap[monthKey]; + const value = listToMap( + hazardKeysFromSelectedRisk, + (hazardKey) => hazardKey, + (hazardKey) => (filteredRiskData?.[hazardKey]?.[month] ?? 0), + ); + + return { + value, + month, + date: new Date(currentYear, monthKey, 1), + }; + }, + ) + ); + }, + [filteredRiskData], + ); + + const yAxisLabelSelector = useCallback( + (value: number) => { + if (selectedRiskMetricDetail.key === 'riskScore') { + return riskCategoryToLabelMap[value]; + } + + return ( + + ); + }, + [riskCategoryToLabelMap, selectedRiskMetricDetail.key], + ); + + const chartOptions = useMemo>( + () => ({ + containerRef, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + chartOffset, + type: 'numeric', + keySelector: (datum) => datum.month, + xValueSelector: (datum) => ( + datum.date.getMonth() + ), + yValueSelector: (datum) => Math.max(...Object.values(datum.value)), + xAxisLabelSelector: (month) => new Date(today.getFullYear(), month, 1).toLocaleString( + navigator.language, + { month: 'short' }, + ), + yAxisLabelSelector, + xDomain: { + min: 0, + max: 11, + }, + yAxisScale: selectedRiskMetricDetail.key === 'riskScore' ? 'linear' : 'cbrt', + yAxisStartsFromZero: true, + }), + [yAxisLabelSelector, selectedRiskMetricDetail.key], + ); + + const { + dataPoints, + chartSize, + xAxisTicks, + yAxisTicks, + yScaleFn, + } = useChartData( + hazardData, + chartOptions, + ); + + function getChartHeight(y: number) { + return isDefined(y) + ? Math.max( + chartSize.height + - y + - defaultChartPadding.bottom + - defaultChartMargin.bottom + - chartOffset.bottom, + 0, + ) : 0; + } + + const xAxisDiff = xAxisTicks.length > 1 + ? xAxisTicks[1].x - xAxisTicks[0].x + : 0; + const barGap = Math.min(BAR_GAP, xAxisDiff / 30); + + return ( +
+ + + {dataPoints.map( + (datum) => ( + + {hazardListForDisplay.map( + ({ hazard_type: hazard, hazard_type_display }, hazardIndex) => { + const value = datum.originalData.value[hazard]; + const y = yScaleFn(value); + const height = getChartHeight(y); + + const offsetX = barGap; + const numItems = hazardListForDisplay.length; + + const width = Math.max( + // eslint-disable-next-line max-len + (xAxisDiff / numItems) - offsetX * 2 - barGap * (numItems - 1), + 0, + ); + // eslint-disable-next-line max-len + const x = (datum.x - xAxisDiff / 2) + offsetX + (width + barGap) * hazardIndex; + + return ( + + + + {selectedRiskMetricDetail.key === 'riskScore' ? ( + + ) : ( + + )} + + )} + /> + + ); + }, + )} + + ), + )} + +
+ ); +} + +export default CombinedChart; diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/styles.module.css b/app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/styles.module.css rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/CombinedChart/styles.module.css diff --git a/app/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/index.tsx b/app/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/index.tsx new file mode 100644 index 000000000..291332798 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/index.tsx @@ -0,0 +1,354 @@ +import { + useMemo, + useRef, +} from 'react'; +import { + ChartAxes, + ChartPoint, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { + avgSafe, + getDiscretePathDataList, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import useChartData from '#hooks/useChartData'; +import { + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; +import { getPrioritizedIpcData } from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; + +const colors = [ + 'var(--go-ui-color-gray-30)', + 'var(--go-ui-color-gray-40)', + 'var(--go-ui-color-gray-50)', + 'var(--go-ui-color-gray-60)', + 'var(--go-ui-color-gray-70)', + 'var(--go-ui-color-gray-80)', + 'var(--go-ui-color-gray-90)', +]; + +const X_AXIS_HEIGHT = 24; +const Y_AXIS_WIDTH = 48; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 10, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +const currentYear = new Date().getFullYear(); + +type FiChartPointProps = { + dataPoint: { + originalData: { + year?: number; + month: number; + analysis_date?: string; + total_displacement: number; + }, + key: number | string; + x: number; + y: number; + }; +}; +function FiChartPoint(props: FiChartPointProps) { + const { + dataPoint: { + x, + y, + originalData, + }, + } = props; + + const title = useMemo( + () => { + const { + year, + month, + } = originalData; + + if (isDefined(year)) { + return new Date(year, month - 1, 1).toLocaleString( + navigator.language, + { + year: 'numeric', + month: 'long', + }, + ); + } + + const formattedMonth = new Date(currentYear, month - 1, 1).toLocaleString( + navigator.language, + { month: 'long' }, + ); + + // FIXME: use translations + return `Average for ${formattedMonth}`; + }, + [originalData], + ); + + return ( + + + {isDefined(originalData.analysis_date) && ( + + )} + + + )} + /> + + ); +} + +interface Props { + ipcData: RiskData['ipc_displacement_data'] | undefined; + showHistoricalData?: boolean; + showProjection?: boolean; +} + +function FoodInsecurityChart(props: Props) { + const { + ipcData, + showHistoricalData, + showProjection, + } = props; + + const chartContainerRef = useRef(null); + const uniqueData = useMemo( + () => getPrioritizedIpcData(ipcData ?? []), + [ipcData], + ); + + const { + dataPoints, + chartSize, + xAxisTicks, + yAxisTicks, + yScaleFn, + } = useChartData( + uniqueData, + { + containerRef: chartContainerRef, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + chartOffset, + type: 'numeric', + keySelector: (datum) => datum.month, + xValueSelector: (datum) => ( + datum.month - 1 + ), + yValueSelector: (datum) => datum.total_displacement, + xAxisLabelSelector: (month) => new Date(currentYear, month, 1).toLocaleString( + navigator.language, + { month: 'short' }, + ), + xDomain: { + min: 0, + max: 11, + }, + yAxisStartsFromZero: true, + }, + ); + + const latestProjectionYear = useMemo( + () => { + const projectionData = uniqueData.filter( + (fiData) => fiData.estimation_type !== 'current', + ).map( + (fiData) => fiData.year, + ); + + return Math.max(...projectionData); + }, + [uniqueData], + ); + + const historicalPointsDataList = useMemo( + () => { + const yearGroupedDataPoints = listToGroupList( + dataPoints.filter( + (pathPoints) => pathPoints.originalData.year !== latestProjectionYear, + ), + (dataPoint) => dataPoint.originalData.year, + ); + + return mapToList( + yearGroupedDataPoints, + (list, key) => ({ + key, + list, + }), + ); + }, + [latestProjectionYear, dataPoints], + ); + + const averagePointsData = useMemo( + () => { + const monthGroupedDataPoints = listToGroupList( + dataPoints, + (dataPoint) => dataPoint.originalData.month, + ); + + return mapToList( + monthGroupedDataPoints, + (list, month) => { + const averageDisplacement = avgSafe( + list.map( + (fiData) => fiData.originalData.total_displacement, + ), + ); + + if (isNotDefined(averageDisplacement)) { + return undefined; + } + + return { + key: month, + x: list[0].x, + y: yScaleFn(averageDisplacement), + originalData: { + total_displacement: averageDisplacement, + month: Number(month), + }, + }; + }, + ).filter(isDefined); + }, + [dataPoints, yScaleFn], + ); + + const predictionPointsData = useMemo( + () => ( + dataPoints.filter( + (pathPoints) => pathPoints.originalData.year === latestProjectionYear, + ) + ), + [dataPoints, latestProjectionYear], + ); + + return ( +
+ + + {showHistoricalData && historicalPointsDataList.map( + (historicalPointsData, i) => ( + + {getDiscretePathDataList(historicalPointsData.list).map( + (discretePath) => ( + + ), + )} + {historicalPointsData.list.map( + (pointData) => ( + + ), + )} + + ), + )} + {showProjection && ( + + {getDiscretePathDataList(predictionPointsData).map( + (discretePath) => ( + + ), + )} + {predictionPointsData.map( + (pointData) => ( + + ), + )} + + )} + + {getDiscretePathDataList(averagePointsData).map( + (discretePath) => ( + + ), + )} + {averagePointsData.map( + (pointData) => ( + + ), + )} + + +
+ ); +} + +export default FoodInsecurityChart; diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/styles.module.css b/app/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/styles.module.css rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/FoodInsecurityChart/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/i18n.json b/app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/i18n.json rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/index.tsx b/app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/index.tsx new file mode 100644 index 000000000..e4f53cdd6 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/index.tsx @@ -0,0 +1,288 @@ +import { + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { + ChartAxes, + ChartPoint, + TextOutput, + Tooltip, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + avgSafe, + formatNumber, + getDiscretePathDataList, + getPathData, + resolveToString, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import { paths } from '#generated/riskTypes'; +import useChartData from '#hooks/useChartData'; +import { + COLOR_PRIMARY_BLUE, + defaultChartMargin, + defaultChartPadding, +} from '#utils/constants'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetCountryRisk = paths['/api/v1/country-seasonal/']['get']; +type CountryRiskResponse = GetCountryRisk['responses']['200']['content']['application/json']; +type RiskData = CountryRiskResponse[number]; + +const X_AXIS_HEIGHT = 32; +const Y_AXIS_WIDTH = 48; + +const chartOffset = { + left: Y_AXIS_WIDTH, + top: 0, + right: 0, + bottom: X_AXIS_HEIGHT, +}; + +interface ChartPoint { + x: number; + y: number; + label: string | undefined; +} + +interface Props { + gwisData: RiskData['gwis'] | undefined; +} + +const currentYear = new Date().getFullYear(); + +function WildfireChart(props: Props) { + const { gwisData } = props; + + const strings = useTranslation(i18n); + const chartContainerRef = useRef(null); + + const aggregatedList = useMemo( + () => { + const monthGroupedData = listToGroupList( + gwisData?.filter((dataItem) => dataItem.dsr_type === 'monthly') ?? [], + (gwisItem) => gwisItem.month, + ); + + return mapToList( + monthGroupedData, + (monthlyData, monthKey) => { + const average = avgSafe(monthlyData.map((dataItem) => dataItem.dsr)) ?? 0; + const min = avgSafe(monthlyData.map((dataItem) => dataItem.dsr_min)) ?? 0; + const max = avgSafe(monthlyData.map((dataItem) => dataItem.dsr_max)) ?? 0; + + const current = monthlyData.find( + (dataItem) => dataItem.year === currentYear, + )?.dsr; + + const month = Number(monthKey) - 1; + + return { + date: new Date(currentYear, month, 1), + month, + min, + max, + average, + current, + maxValue: Math.max(min, max, average, current ?? 0), + }; + }, + ); + }, + [gwisData], + ); + + const { + dataPoints, + xAxisTicks, + yAxisTicks, + chartSize, + yScaleFn, + } = useChartData( + aggregatedList, + { + containerRef: chartContainerRef, + chartOffset, + chartMargin: defaultChartMargin, + chartPadding: defaultChartPadding, + type: 'numeric', + keySelector: (datum) => datum.month, + xValueSelector: (datum) => datum.month, + yValueSelector: (datum) => datum.maxValue, + xAxisLabelSelector: (month) => new Date(currentYear, month, 1).toLocaleString( + navigator.language, + { month: 'short' }, + ), + xDomain: { + min: 0, + max: 11, + }, + yAxisStartsFromZero: true, + }, + ); + + const minPoints = dataPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: yScaleFn(dataPoint.originalData.min), + }), + ); + + const maxPoints = dataPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: yScaleFn(dataPoint.originalData.max), + }), + ); + + const minMaxPoints = [...minPoints, ...[...maxPoints].reverse()]; + + const currentYearPoints = dataPoints.map( + (dataPoint) => { + if (isNotDefined(dataPoint.originalData.current)) { + return undefined; + } + + return { + ...dataPoint, + y: yScaleFn(dataPoint.originalData.current), + }; + }, + ).filter(isDefined); + + const averagePoints = dataPoints.map( + (dataPoint) => ({ + ...dataPoint, + y: yScaleFn(dataPoint.originalData.average), + }), + ); + + const tooltipSelector = useCallback( + (_: number | string, i: number) => { + const date = new Date(currentYear, i, 1); + const monthData = aggregatedList[i]; + + return ( + + + + + + )} + /> + ); + }, + [strings, aggregatedList], + ); + + const [hoveredAxisIndex, setHoveredAxisIndex] = useState(); + + const handleHover = useCallback( + (_: number | string | undefined, i: number | undefined) => { + setHoveredAxisIndex(i); + }, + [], + ); + + return ( +
+ + + + {getDiscretePathDataList(currentYearPoints).map( + (points) => ( + + ), + )} + {currentYearPoints.map( + (pointData, i) => ( + + ), + )} + + + + {averagePoints.map( + (pointData, i) => ( + + ), + )} + + + +
+ ); +} + +export default WildfireChart; diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/styles.module.css b/app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/styles.module.css rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/WildfireChart/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/i18n.json b/app/src/views/CountryProfileRiskWatch/RiskBarChart/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/i18n.json rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/RiskBarChart/index.tsx b/app/src/views/CountryProfileRiskWatch/RiskBarChart/index.tsx new file mode 100644 index 000000000..8efd3f6a0 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/RiskBarChart/index.tsx @@ -0,0 +1,385 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + BlockLoading, + Checkbox, + Container, + LegendItem, + SelectInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + resolveToString, + stringLabelSelector, +} from '@ifrc-go/ui/utils'; +import { + isDefined, + isFalsyString, + isNotDefined, + listToGroupList, + listToMap, + unique, +} from '@togglecorp/fujs'; + +import useInputState from '#hooks/useInputState'; +import { + COLOR_LIGHT_GREY, + COLOR_PRIMARY_BLUE, + COLOR_PRIMARY_RED, +} from '#utils/constants'; +import type { + HazardType, + RiskMetric, + RiskMetricOption, +} from '#utils/domain/risk'; +import { + applicableHazardsByRiskMetric, + getDataWithTruthyHazardType, + getFiRiskDataItem, + hasSomeDefinedValue, + hazardTypeKeySelector, + hazardTypeLabelSelector, + hazardTypeToColorMap, + riskMetricKeySelector, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import CombinedChart from './CombinedChart'; +import FoodInsecurityChart from './FoodInsecurityChart'; +import WildfireChart from './WildfireChart'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; + +const currentYear = new Date().getFullYear(); + +interface Props { + pending: boolean; + seasonalRiskData: RiskData | undefined; +} + +function RiskBarChart(props: Props) { + const { + seasonalRiskData, + pending, + } = props; + const strings = useTranslation(i18n); + + const [selectedRiskMetric, setSelectedRiskMetric] = useInputState('exposure'); + const [ + selectedHazardType, + setSelectedHazardType, + ] = useInputState(undefined); + const [showFiHistoricalData, setShowFiHistoricalData] = useInputState(false); + const [showFiProjection, setShowFiProjection] = useInputState(false); + + const handleRiskMetricChange = useCallback( + (riskMetric: RiskMetric) => { + setSelectedRiskMetric(riskMetric); + setSelectedHazardType(undefined); + }, + [setSelectedHazardType, setSelectedRiskMetric], + ); + + const riskMetricOptions: RiskMetricOption[] = useMemo( + () => ([ + { + key: 'exposure', + label: strings.riskBarChartExposureLabel, + applicableHazards: applicableHazardsByRiskMetric.exposure, + }, + { + key: 'displacement', + label: strings.riskBarChartDisplacementLabel, + applicableHazards: applicableHazardsByRiskMetric.displacement, + }, + { + key: 'riskScore', + label: strings.riskBarChartRiskScoreLabel, + applicableHazards: applicableHazardsByRiskMetric.riskScore, + }, + ]), + [ + strings.riskBarChartExposureLabel, + strings.riskBarChartDisplacementLabel, + strings.riskBarChartRiskScoreLabel, + ], + ); + + const selectedRiskMetricDetail = useMemo( + () => riskMetricOptions.find( + (option) => option.key === selectedRiskMetric, + ) ?? riskMetricOptions[0], + [selectedRiskMetric, riskMetricOptions], + ); + + const data = useMemo( + () => { + if (isNotDefined(seasonalRiskData)) { + return undefined; + } + + const { + idmc, + ipc_displacement_data, + raster_displacement_data, + gwis_seasonal, + inform_seasonal, + } = seasonalRiskData; + + const displacement = idmc?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ).filter(isDefined) ?? []; + + const groupedIpc = Object.values( + listToGroupList( + ipc_displacement_data ?? [], + (ipcDataItem) => ipcDataItem.country, + ), + ); + + const exposure = [ + ...raster_displacement_data?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...groupedIpc.map(getFiRiskDataItem), + ].filter(isDefined); + + const riskScore = unique( + [ + ...inform_seasonal?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ...gwis_seasonal?.map( + (dataItem) => { + if (!hasSomeDefinedValue(dataItem)) { + return undefined; + } + + return getDataWithTruthyHazardType(dataItem); + }, + ) ?? [], + ].filter(isDefined), + (item) => `${item.country_details.iso3}-${item.hazard_type}`, + ); + + return { + displacement, + exposure, + riskScore, + }; + }, + [seasonalRiskData], + ); + + const availableHazards: { [key in HazardType]?: string } | undefined = useMemo( + () => { + if (isNotDefined(data)) { + return undefined; + } + + if (selectedRiskMetric === 'exposure') { + return { + ...listToMap( + data.exposure, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + if (selectedRiskMetric === 'displacement') { + return { + ...listToMap( + data.displacement, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + if (selectedRiskMetric === 'riskScore') { + return { + ...listToMap( + data.riskScore, + (item) => item.hazard_type, + (item) => item.hazard_type_display, + ), + }; + } + + return undefined; + }, + [data, selectedRiskMetric], + ); + + const hazardTypeOptions = useMemo( + () => ( + selectedRiskMetricDetail.applicableHazards.map( + (hazardType) => { + const hazard_type_display = availableHazards?.[hazardType]; + if (isFalsyString(hazard_type_display)) { + return undefined; + } + + return { + hazard_type: hazardType, + hazard_type_display, + }; + }, + ).filter(isDefined) + ), + [availableHazards, selectedRiskMetricDetail], + ); + + const hazardListForDisplay = useMemo( + () => { + if (isNotDefined(selectedHazardType)) { + return hazardTypeOptions; + } + + return hazardTypeOptions.filter( + (hazardType) => hazardType.hazard_type === selectedHazardType, + ); + }, + [selectedHazardType, hazardTypeOptions], + ); + + return ( + + + + {selectedHazardType === 'FI' && ( +
+ + +
+ )} + + )} + > + {pending && } + {!pending && selectedHazardType === 'FI' && ( + + )} + {!pending && selectedHazardType === 'WF' && ( + + )} + {!pending && selectedHazardType !== 'FI' && selectedHazardType !== 'WF' && ( + + )} + {!pending && ( +
+ {hazardListForDisplay.map( + (hazard) => ( + + ), + )} + {selectedHazardType === 'WF' && ( + <> + + + + + )} +
+ )} +
+ ); +} + +export default RiskBarChart; diff --git a/src/views/CountryProfileRiskWatch/RiskBarChart/styles.module.css b/app/src/views/CountryProfileRiskWatch/RiskBarChart/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskBarChart/styles.module.css rename to app/src/views/CountryProfileRiskWatch/RiskBarChart/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/RiskTable/i18n.json b/app/src/views/CountryProfileRiskWatch/RiskTable/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskTable/i18n.json rename to app/src/views/CountryProfileRiskWatch/RiskTable/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/RiskTable/index.tsx b/app/src/views/CountryProfileRiskWatch/RiskTable/index.tsx new file mode 100644 index 000000000..207383697 --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/RiskTable/index.tsx @@ -0,0 +1,286 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { Table } from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createNumberColumn, + createStringColumn, + minSafe, + resolveToComponent, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + listToMap, + unique, +} from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { + CATEGORY_RISK_HIGH, + CATEGORY_RISK_LOW, + CATEGORY_RISK_MEDIUM, + CATEGORY_RISK_VERY_HIGH, + CATEGORY_RISK_VERY_LOW, +} from '#utils/constants'; +import { + getDataWithTruthyHazardType, + getFiRiskDataItem, + getValueForSelectedMonths, + getWfRiskDataItem, + hasSomeDefinedValue, + riskScoreToCategory, +} from '#utils/domain/risk'; +import { type RiskApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type CountryRiskResponse = RiskApiResponse<'/api/v1/country-seasonal/'>; +type RiskData = CountryRiskResponse[number]; + +interface Props { + className?: string; + riskData: RiskData | undefined; + selectedMonths: Record | undefined; + dataPending: boolean; +} + +function RiskTable(props: Props) { + const { + riskData, + className, + selectedMonths, + dataPending, + } = props; + + const strings = useTranslation(i18n); + + const fiData = useMemo( + () => getFiRiskDataItem(riskData?.ipc_displacement_data), + [riskData], + ); + + const wfData = useMemo( + () => getWfRiskDataItem(riskData?.gwis), + [riskData], + ); + + const hazardTypeList = useMemo( + () => ( + unique( + [ + ...riskData?.idmc?.filter(hasSomeDefinedValue) ?? [], + ...riskData?.raster_displacement_data?.filter(hasSomeDefinedValue) ?? [], + ...riskData?.inform_seasonal?.filter(hasSomeDefinedValue) ?? [], + fiData, + wfData, + ].filter(isDefined).map(getDataWithTruthyHazardType).filter(isDefined), + (data) => data.hazard_type, + ).map((combinedData) => ({ + hazard_type: combinedData.hazard_type, + hazard_type_display: combinedData.hazard_type_display, + })) + ), + [riskData, fiData, wfData], + ); + + type HazardTypeOption = (typeof hazardTypeList)[number]; + + const hazardKeySelector = useCallback( + (d: HazardTypeOption) => d.hazard_type, + [], + ); + + const riskScoreToLabel = useCallback( + (score: number | undefined | null, hazardType: HazardTypeOption['hazard_type']) => { + if (isNotDefined(score) || score < 0) { + return '-'; + } + + const riskCategory = riskScoreToCategory(score, hazardType); + + if (isNotDefined(riskCategory)) { + return strings.riskScoreNotApplicableLabel; + } + + const riskCategoryToLabelMap = { + [CATEGORY_RISK_VERY_HIGH]: strings.riskScoreVeryHighLabel, + [CATEGORY_RISK_HIGH]: strings.riskScoreHighLabel, + [CATEGORY_RISK_MEDIUM]: strings.riskScoreMediumLabel, + [CATEGORY_RISK_LOW]: strings.riskScoreLowLabel, + [CATEGORY_RISK_VERY_LOW]: strings.riskScoreVeryLowLabel, + }; + + return riskCategoryToLabelMap[riskCategory]; + }, + [ + strings.riskScoreNotApplicableLabel, + strings.riskScoreVeryHighLabel, + strings.riskScoreMediumLabel, + strings.riskScoreLowLabel, + strings.riskScoreHighLabel, + strings.riskScoreVeryLowLabel, + ], + ); + + const displacementRiskData = useMemo( + () => listToMap( + riskData?.idmc?.map(getDataWithTruthyHazardType).filter(isDefined) ?? [], + (data) => data.hazard_type, + ), + [riskData], + ); + + const exposureRiskData = useMemo( + () => ({ + ...listToMap( + riskData?.raster_displacement_data?.map( + getDataWithTruthyHazardType, + ).filter(isDefined) ?? [], + (data) => data.hazard_type, + ), + FI: fiData, + }), + [riskData, fiData], + ); + + const informSeasonalRiskData = useMemo( + () => ({ + ...listToMap( + riskData?.inform_seasonal?.map(getDataWithTruthyHazardType).filter(isDefined) ?? [], + (data) => data.hazard_type, + ), + WF: wfData, + }), + [wfData, riskData], + ); + + const riskTableColumns = useMemo( + () => ([ + createStringColumn( + 'hazard_type', + strings.riskTableHazardTypeTitle, + (item) => item.hazard_type_display, + ), + createStringColumn( + 'riskScore', + strings.riskTableInformTitle, + (option) => riskScoreToLabel( + getValueForSelectedMonths( + selectedMonths, + informSeasonalRiskData[option.hazard_type], + 'max', + ), + option.hazard_type, + ), + { + headerInfoTitle: strings.riskTableInformTitle, + // FIXME: add description for wildfire + headerInfoDescription: ( +
+
+ {strings.riskTableInformDescriptionP1} +
+
+ {strings.riskTableInformDescriptionP2} +
+
+ {resolveToComponent( + strings.riskTableInformDescriptionP3, + { + moreInfoLink: ( + + {strings.riskTableInformDescriptionHereLabel} + + ), + }, + )} +
+
+ ), + }, + ), + createNumberColumn( + 'exposure', + strings.riskTablePeopleExposedTitle, + (option) => getValueForSelectedMonths( + selectedMonths, + exposureRiskData[option.hazard_type], + ), + { + headerInfoTitle: strings.riskTablePeopleExposedTitle, + headerInfoDescription: strings.riskTablePeopleExposedDescription, + maximumFractionDigits: 0, + }, + ), + createNumberColumn( + 'displacement', + strings.riskTableDisplacementTitle, + (option) => { + // NOTE: Naturally displacement should always be greater than + // or equal to the exposure. To follow that logic we reduce + // displacement value to show the exposure in case displacement + // is greater than exposure + + const exposure = getValueForSelectedMonths( + selectedMonths, + exposureRiskData[option.hazard_type], + ); + + const displacement = getValueForSelectedMonths( + selectedMonths, + displacementRiskData[option.hazard_type], + ); + + if (isNotDefined(displacement)) { + return undefined; + } + + return minSafe([exposure, displacement]); + }, + { + headerInfoTitle: strings.riskTableDisplacementTitle, + headerInfoDescription: strings.riskTableDisplacementDescription, + maximumFractionDigits: 0, + }, + ), + ]), + [ + displacementRiskData, + exposureRiskData, + informSeasonalRiskData, + strings.riskTableHazardTypeTitle, + strings.riskTableInformTitle, + strings.riskTableInformDescriptionP1, + strings.riskTableInformDescriptionP2, + strings.riskTableInformDescriptionP3, + strings.riskTableInformDescriptionHereLabel, + strings.riskTablePeopleExposedTitle, + strings.riskTablePeopleExposedDescription, + strings.riskTableDisplacementTitle, + strings.riskTableDisplacementDescription, + riskScoreToLabel, + selectedMonths, + ], + ); + + return ( +
+ ); +} + +export default RiskTable; diff --git a/src/views/CountryProfileRiskWatch/RiskTable/styles.module.css b/app/src/views/CountryProfileRiskWatch/RiskTable/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/RiskTable/styles.module.css rename to app/src/views/CountryProfileRiskWatch/RiskTable/styles.module.css diff --git a/src/views/CountryProfileRiskWatch/i18n.json b/app/src/views/CountryProfileRiskWatch/i18n.json similarity index 100% rename from src/views/CountryProfileRiskWatch/i18n.json rename to app/src/views/CountryProfileRiskWatch/i18n.json diff --git a/app/src/views/CountryProfileRiskWatch/index.tsx b/app/src/views/CountryProfileRiskWatch/index.tsx new file mode 100644 index 000000000..0a293d90b --- /dev/null +++ b/app/src/views/CountryProfileRiskWatch/index.tsx @@ -0,0 +1,196 @@ +import { useMemo } from 'react'; +import { + useOutletContext, + useParams, +} from 'react-router-dom'; +import { + BlockLoading, + Container, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + isDefined, + isNotDefined, + mapToList, +} from '@togglecorp/fujs'; +import getBbox from '@turf/bbox'; + +import RiskImminentEvents, { type ImminentEventSource } from '#components/domain/RiskImminentEvents'; +import Link from '#components/Link'; +import useInputState from '#hooks/useInputState'; +import type { CountryOutletContext } from '#utils/outletContext'; +import { useRiskRequest } from '#utils/restRequest'; + +import MultiMonthSelectInput from './MultiMonthSelectInput'; +import PossibleEarlyActionTable from './PossibleEarlyActionTable'; +import ReturnPeriodTable from './ReturnPeriodTable'; +import RiskBarChart from './RiskBarChart'; +import RiskTable from './RiskTable'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryResponse } = useOutletContext(); + const { countryId } = useParams<{ countryId: string }>(); + const strings = useTranslation(i18n); + const [ + selectedMonths, + setSelectedMonths, + ] = useInputState | undefined>({ 0: true }); + + const { + pending: pendingCountryRiskResponse, + response: countryRiskResponse, + } = useRiskRequest({ + apiType: 'risk', + url: '/api/v1/country-seasonal/', + query: { + // FIXME: why do we need to use lowercase? + iso3: countryResponse?.iso3?.toLowerCase(), + }, + }); + + const { + pending: pendingImminentEventCounts, + response: imminentEventCountsResponse, + } = useRiskRequest({ + apiType: 'risk', + url: '/api/v1/country-imminent-counts/', + query: { + iso3: countryResponse?.iso3?.toLowerCase(), + }, + }); + + const hasImminentEvents = useMemo( + () => { + if (isNotDefined(imminentEventCountsResponse)) { + return false; + } + + const eventCounts = mapToList( + imminentEventCountsResponse, + (value) => value, + ).filter(isDefined).filter( + (value) => value > 0, + ); + + return eventCounts.length > 0; + }, + [imminentEventCountsResponse], + ); + + const defaultImminentEventSource = useMemo( + () => { + if (isNotDefined(imminentEventCountsResponse)) { + return undefined; + } + + const { + pdc, + adam, + gdacs, + meteoswiss, + } = imminentEventCountsResponse; + + if (isDefined(pdc) && pdc > 0) { + return 'pdc'; + } + + if (isDefined(adam) && adam > 0) { + return 'wfpAdam'; + } + + if (isDefined(gdacs) && gdacs > 0) { + return 'gdacs'; + } + + if (isDefined(meteoswiss) && meteoswiss > 0) { + return 'meteoSwiss'; + } + + return undefined; + }, + [imminentEventCountsResponse], + ); + + // NOTE: we always get 1 child in the response + const riskResponse = countryRiskResponse?.[0]; + const bbox = useMemo( + () => (countryResponse ? getBbox(countryResponse.bbox) : undefined), + [countryResponse], + ); + + return ( + + {pendingImminentEventCounts && ( + + )} + {hasImminentEvents && isDefined(countryResponse) && isDefined(countryResponse.iso3) && ( + + )} + + + + +
+ + {strings.eapDownloadButtonLabel} + + )} + > + {strings.eapDescription} + +
+ + + +
+ ); +} + +Component.displayName = 'CountryProfileRiskWatch'; diff --git a/src/views/CountryProfileRiskWatch/styles.module.css b/app/src/views/CountryProfileRiskWatch/styles.module.css similarity index 100% rename from src/views/CountryProfileRiskWatch/styles.module.css rename to app/src/views/CountryProfileRiskWatch/styles.module.css diff --git a/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/i18n.json b/app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/i18n.json similarity index 100% rename from src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/i18n.json rename to app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/i18n.json diff --git a/app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/index.tsx b/app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/index.tsx new file mode 100644 index 000000000..ba52ca19f --- /dev/null +++ b/app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/index.tsx @@ -0,0 +1,145 @@ +import { useMemo } from 'react'; +import { CheckboxCircleLineIcon } from '@ifrc-go/icons'; +import { + Container, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createElementColumn, + createStringColumn, + numericKeySelector, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + listToGroupList, + listToMap, + mapToList, + unique, +} from '@togglecorp/fujs'; + +import { type GoApiResponse } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetCountryPlanResponse = GoApiResponse<'/api/v2/country-plan/{country}/'>; + +interface IconProps { + shouldShow: boolean; +} + +function Icon(props: IconProps) { + const { shouldShow } = props; + + if (!shouldShow) { + return null; + } + + return ; +} + +interface Props { + className?: string; + pending?: boolean; + membershipData: GetCountryPlanResponse['membership_coordinations'] | undefined; +} + +function MembershipCoordinationTable(props: Props) { + const { + className, + membershipData, + pending = false, + } = props; + + const strings = useTranslation(i18n); + + const tableData = useMemo( + () => { + if (isNotDefined(membershipData)) { + return undefined; + } + + const membershipWithUniqueSectors = unique( + membershipData, + (membership) => membership.sector, + ); + + const nationalSocieties = mapToList( + listToGroupList( + // NOTE: entries with null ids are included + // to include all the sectors so we can ignore + // them here + membershipData.filter( + (membership) => isDefined(membership.id), + ), + (membership) => membership.national_society, + ), + (membershipList, key) => ({ + key: Number(key), + national_society: membershipList[0].national_society, + national_society_name: membershipList[0].national_society_name, + sectors: listToMap( + membershipList, + (membership) => membership.sector, + () => true, + ), + }), + ); + + type NationalSocietyElement = (typeof nationalSocieties)[number]; + const columns = [ + createStringColumn( + 'national_society_name', + strings.membershipCoordinationTableNameOfPNS, + (item) => item.national_society_name, + ), + ...membershipWithUniqueSectors.map( + (membership) => ( + createElementColumn( + membership.sector, + membership.sector_display, + Icon, + (_, item) => ({ + shouldShow: item.sectors[membership.sector] ?? false, + }), + { headerCellRendererClassName: styles.sectorHeading }, + ) + ), + ), + ]; + + return { + membershipWithUniqueSectors, + nationalSocieties, + columns, + }; + }, + [strings.membershipCoordinationTableNameOfPNS, membershipData], + ); + + if (!tableData) { + return null; + } + + return ( + +
+ + ); +} + +export default MembershipCoordinationTable; diff --git a/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/styles.module.css b/app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/styles.module.css similarity index 100% rename from src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/styles.module.css rename to app/src/views/CountryProfileSupportingPartners/MembershipCoordinationTable/styles.module.css diff --git a/src/views/CountryProfileSupportingPartners/Presence/i18n.json b/app/src/views/CountryProfileSupportingPartners/Presence/i18n.json similarity index 100% rename from src/views/CountryProfileSupportingPartners/Presence/i18n.json rename to app/src/views/CountryProfileSupportingPartners/Presence/i18n.json diff --git a/app/src/views/CountryProfileSupportingPartners/Presence/index.tsx b/app/src/views/CountryProfileSupportingPartners/Presence/index.tsx new file mode 100644 index 000000000..43d544d42 --- /dev/null +++ b/app/src/views/CountryProfileSupportingPartners/Presence/index.tsx @@ -0,0 +1,96 @@ +import { useOutletContext } from 'react-router-dom'; +import { + Container, + TextOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + getCurrentMonthYear, + resolveToString, +} from '@ifrc-go/ui/utils'; + +import Link from '#components/Link'; +import { type CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const legalStatusLink = 'https://idp.ifrc.org/SSO/SAMLLogin?loginToSp=https://fednet.ifrc.org&returnUrl=https://fednet.ifrc.org/PageFiles/255835/List%20States%20with%20Defined%20Legal%20Status%2025.07.2023_ENG.pdf'; + +function Presence() { + const strings = useTranslation(i18n); + + const { countryResponse } = useOutletContext(); + + return ( +
+ +
+ {/* //TODO: Add IFRC Delegation name */} + + + {strings.countryIFRCLegalStatus} + +
+
+ {/* //TODO: Add IFRC Delegation contact */} + + + {strings.countryIFRCDisasterLaw} + +
+
+ {countryResponse?.icrc_presence && ( + + {resolveToString( + strings.countryICRCConfirmedPartner, + { year: getCurrentMonthYear() }, + )} + {countryResponse?.icrc_presence.key_operation && ( +
+ + {strings.countryICRCKeyOperations} + + {resolveToString( + strings.countryICRCWithin, + { name: countryResponse?.name ?? '--' }, + )} +
+ )} +
+ )} +
+ ); +} + +export default Presence; diff --git a/src/views/CountryProfileSupportingPartners/Presence/styles.module.css b/app/src/views/CountryProfileSupportingPartners/Presence/styles.module.css similarity index 100% rename from src/views/CountryProfileSupportingPartners/Presence/styles.module.css rename to app/src/views/CountryProfileSupportingPartners/Presence/styles.module.css diff --git a/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/i18n.json b/app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/i18n.json similarity index 100% rename from src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/i18n.json rename to app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/i18n.json diff --git a/app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/index.tsx b/app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/index.tsx new file mode 100644 index 000000000..d9d8bf876 --- /dev/null +++ b/app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/index.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + BlockLoading, + Container, + Table, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + createStringColumn, + numericIdSelector, +} from '@ifrc-go/ui/utils'; +import { + _cs, + isDefined, + isNotDefined, + listToGroupList, + mapToList, +} from '@togglecorp/fujs'; + +import { createLinkColumn } from '#utils/domain/tableHelpers'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { + GoApiResponse, + useRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type GetPartnerResponse = GoApiResponse<'/api/v2/country-supporting-partner/'>; +type ContactListItem = NonNullable[number]; + +interface Props { + className?: string; +} + +function SupportingPartnersContacts(props: Props) { + const { className } = props; + const strings = useTranslation(i18n); + + const { countryId } = useOutletContext(); + + const { + pending: supportingPartnerPending, + response: supportingPartnerResponse, + } = useRequest({ + url: '/api/v2/country-supporting-partner/', + skip: isNotDefined(countryId), + query: { + country: isDefined(countryId) ? [Number(countryId)] : undefined, + }, + }); + + const partnersGroupedByType = ( + listToGroupList( + supportingPartnerResponse?.results, + (item) => item.supporting_type_display, + (item) => item, + ) + ); + + const groupedPartnersList = mapToList( + partnersGroupedByType, + (contacts, contactType) => ({ label: contactType, value: contacts }), + ); + + const columns = useMemo( + () => ([ + createStringColumn( + 'name', + '', + (item) => { + if (isDefined(item.first_name) && isDefined(item.last_name)) { + return `${item.first_name} ${item.last_name}`; + } + if (isDefined(item.first_name)) { + return `${item.first_name}`; + } + if (isDefined(item.last_name)) { + return `${item.last_name}`; + } + return null; + }, + { + cellRendererClassName: styles.name, + }, + ), + createStringColumn( + 'position', + '', + (item) => item.position, + ), + createLinkColumn( + 'email', + '', + (item) => item.email, + (item) => ({ + href: isDefined(item.email) ? `mailto:${item.email}` : undefined, + external: true, + }), + ), + ]), + [], + ); + + return ( + + {supportingPartnerPending && } + {groupedPartnersList?.map((groupedPartner) => ( +
+ ))} + + ); +} + +export default SupportingPartnersContacts; diff --git a/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/styles.module.css b/app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/styles.module.css similarity index 100% rename from src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/styles.module.css rename to app/src/views/CountryProfileSupportingPartners/SupportingPartnersContacts/styles.module.css diff --git a/src/views/CountryProfileSupportingPartners/i18n.json b/app/src/views/CountryProfileSupportingPartners/i18n.json similarity index 100% rename from src/views/CountryProfileSupportingPartners/i18n.json rename to app/src/views/CountryProfileSupportingPartners/i18n.json diff --git a/app/src/views/CountryProfileSupportingPartners/index.tsx b/app/src/views/CountryProfileSupportingPartners/index.tsx new file mode 100644 index 000000000..f6f177998 --- /dev/null +++ b/app/src/views/CountryProfileSupportingPartners/index.tsx @@ -0,0 +1,59 @@ +import { useOutletContext } from 'react-router-dom'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isNotDefined } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import { type CountryOutletContext } from '#utils/outletContext'; +import { useRequest } from '#utils/restRequest'; + +import MembershipCoordinationTable from './MembershipCoordinationTable'; +import Presence from './Presence'; +import SupportingPartnersContacts from './SupportingPartnersContacts'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const strings = useTranslation(i18n); + const { + countryId, + countryResponse, + } = useOutletContext(); + + const { + pending: countryPlanPending, + response: countryPlanResponse, + } = useRequest({ + // FIXME: need to check if countryId can be '' + skip: isNotDefined(countryId) || !countryResponse?.has_country_plan, + url: '/api/v2/country-plan/{country}/', + pathVariables: { + country: Number(countryId), + }, + }); + + return ( +
+ {/* TODO: This is just temporary link, and should be removed */} + + {strings.gotoIfrcLinkLabel} + + + {countryResponse?.has_country_plan && ( + + )} + +
+ ); +} + +Component.displayName = 'CountryProfileSupportingPartners'; diff --git a/src/views/CountryProfileSupportingPartners/styles.module.css b/app/src/views/CountryProfileSupportingPartners/styles.module.css similarity index 100% rename from src/views/CountryProfileSupportingPartners/styles.module.css rename to app/src/views/CountryProfileSupportingPartners/styles.module.css diff --git a/src/views/CountryThreeW/i18n.json b/app/src/views/CountryThreeW/i18n.json similarity index 100% rename from src/views/CountryThreeW/i18n.json rename to app/src/views/CountryThreeW/i18n.json diff --git a/app/src/views/CountryThreeW/index.tsx b/app/src/views/CountryThreeW/index.tsx new file mode 100644 index 000000000..cd4575dd4 --- /dev/null +++ b/app/src/views/CountryThreeW/index.tsx @@ -0,0 +1,70 @@ +import { + Outlet, + useOutletContext, + useParams, +} from 'react-router-dom'; +import { + Container, + NavigationTabList, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { isDefined } from '@togglecorp/fujs'; + +import Link from '#components/Link'; +import NavigationTab from '#components/NavigationTab'; +import useUserMe from '#hooks/domain/useUserMe'; +import type { CountryOutletContext } from '#utils/outletContext'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { countryId } = useParams<{ countryId: string }>(); + + const outletContext = useOutletContext(); + const { countryResponse } = outletContext; + + const strings = useTranslation(i18n); + const userMe = useUserMe(); + + return ( + + {/* {strings.wikiJsLink?.length > 0 && ( + + )} */} + + {strings.addThreeWProject} + + + ) + )} + > + + + {resolveToString( + strings.countryThreeWNationalSocietyProjectsTab, + { countryName: countryResponse?.name ?? '-' }, + )} + + + + + ); +} + +Component.displayName = 'CountryThreeW'; diff --git a/src/views/CountryThreeW/styles.module.css b/app/src/views/CountryThreeW/styles.module.css similarity index 100% rename from src/views/CountryThreeW/styles.module.css rename to app/src/views/CountryThreeW/styles.module.css diff --git a/src/views/DrefApplicationExport/i18n.json b/app/src/views/DrefApplicationExport/i18n.json similarity index 100% rename from src/views/DrefApplicationExport/i18n.json rename to app/src/views/DrefApplicationExport/i18n.json diff --git a/app/src/views/DrefApplicationExport/index.tsx b/app/src/views/DrefApplicationExport/index.tsx new file mode 100644 index 000000000..fe3dee03b --- /dev/null +++ b/app/src/views/DrefApplicationExport/index.tsx @@ -0,0 +1,1186 @@ +import { + Fragment, + useMemo, + useState, +} from 'react'; +import { useParams } from 'react-router-dom'; +import { + Container, + DateOutput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { + DescriptionText, + Heading, + Image, + TextOutput, + type TextOutputProps, +} from '@ifrc-go/ui/printable'; +import { + _cs, + isDefined, + isFalsyString, + isNotDefined, + isTruthyString, +} from '@togglecorp/fujs'; + +import ifrcLogo from '#assets/icons/ifrc-square.png'; +import Link from '#components/printable/Link'; +import { + DISASTER_CATEGORY_ORANGE, + DISASTER_CATEGORY_RED, + DISASTER_CATEGORY_YELLOW, + DisasterCategory, + DREF_TYPE_ASSESSMENT, + DREF_TYPE_IMMINENT, + ONSET_SLOW, +} from '#utils/constants'; +import { + identifiedNeedsAndGapsOrder, + nsActionsOrder, + plannedInterventionOrder, +} from '#utils/domain/dref'; +import { useRequest } from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function BlockTextOutput(props: TextOutputProps & { variant?: never, withoutLabelColon?: never }) { + return ( + + ); +} + +const colorMap: Record = { + [DISASTER_CATEGORY_YELLOW]: styles.yellow, + [DISASTER_CATEGORY_ORANGE]: styles.orange, + [DISASTER_CATEGORY_RED]: styles.red, +}; + +// eslint-disable-next-line import/prefer-default-export +export function Component() { + const { drefId } = useParams<{ drefId: string }>(); + const [previewReady, setPreviewReady] = useState(false); + const strings = useTranslation(i18n); + + const { + // pending: fetchingDref, + response: drefResponse, + } = useRequest({ + skip: isFalsyString(drefId), + url: '/api/v2/dref/{id}/', + pathVariables: isDefined(drefId) ? { + id: drefId, + } : undefined, + onSuccess: () => { + // FIXME: create common function / hook for this + async function waitForImages() { + const images = document.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); + }); + }); + }, + ).filter(isDefined); + + await Promise.all(promises); + setPreviewReady(true); + } + + waitForImages(); + }, + onFailure: () => { + setPreviewReady(true); + }, + }); + + const plannedInterventions = useMemo( + () => { + if (isNotDefined(drefResponse) || isNotDefined(drefResponse.planned_interventions)) { + return undefined; + } + + const { planned_interventions } = drefResponse; + + return planned_interventions.map( + (intervention) => { + if (isNotDefined(intervention.title)) { + return undefined; + } + return { ...intervention, title: intervention.title }; + }, + ).filter(isDefined).sort( + (a, b) => plannedInterventionOrder[a.title] - plannedInterventionOrder[b.title], + ); + }, + [drefResponse], + ); + + const needsIdentified = useMemo( + () => { + if (isNotDefined(drefResponse) || isNotDefined(drefResponse.needs_identified)) { + return undefined; + } + + const { needs_identified } = drefResponse; + + return needs_identified.map( + (need) => { + if (isNotDefined(need.title)) { + return undefined; + } + + return { + ...need, + title: need.title, + }; + }, + ).filter(isDefined).sort((a, b) => ( + identifiedNeedsAndGapsOrder[a.title] - identifiedNeedsAndGapsOrder[b.title] + )); + }, + [drefResponse], + ); + + const nsActions = useMemo( + () => { + if (isNotDefined(drefResponse) || isNotDefined(drefResponse.needs_identified)) { + return undefined; + } + + const { national_society_actions } = drefResponse; + + return national_society_actions?.map((nsAction) => { + if (isNotDefined(nsAction.title)) { + return undefined; + } + return { ...nsAction, title: nsAction.title }; + }).filter(isDefined).sort((a, b) => ( + nsActionsOrder[a.title] - nsActionsOrder[b.title] + )); + }, + [drefResponse], + ); + + const eventDescriptionDefined = isTruthyString(drefResponse?.event_description?.trim()); + const eventScopeDefined = drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT + && isTruthyString(drefResponse?.event_scope?.trim()); + const imagesFileDefined = isDefined(drefResponse) + && isDefined(drefResponse.images_file) + && drefResponse.images_file.length > 0; + const anticipatoryActionsDefined = drefResponse?.type_of_dref === DREF_TYPE_IMMINENT + && isTruthyString(drefResponse?.anticipatory_actions?.trim()); + const eventDateDefined = drefResponse?.type_of_dref !== DREF_TYPE_IMMINENT + && isDefined(drefResponse?.event_date); + const eventTextDefined = drefResponse?.type_of_dref === DREF_TYPE_IMMINENT + && isTruthyString(drefResponse?.event_text?.trim()); + const showEventDescriptionSection = eventDescriptionDefined + || eventScopeDefined + || imagesFileDefined + || anticipatoryActionsDefined + || eventTextDefined + || eventDateDefined + || isDefined(drefResponse?.event_map_file?.file); + + const lessonsLearnedDefined = isTruthyString(drefResponse?.lessons_learned?.trim()); + const showPreviousOperations = drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT && ( + isDefined(drefResponse?.did_it_affect_same_area) + || isDefined(drefResponse?.did_it_affect_same_population) + || isDefined(drefResponse?.did_ns_respond) + || isDefined(drefResponse?.did_ns_request_fund) + || isTruthyString(drefResponse?.ns_request_text?.trim()) + || isTruthyString(drefResponse?.dref_recurrent_text?.trim()) + || lessonsLearnedDefined + ); + + const ifrcActionsDefined = isTruthyString(drefResponse?.ifrc?.trim()); + const partnerNsActionsDefined = isTruthyString(drefResponse?.partner_national_society?.trim()); + const showMovementPartnersActionsSection = ifrcActionsDefined || partnerNsActionsDefined; + + const showNsAction = isDefined(drefResponse) + && isDefined(drefResponse.national_society_actions) + && drefResponse.national_society_actions.length > 0 + && isDefined(nsActions); + + const icrcActionsDefined = isTruthyString(drefResponse?.icrc?.trim()); + + const governmentRequestedAssistanceDefined = isDefined( + drefResponse?.government_requested_assistance, + ); + const nationalAuthoritiesDefined = isDefined(drefResponse?.national_authorities?.trim()); + const unOrOtherActorDefined = isDefined(drefResponse?.un_or_other_actor?.trim()); + const majorCoordinationMechanismDefined = isDefined( + drefResponse?.major_coordination_mechanism?.trim(), + ); + const showOtherActorsActionsSection = governmentRequestedAssistanceDefined + || nationalAuthoritiesDefined + || unOrOtherActorDefined + || majorCoordinationMechanismDefined; + + const identifiedGapsDefined = drefResponse?.type_of_dref !== DREF_TYPE_IMMINENT + && isTruthyString(drefResponse?.identified_gaps?.trim()); + const needsIdentifiedDefined = isDefined(drefResponse) + && isDefined(drefResponse.needs_identified) + && drefResponse.needs_identified.length > 0 + && isDefined(needsIdentified); + + const assessmentReportDefined = isDefined(drefResponse) + && isDefined(drefResponse.assessment_report_details) + && isDefined(drefResponse.assessment_report_details.file); + + const showNeedsIdentifiedSection = isDefined(drefResponse) + && drefResponse.type_of_dref !== DREF_TYPE_ASSESSMENT + && (identifiedGapsDefined || needsIdentifiedDefined || assessmentReportDefined); + + const operationObjectiveDefined = isTruthyString(drefResponse?.operation_objective?.trim()); + const responseStrategyDefined = isTruthyString(drefResponse?.response_strategy?.trim()); + const showOperationStrategySection = operationObjectiveDefined || responseStrategyDefined; + + const peopleAssistedDefined = isTruthyString(drefResponse?.people_assisted?.trim()); + const selectionCriteriaDefined = isTruthyString(drefResponse?.selection_criteria?.trim()); + const targetingStrategySupportingDocumentDefined = isDefined( + drefResponse?.targeting_strategy_support_file_details, + ); + const showTargetingStrategySection = peopleAssistedDefined + || selectionCriteriaDefined + || targetingStrategySupportingDocumentDefined; + + const riskSecurityDefined = isDefined(drefResponse) + && isDefined(drefResponse.risk_security) + && drefResponse.risk_security.length > 0; + const riskSecurityConcernDefined = isTruthyString(drefResponse?.risk_security_concern?.trim()); + const hasChildrenSafeguardingDefined = isDefined( + drefResponse?.has_child_safeguarding_risk_analysis_assessment, + ); + const showRiskAndSecuritySection = riskSecurityDefined + || riskSecurityConcernDefined + || hasChildrenSafeguardingDefined; + + const plannedInterventionDefined = isDefined(drefResponse) + && isDefined(drefResponse.planned_interventions) + && drefResponse.planned_interventions.length > 0 + && isDefined(plannedInterventions); + + const humanResourceDefined = isTruthyString(drefResponse?.human_resource?.trim()); + const surgePersonnelDeployedDefined = isTruthyString( + drefResponse?.surge_personnel_deployed?.trim(), + ); + const logisticCapacityOfNsDefined = isTruthyString( + drefResponse?.logistic_capacity_of_ns?.trim(), + ); + const pmerDefined = isTruthyString(drefResponse?.pmer?.trim()); + const communicationDefined = isTruthyString(drefResponse?.communication?.trim()); + const showAboutSupportServicesSection = humanResourceDefined + || surgePersonnelDeployedDefined + || logisticCapacityOfNsDefined + || pmerDefined + || communicationDefined; + + const sourceInformationDefined = isDefined(drefResponse) + && isDefined(drefResponse.source_information) + && drefResponse.source_information.length > 0; + + const showBudgetOverview = isTruthyString(drefResponse?.budget_file_details?.file); + + const nsContactText = [ + drefResponse?.national_society_contact_name, + drefResponse?.national_society_contact_title, + drefResponse?.national_society_contact_email, + drefResponse?.national_society_contact_phone_number, + ].filter(isTruthyString).join(', '); + const nsContactDefined = isTruthyString(nsContactText); + const appealManagerContactText = [ + drefResponse?.ifrc_appeal_manager_name, + drefResponse?.ifrc_appeal_manager_title, + drefResponse?.ifrc_appeal_manager_email, + drefResponse?.ifrc_appeal_manager_phone_number, + ].filter(isTruthyString).join(', '); + const appealManagerContactDefined = isTruthyString(appealManagerContactText); + const projectManagerContactText = [ + drefResponse?.ifrc_project_manager_name, + drefResponse?.ifrc_project_manager_title, + drefResponse?.ifrc_project_manager_email, + drefResponse?.ifrc_project_manager_phone_number, + ].filter(isTruthyString).join(', '); + const projectManagerContactDefined = isTruthyString(projectManagerContactText); + const focalPointContactText = [ + drefResponse?.ifrc_emergency_name, + drefResponse?.ifrc_emergency_title, + drefResponse?.ifrc_emergency_email, + drefResponse?.ifrc_emergency_phone_number, + ].filter(isTruthyString).join(', '); + const focalPointContactDefined = isTruthyString(focalPointContactText); + const mediaContactText = [ + drefResponse?.media_contact_name, + drefResponse?.media_contact_title, + drefResponse?.media_contact_email, + drefResponse?.media_contact_phone_number, + ].filter(isTruthyString).join(', '); + const mediaContactDefined = isTruthyString(mediaContactText); + const showContactsSection = nsContactDefined + || appealManagerContactDefined + || projectManagerContactDefined + || focalPointContactDefined + || mediaContactDefined; + return ( +
+ + {strings.imageLogoIFRCAlt} +
+ + {strings.exportTitle} + +
+ {drefResponse?.title} +
+
+
+ {isDefined(drefResponse) + && isDefined(drefResponse.cover_image_file) + && isDefined(drefResponse.cover_image_file.file) + && ( + + + + )} + + + + + + + + + + + + + + + + district.name, + ).join(', ')} + strongValue + /> + + {showEventDescriptionSection && ( + <> +
+ + {strings.eventDescriptionSectionHeading} + + {drefResponse?.disaster_category_analysis_details?.file && ( + + + {strings.crisisCategorySupportingDocumentLabel} + + + )} + {eventTextDefined && ( + + + {drefResponse?.event_text} + + + )} + {eventDateDefined && ( + + + + )} + {isTruthyString(drefResponse?.event_map_file?.file) && ( + + + + )} + {eventDescriptionDefined && ( + + + {drefResponse?.event_description} + + + )} + {imagesFileDefined && ( + + {drefResponse.images_file?.map( + (imageFile) => ( + + ), + )} + + )} + {anticipatoryActionsDefined && ( + + + {drefResponse?.anticipatory_actions} + + + )} + {eventScopeDefined && ( + + + {drefResponse?.event_scope} + + + )} + {drefResponse?.supporting_document_details?.file && ( + + + {strings.drefApplicationSupportingDocumentation} + + + )} + {sourceInformationDefined && ( + +
+ {strings.sourceInformationSourceNameTitle} +
+
+ {strings.sourceInformationSourceLinkTitle} +
+ {drefResponse?.source_information?.map( + (source, index) => ( + + +
+ {`${index + 1}. ${source.source_name}`} +
+
+ + + {source?.source_link} + + +
+ ), + )} + +
+ )} + + )} + {showPreviousOperations && ( + + + + + + + + {lessonsLearnedDefined && ( + + )} + + )} + {showNsAction && ( + <> + + {strings.currentNationalSocietyActionsHeading} + + {drefResponse?.ns_respond_date && ( + + + + )} + + {nsActions?.map( + (nsAction) => ( + + ), + )} + + + )} + {showMovementPartnersActionsSection && ( + + {ifrcActionsDefined && ( + + )} + {partnerNsActionsDefined && ( + + )} + + )} + {icrcActionsDefined && ( + + + {drefResponse?.icrc} + + + )} + {showOtherActorsActionsSection && ( + + {governmentRequestedAssistanceDefined && ( + + )} + {nationalAuthoritiesDefined && ( + + )} + {unOrOtherActorDefined && ( + + )} + {majorCoordinationMechanismDefined && ( + + )} + + )} + {showNeedsIdentifiedSection && ( + <> + + {strings.needsIdentifiedSectionHeading} + + {needsIdentifiedDefined && needsIdentified?.map( + (identifiedNeed) => ( + + + + {identifiedNeed.title_display} + + + {identifiedNeed.description} + + + ), + )} + {identifiedGapsDefined && ( + + + {drefResponse?.identified_gaps} + + + )} + {assessmentReportDefined && ( + + + {strings.drefAssessmentReportLink} + + + )} + + )} + {showOperationStrategySection && ( + <> + + {strings.operationalStrategySectionHeading} + + {operationObjectiveDefined && ( + + + {drefResponse?.operation_objective} + + + )} + {responseStrategyDefined && ( + + + {drefResponse?.response_strategy} + + + )} + + )} + {showTargetingStrategySection && ( + <> + + {strings.targetingStrategySectionHeading} + + {targetingStrategySupportingDocumentDefined && ( + + + {strings.targetingStrategySupportingDocument} + + + )} + {peopleAssistedDefined && ( + + + {drefResponse?.people_assisted} + + + )} + {selectionCriteriaDefined && ( + + + {drefResponse?.selection_criteria} + + + )} + + )} + + {drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT && ( + + )} + + {drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT && ( + + )} + + {drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT && ( + + )} + + {drefResponse?.type_of_dref !== DREF_TYPE_ASSESSMENT && ( + + )} +
+ + + {showRiskAndSecuritySection && ( + <> + + {strings.riskAndSecuritySectionHeading} + + {riskSecurityDefined && ( + +
+ {strings.drefApplicationExportRisk} +
+
+ {strings.drefApplicationExportMitigation} +
+ {drefResponse?.risk_security?.map( + (riskSecurity) => ( + + + {riskSecurity.risk} + + + {riskSecurity.mitigation} + + + ), + )} +
+ )} + {riskSecurityConcernDefined && ( + + + {drefResponse?.risk_security_concern} + + + )} + {hasChildrenSafeguardingDefined && ( + + + + )} + + )} + {plannedInterventionDefined && ( + <> + + {strings.plannedInterventionSectionHeading} + + {plannedInterventions?.map((plannedIntervention) => ( + + + + {plannedIntervention.title_display} + + + + + + +
+ {strings.indicatorTitleLabel} +
+
+ {strings.indicatorTargetLabel} +
+ {plannedIntervention.indicators?.map( + (indicator) => ( + + ), + )} +
+ + + {plannedIntervention.description} + + +
+ ))} + + )} + {showAboutSupportServicesSection && ( + <> + + {strings.aboutSupportServicesSectionHeading} + + {humanResourceDefined && ( + + + {drefResponse?.human_resource} + + + )} + {surgePersonnelDeployedDefined && ( + + + {drefResponse?.surge_personnel_deployed} + + + )} + {logisticCapacityOfNsDefined && ( + + + {drefResponse?.logistic_capacity_of_ns} + + + )} + {pmerDefined && ( + + + {drefResponse?.pmer} + + + )} + {communicationDefined && ( + + + {drefResponse?.communication} + + + )} + + )} + {showBudgetOverview && ( + <> +
+ + + + + + {strings.drefExportDownloadBudget} + + + + )} + {showContactsSection && ( + <> +
+ + {strings.contactInformationSectionHeading} + + + {strings.contactInformationSectionDescription} + + + {nsContactDefined && ( + + )} + {appealManagerContactDefined && ( + + )} + {projectManagerContactDefined && ( + + )} + {focalPointContactDefined && ( + + )} + {mediaContactDefined && ( + + )} + + + {strings.drefExportReference} + + + )} + {previewReady &&
} +
+ ); +} + +Component.displayName = 'DrefApplicationExport'; diff --git a/src/views/DrefApplicationExport/styles.module.css b/app/src/views/DrefApplicationExport/styles.module.css similarity index 100% rename from src/views/DrefApplicationExport/styles.module.css rename to app/src/views/DrefApplicationExport/styles.module.css diff --git a/src/views/DrefApplicationForm/Actions/NSActionInput/i18n.json b/app/src/views/DrefApplicationForm/Actions/NSActionInput/i18n.json similarity index 100% rename from src/views/DrefApplicationForm/Actions/NSActionInput/i18n.json rename to app/src/views/DrefApplicationForm/Actions/NSActionInput/i18n.json diff --git a/app/src/views/DrefApplicationForm/Actions/NSActionInput/index.tsx b/app/src/views/DrefApplicationForm/Actions/NSActionInput/index.tsx new file mode 100644 index 000000000..dcdbe5344 --- /dev/null +++ b/app/src/views/DrefApplicationForm/Actions/NSActionInput/index.tsx @@ -0,0 +1,93 @@ +import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; +import { + Button, + InputSection, + TextArea, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { isDefined } from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type SetValueArg, + useFormObject, +} from '@togglecorp/toggle-form'; + +import NonFieldError from '#components/NonFieldError'; + +import { type PartialDref } from '../../schema'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type NsActionFormFields = NonNullable[number]; + +const defaultNsActionValue: NsActionFormFields = { + client_id: '-1', +}; + +interface Props { + value: NsActionFormFields; + error: ArrayError | undefined; + onChange: (value: SetValueArg, index: number) => void; + onRemove: (index: number) => void; + index: number; + titleDisplayMap: Record | undefined; + disabled?: boolean; +} + +function NsActionInput(props: Props) { + const { + error: errorFromProps, + onChange, + value, + index, + titleDisplayMap, + onRemove, + disabled, + } = props; + + const strings = useTranslation(i18n); + + const onFieldChange = useFormObject(index, onChange, defaultNsActionValue); + + const nsActionLabel = isDefined(value.title) + ? titleDisplayMap?.[value.title] + : '--'; + + const error = (value && value.client_id && errorFromProps) + ? getErrorObject(errorFromProps?.[value.client_id]) + : undefined; + + return ( + + +