diff --git a/config/webpack.common.js b/config/webpack.common.js index d2b6291..a85621c 100644 --- a/config/webpack.common.js +++ b/config/webpack.common.js @@ -1,6 +1,8 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); +const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const path = require('path'); const root = path.join(__dirname, '../'); @@ -11,6 +13,16 @@ module.exports = { resolve: { extensions: ['.ts', '.tsx', '.js'] }, + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + parallel: true, + sourceMap: true, + }), + new OptimizeCSSAssetsPlugin({}), + ], + }, performance: { hints: false, maxEntrypointSize: 512000, diff --git a/config/webpack.prod.js b/config/webpack.prod.js index 4ceff7b..9fe4a0d 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -8,9 +8,9 @@ const config = { output: { publicPath: 'auto', }, - devtool: 'eval-source-map', + devtool: 'source-map', devServer: { - port: 3000, + port: 3001, historyApiFallback: true, headers: { "Access-Control-Allow-Origin": "*" @@ -18,18 +18,15 @@ const config = { }, plugins: [ new ModuleFederationPlugin({ - name: 'MicroFeChild', + name: 'MicroFETradePartners', filename: 'remoteEntry.js', exposes: { './Components': './src/externals/exports.tsx', }, shared: { ...packageJson.dependencies, - react: { requiredVersion: packageJson.dependencies.react }, - 'react-dom': { requiredVersion: packageJson.dependencies['react-dom'] }, - 'react-router-dom': { requiredVersion: packageJson.dependencies['react-router-dom'] }, - 'react-redux': { requiredVersion: packageJson.dependencies['react-redux'] }, - 'react-intl': { requiredVersion: packageJson.dependencies['react-intl'] }, + react: { singleton: true }, + 'react-dom': { singleton: true }, } }), ] diff --git a/package.json b/package.json index 4503e51..0b3ca20 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "customers-microfe", + "name": "tradepartners-microfe", "version": "0.1.0", "private": true, "dependencies": { @@ -13,12 +13,12 @@ "@types/react-dom": "^18.2.4", "astro-actions": "1.6.17", "astro-sso-sdk": "^1.6.17", - "cosmos-components": "2.0.0-alpha.288", + "cosmos-components": "1.35.0", "lodash": "^4.17.21", "moment": "^2.29.4", "numeral": "^2.0.6", - "react": "17", - "react-dom": "17", + "react": "^18.1.0", + "react-dom": "^18.1.0", "react-intl": "^6.4.4", "react-redux": "^8.1.0", "react-router-config": "4.4.0-beta.8", @@ -46,7 +46,9 @@ "css-loader": "^6.7.4", "html-webpack-plugin": "^5.5.1", "mini-css-extract-plugin": "^2.7.6", + "optimize-css-assets-webpack-plugin": "^6.0.1", "style-loader": "^3.3.3", + "terser-webpack-plugin": "1.4.5", "webpack": "^5.83.1", "webpack-cli": "^5.1.1", "webpack-dev-server": "^4.15.0", diff --git a/src/App.tsx b/src/App.tsx index 46a632b..1955b87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ function App() { return loading ? null : (
-
Customers MicroFE
+
Tradepartners MicroFE
{renderRoutes(routes as any)}
); diff --git a/src/apis/trade-partners.api.ts b/src/apis/trade-partners.api.ts new file mode 100644 index 0000000..ef298b9 --- /dev/null +++ b/src/apis/trade-partners.api.ts @@ -0,0 +1,34 @@ +import { download } from "../core/utils/download"; +import { getApiSDK } from "../helpers/window.helper"; + +export async function listTradePartners ({ ...params }): Promise { + return await getApiSDK().api.get( + '/agents', + { + params, + }, + ); +} + +export async function exportTradePartners ({ postSearchRequestAgent }): Promise { + try { + const result = await getApiSDK().api.post( + '/agents/export', + { + ...postSearchRequestAgent, + subGroupIds: postSearchRequestAgent.subGroupIds?.split(','), + }, + ); + + const blobURL = + window.URL && window.URL.createObjectURL + ? window.URL.createObjectURL( + new Blob([result], { type: 'application/octet-stream' }), + ) + : window.webkitURL.createObjectURL(result); + + download(blobURL, 'agents.csv'); + } catch (error) { + console.error(error); + } +} diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index ff7fd44..bfd0b97 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -2,21 +2,14 @@ import React from 'react'; import { render } from 'react-dom'; import './index.css'; import App from './App'; -import { Router } from 'react-router-dom'; -import { Store } from './core/store/Store'; -import { Internationalization } from './components/internationalization/Internationalization'; -import { getHistory } from './helpers/window.helper'; +import { ExternalWrapper } from './externals/external-wrapper/ExternalWrapper'; const mount = (el: HTMLElement) => { render( - - - - - - - + + + , el ); diff --git a/src/components/allocate-rate-modal/AllocateRateModalContent.tsx b/src/components/allocate-rate-modal/AllocateRateModalContent.tsx new file mode 100644 index 0000000..5006970 --- /dev/null +++ b/src/components/allocate-rate-modal/AllocateRateModalContent.tsx @@ -0,0 +1,190 @@ +import { + Icon, + PrimaryButton, + TextButton, + SelectOptionProps, +} from 'cosmos-components'; +import React, { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import styled, { css } from 'styled-components'; + +import { SearchableTagSelect } from 'modules/search'; + +import msg from 'modules/agents/agents/messages'; + +import { getProductRateOptions, getAllocatRateLoading } from '../selectors'; + +import { actions as ratesActions } from '../ducks/productRates'; +import { allocateProductRate as allocateRateAction } from '../ducks/allocateProductRate'; + +type ActionType = 'Add' | 'Remove'; + +export const AllocateRateModalContent = ({ selectedAgents }) => { + const [rateCode, setRateCode] = useState(null); + const [succeedNotice, setSucceedNotice] = useState(null); + const [allocateRateSucceed, setAllocateRateSucceed] = useState(false); + + const intl = useIntl(); + const dispatch = useDispatch(); + + const { options: rateOptions, loading: rateLoading } = useSelector( + getProductRateOptions, + ); + + const allocating = useSelector(getAllocatRateLoading); + + const searchForRates = (keyword = null) => { + if (keyword) { + dispatch( + ratesActions.listRequest({ + text: keyword, + }), + ); + } + }; + + const generateNotice = (action: ActionType) => { + if (action === 'Add') { + return intl.formatMessage(msg.editProductRates.addSucceed, { + code: rateCode?.value, + }); + } + + return intl.formatMessage(msg.editProductRates.removeSucceed, { + code: rateCode?.value, + }); + }; + + const allocateProductRate = (action: ActionType) => { + dispatch( + allocateRateAction({ + group: { + code: rateCode?.value, + }, + agents: selectedAgents.map((agent) => ({ + id: agent.id, + code: agent.code, + })), + action, + onSuccess: () => { + setAllocateRateSucceed(true); + setRateCode(null); + setSucceedNotice(generateNotice(action)); + }, + }), + ); + }; + + useEffect(() => { + dispatch(ratesActions.clean()); + }, []); + + return ( + <> + + {intl.formatMessage(msg.editProductRates.code)} + + { + setRateCode(selected); + setAllocateRateSucceed(false); + dispatch(ratesActions.clean()); + }} + isDisabled={allocating} + /> + {rateCode && ( + + allocateProductRate('Remove')} + isLoading={allocating} + > + + {intl.formatMessage(msg.editProductRates.remove)} + + allocateProductRate('Add')} + isLoading={allocating} + > + + {intl.formatMessage(msg.editProductRates.add)} + + + )} + {allocateRateSucceed && ( + + + {succeedNotice} + + )} + + ); +}; + +const SelectTitle = styled.div` + color: ${({ theme: { colors } }) => colors.battleshipGrey}; + margin-bottom: 0.5rem; +`; + +const ModalButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + margin-top: 1.5rem; +`; + +const buttonStyle = css` + width: 8rem; + height: 3.8rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + font-size: 1.2rem; + + svg { + width: 2rem; + margin-right: 0.5rem; + } +`; + +const ModalPrimaryButton = styled(PrimaryButton)` + ${buttonStyle} +`; + +const ModalTextButton = styled(TextButton)` + ${buttonStyle} + + margin-right: 1.5rem; + + svg { + fill: ${({ theme: { colors } }) => colors.blueMain}; + } +`; + +const AllocateRateSucceed = styled.div` + margin-top: 1.5rem; + background-color: #eaf5fd; + width: 100%; + height: 4rem; + display: flex; + align-items: center; + padding-left: 5px; + + svg { + fill: ${({ theme: { colors } }) => colors.green}; + width: 1.5rem; + margin-right: 1rem; + } +`; diff --git a/src/components/customers/reducers/trade-partners.reducer.ts b/src/components/customers/reducers/trade-partners.reducer.ts new file mode 100644 index 0000000..8708442 --- /dev/null +++ b/src/components/customers/reducers/trade-partners.reducer.ts @@ -0,0 +1,33 @@ +import { tradePartnerActionsConstants } from "../../tradepartners/actions/trade-partners.action"; + +const intialState = { + list: { + loading: false, + data: null + }, +}; + +export default (state = intialState, { type, payload }) => { + switch (type) { + case tradePartnerActionsConstants.triggered: + return { + ...intialState, + list: { + ...intialState.list, + loading: true + } + }; + case tradePartnerActionsConstants.loaded: { + return { + ...intialState, + list: { + ...intialState.list, + ...payload, + data: payload.data + } + }; + } + default: + return state; + } +}; diff --git a/src/components/customers/view/Customers.tsx b/src/components/customers/view/Customers.tsx index 992ece5..9660809 100644 --- a/src/components/customers/view/Customers.tsx +++ b/src/components/customers/view/Customers.tsx @@ -10,7 +10,7 @@ import { Page } from 'cosmos-components/dist/components/Page'; import { PaginatedTableDeprecated } from 'cosmos-components/dist/components/deprecated/PaginatedTableDeprecated'; import { TextButton } from 'cosmos-components/dist/components/Button'; -import SearchBox from "../../search-box"; +import SearchBox from "../../search"; // hooks import { useCustomersSearch } from "../hooks"; diff --git a/src/components/filters/Filters.tsx b/src/components/filters/Filters.tsx new file mode 100644 index 0000000..18e156e --- /dev/null +++ b/src/components/filters/Filters.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import styled, { css } from 'styled-components'; + +// components +import { Button, Labeled, TagSelectDeprecated } from 'cosmos-components'; +import { SearchableTagSelect } from '../search/SearchableTagSelect'; + +// actions +import { actions as agentGroupListActions } from '../ducks/agentGroup/agentGroups'; +import { actions as typesActions } from '../ducks/agentType/agentTypes'; +import { actions as agentSubGroupActions } from '../ducks/agentSubGroup/agentSubGroups'; +import { actions as pointActions } from 'core/ducks/points'; +import { actions as marketListActions } from '../ducks/marketCode/marketCodes'; + +// selectors +import { + getAgentGroupOptionsWithLoading, + getAgentTypeOptions, + getAgentSubGroupOptionsWithLoading, + getAddressCountries, + getMarketCodeOptionsWithLoading, +} from '../selectors'; + +// messages +import coreMsg from 'core/messages'; +import msg from '../messages'; + +// types +import { StatusBase } from 'core/enums'; +import { FilterName, FilterProps, FilterValue, Status } from '../types'; + +// helpers +import { mainStatusOptions } from 'core/helpers/options'; +import { convertFiltersForBackend } from '../helpers'; +import { FILTER_MAX_SUB_GROUP_LIMIT } from '../constants'; + +const initialState: FilterProps = { + status: mainStatusOptions.find( + (status) => status.value === StatusBase.Active, + ), + type: null, + group: null, + subGroups: [], + market: null, + billingCountryCode: null, +}; + +export const Filters = ({ setFiltersCallback, resetPagination }) => { + const intl = useIntl(); + const dispatch = useDispatch(); + + const [filters, setFilters] = useState(initialState); + + const { options: typeOptions, loading: typeLoading } = useSelector( + getAgentTypeOptions, + ); + const { options: groupOptions, loading: groupLoading } = useSelector( + getAgentGroupOptionsWithLoading, + ); + const { options: subGroupOptions, loading: subGroupLoading } = useSelector( + getAgentSubGroupOptionsWithLoading, + ); + const { options: marketOptions, loading: marketLoading } = useSelector( + getMarketCodeOptionsWithLoading, + ); + const countryCodeOptions = useSelector(getAddressCountries); + + useEffect(() => { + searchForTypes(); + searchForGroups(); + fetchMarkets(); + fetchCountryCodes(); + }, []); + + const searchForTypes = (keyword = null) => { + dispatch( + typesActions.listRequest({ + ...(keyword && { query: `*${keyword}*` }), + }), + ); + }; + + const searchForGroups = (keyword = null) => { + dispatch( + agentGroupListActions.listRequest({ + text: keyword, + groupStatus: 'Active', + hasParent: false, + }), + ); + }; + + const fetchSubGroups = (groupId) => { + dispatch( + agentSubGroupActions.listRequest({ + groupId, + groupStatus: Status.Active, + }), + ); + }; + + const fetchMarkets = () => { + dispatch( + marketListActions.listRequest({ + consistent: true, + }), + ); + }; + + const fetchCountryCodes = () => { + dispatch( + pointActions.request({ + consistent: true, + search: 'and(status=Active,type=Country)', + }), + ); + }; + + const setFilter = (name: string, value: FilterValue) => { + setFilters({ + ...filters, + [name]: value, + }); + }; + + const setGroupfilter = (value: FilterValue) => { + setFilters({ + ...filters, + [FilterName.Group]: value, + [FilterName.SubGroups]: [], + }); + }; + + const handleFilter = () => { + convertAndApplyFilters(filters); + }; + + const handleReset = () => { + setFilters(initialState); + convertAndApplyFilters(initialState); + }; + + const convertAndApplyFilters = (rawFilters: FilterProps) => { + const filtersWithSubGroupIds = { + ...rawFilters, + subGroupIds: rawFilters.subGroups + ?.map((subGroup) => subGroup.value) + .join(','), + }; + const convertedFilters = convertFiltersForBackend( + filtersWithSubGroupIds, + ); + setFiltersCallback(convertedFilters); + resetPagination(); + }; + + const onSubGroupsSelect = (selected) => { + const isWithinSubGroupsLimit = + selected.length <= FILTER_MAX_SUB_GROUP_LIMIT; + isWithinSubGroupsLimit && setFilter(FilterName.SubGroups, selected); + }; + + return ( + + + + { + setFilter(FilterName.Status, value); + }} + /> + + + + + } + > + { + setFilter(FilterName.Type, value); + }} + /> + + + + + + { + setGroupfilter(selected); + fetchSubGroups(selected.value); + }} + /> + + + + + + + + + + + + { + setFilter(FilterName.Market, value); + }} + /> + + + + + + { + setFilter(FilterName.BillingCountryCode, value); + }} + /> + + + + + + + + + + + ); +}; + +const FiltersWrapper = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 0.7rem; + font-size: 14px; +`; + +const FullRowWidth = css` + grid-column: 1 / span 2; +`; + +const DropDownWrapper = styled.div` + ${FullRowWidth} +`; + +const FilterButton = styled(Button)` + ${FullRowWidth} + margin-top: 3rem; + color: white; +`; + +const ResetButton = styled(Button)` + ${FullRowWidth} + margin-top: 1rem; + color: white; +`; diff --git a/src/components/search/SearchableTagSelect.tsx b/src/components/search/SearchableTagSelect.tsx new file mode 100644 index 0000000..dbbbc9d --- /dev/null +++ b/src/components/search/SearchableTagSelect.tsx @@ -0,0 +1,92 @@ +import { TagSelectDeprecated } from 'cosmos-components'; +import { ComponentProps, FC } from 'react'; +import { useSearch, UseSearchArgs } from './useSearch'; + +type TagSelectProps = ComponentProps; +type SearchableTagSelectProps = TagSelectProps & + UseSearchArgs & { + isKeepingInputValue?: boolean; + isKeepingActiveInputValue?: boolean; + }; +export const SearchableTagSelect: FC = ({ + isMulti = true, + isKeepingInputValue, + isKeepingActiveInputValue, + keywordMinLength, + onSearch, + value, + ...props +}) => { + const controlledProps = useControlledSearch({ + isKeepingInputValue, + isKeepingActiveInputValue, + keywordMinLength, + onSearch, + }); + + const currentValue = + (!isMulti && + value && + props.options?.find((option) => option?.value === value)) || + value; + + return ( + + ); +}; + +type UseControllerSearchArgs = Pick< + SearchableTagSelectProps, + | 'isKeepingInputValue' + | 'keywordMinLength' + | 'onSearch' + | 'isKeepingActiveInputValue' +>; + +const useControlledSearch = ({ + isKeepingInputValue, + isKeepingActiveInputValue, + keywordMinLength, + onSearch, +}: UseControllerSearchArgs) => { + if (!onSearch) { + return {}; + } + + const [keyword, handleSearch, setKeyword] = useSearch({ + keywordMinLength, + onSearch, + }); + + // Preventing clearing input value + // https://github.com/JedWatson/react-select/issues/3210#issuecomment-566482487 + const handleInputChangeAndPreserve = ( + isKeepingAll: boolean, + ): TagSelectProps['onInputChange'] => (inputValue, { action }) => { + const clearingActions = isKeepingAll + ? ['set-value', 'input-blur', 'menu-close'] + : ['set-value']; + + if (clearingActions.includes(action)) { + return keyword; + } + + handleSearch(inputValue); + + return inputValue; + }; + + const handleInputChange = + isKeepingInputValue || isKeepingActiveInputValue + ? handleInputChangeAndPreserve(isKeepingInputValue) + : handleSearch; + + return { handleInputChange, keyword }; +}; diff --git a/src/components/search-box/index.tsx b/src/components/search/index.tsx similarity index 100% rename from src/components/search-box/index.tsx rename to src/components/search/index.tsx diff --git a/src/components/search/useSearch.ts b/src/components/search/useSearch.ts new file mode 100644 index 0000000..3d73b54 --- /dev/null +++ b/src/components/search/useSearch.ts @@ -0,0 +1,36 @@ +import debounce from 'lodash/debounce'; +import { useCallback, useState } from 'react'; + +export interface UseSearchArgs { + onSearch: (value: string) => void; + keywordMinLength?: number; + debounceTimer?: number; +} + +type UseSearch = (args: UseSearchArgs) => [string, (nextKeyword: string) => void, React.Dispatch>]; // prettier-ignore + +export const useSearch: UseSearch = ({ + onSearch, + keywordMinLength = 1, + debounceTimer = 500, +}) => { + const [keyword, setKeyword] = useState(''); + + const debouncedOnSearchCallback = useCallback( + debounce(onSearch, debounceTimer), + [onSearch, debounceTimer], + ); + + const handleSearch = (nextKeyword: string) => { + setKeyword(nextKeyword); + + if ( + nextKeyword.length >= keywordMinLength || + nextKeyword.length === 0 + ) { + debouncedOnSearchCallback(nextKeyword); + } + }; + + return [keyword, handleSearch, setKeyword]; +}; diff --git a/src/components/tradepartners/actions/trade-partners.action.ts b/src/components/tradepartners/actions/trade-partners.action.ts new file mode 100644 index 0000000..eaf8412 --- /dev/null +++ b/src/components/tradepartners/actions/trade-partners.action.ts @@ -0,0 +1,10 @@ +export const tradePartnerActionsConstants = { + loaded: 'TRADE_PARTNER/LOADED', + triggered: 'TRADE_PARTNER/TRIGGERED', +}; + +export const tradePartnerActions = { + TYPE: tradePartnerActionsConstants, + triggered: () => ({ type: tradePartnerActionsConstants.triggered}), + loaded: (payload = {}) => ({ type: tradePartnerActionsConstants.loaded, payload}), +}; diff --git a/src/components/tradepartners/hooks/index.ts b/src/components/tradepartners/hooks/index.ts new file mode 100644 index 0000000..0dd00e6 --- /dev/null +++ b/src/components/tradepartners/hooks/index.ts @@ -0,0 +1 @@ +export { useAgentsSearch } from './useAgentsSearch'; diff --git a/src/components/tradepartners/hooks/useAgentsSearch.ts b/src/components/tradepartners/hooks/useAgentsSearch.ts new file mode 100644 index 0000000..d7ac741 --- /dev/null +++ b/src/components/tradepartners/hooks/useAgentsSearch.ts @@ -0,0 +1,137 @@ +import { useSearchParams } from 'cosmos-components'; +import { useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { FormattedFilters, Status } from '../types'; +import { exportTradePartners, listTradePartners } from '../../../apis/trade-partners.api'; +import { tradePartnerActions } from '../actions/trade-partners.action'; + +const defaultPageSize = 20; + +export const useAgentsSearch = ({ filters }) => { + const localDispatch = useDispatch(); + const [paginationParams, setPaginationParams] = useSearchParams({ + page: 1, + pageSize: defaultPageSize, + text: '', + }); + const { page, pageSize, text } = paginationParams; + const offset = pageSize * (page - 1); + + const setSearchPagination = useCallback( + (pageSelected: number, pageSizeSelected: number = defaultPageSize) => { + setPaginationParams({ + page: pageSelected, + pageSize: pageSizeSelected, + }); + }, + [setPaginationParams], + ); + + const setSearchText = useCallback( + (searchText: string) => { + setPaginationParams({ + text: searchText, + }); + setSearchPagination(1, pageSize); + }, + [setPaginationParams], + ); + + const loadPageData = ( + loadPage: any, + loadPageSize: number, + loadSearch: string, + loadFilters: FormattedFilters, + ) => { + if (!loadPage || !loadPageSize) { + return; + } + + const fetchTradePartners = async () => { + return await listTradePartners({ + ...(loadSearch && { text: loadSearch }), + limit: pageSize, + offset, + consistent: true, + summary: true, + ...transformFilters(loadFilters) + }); + }; + + localDispatch(tradePartnerActions.triggered()); + + fetchTradePartners().then(resp => { + localDispatch(tradePartnerActions.loaded(resp)); + }); + }; + + const transformFilters = ({ + status, + type, + group, + subGroupIds, + market, + billingCountryCode, + }: FormattedFilters) => { + const formattedStatus = !status + ? Status.Active + : status !== Status.All + ? status + : null; + + return { + status: formattedStatus, + typeId: type, + groupId: group, + subGroupIds, + marketId: market, + billingCountryCode, + }; + }; + + const loadPageDataCallback = useCallback(loadPageData, [ + page, + pageSize, + text, + filters, + ]); + + useEffect(() => { + loadPageDataCallback(page, pageSize, text, filters); + }, [loadPageDataCallback, page, pageSize, text, filters]); + + const resetPagination = () => { + setSearchPagination(1, pageSize); + }; + + const initiateDownload = useCallback(() => { + exportTradePartners({ + ...transformFilters(filters), + text, + }); + }, [transformFilters, setSearchText]); + + const handlePagination = (clickAction: 'next' | 'previous') => { + const pageChange = clickAction === 'next' ? 1 : -1; + setSearchPagination(page + pageChange, pageSize); + }; + + const handlePaginationSize = ( + pageSizeSelected: number = defaultPageSize, + ) => { + setSearchPagination(1, pageSizeSelected); + }; + + return { + page, + pageSize, + offset, + text, + handlePagination, + handlePaginationSize, + setSearchText, + resetPagination, + initiateDownload, + }; +}; diff --git a/src/components/tradepartners/hooks/useAgentsTable.tsx b/src/components/tradepartners/hooks/useAgentsTable.tsx new file mode 100644 index 0000000..ef82579 --- /dev/null +++ b/src/components/tradepartners/hooks/useAgentsTable.tsx @@ -0,0 +1,76 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { Link as RouterLink } from 'react-router-dom'; + +import msg from '../messages'; +import { Link } from 'cosmos-components'; +import { testIds } from '../types'; +/*import { usePermissions } from 'core/permissions'; +import { PermissionCode } from 'core/types';*/ + +export const useAgentsTable = () => { + const [selectedAgents, setSelectedAgents] = useState([]); + + const intl = useIntl(); + /*const hasProductManagementPermission = usePermissions( + PermissionCode.ManageProduct, + );*/ + const hasProductManagementPermission = true; + + const tableConfiguration = useMemo( + () => [ + { + Header: intl.formatMessage(msg.fields.name), + accessor: ({ id, name }) => ( + + {name} + + ), + }, + { + Header: intl.formatMessage(msg.fields.debtorStatus), + accessor: 'debtorStatus', + }, + { + Header: intl.formatMessage(msg.fields.type), + accessor: 'type', + }, + { + Header: intl.formatMessage(msg.fields.group), + accessor: 'group', + }, + { + Header: intl.formatMessage(msg.fields.subGroup), + accessor: 'subGroup', + }, + { + Header: intl.formatMessage(msg.fields.billingAddress), + accessor: 'billingAddress', + }, + { + Header: intl.formatMessage(msg.fields.status), + accessor: 'status', + }, + ], + [], + ); + + const selectHandler = useCallback( + hasProductManagementPermission && + ((selectedRows) => { + setSelectedAgents(selectedRows); + }), + [], + ); + + return { + selectedAgents, + setSelectedAgents, + tableConfiguration, + selectHandler, + }; +}; diff --git a/src/components/tradepartners/messages.ts b/src/components/tradepartners/messages.ts new file mode 100644 index 0000000..09209bc --- /dev/null +++ b/src/components/tradepartners/messages.ts @@ -0,0 +1,331 @@ +import { defineMessages } from 'react-intl'; + +const prefix = 'mod.agents'; +const dataField = `${prefix}.dataField`; +const btnScope = `${prefix}.buttons`; +const titleScope = `${prefix}.titles`; +const validationScope = `${prefix}.validation`; +const contentScope = `${prefix}.contents`; +const linkScope = `${prefix}.links`; +const optionScope = `${prefix}.options`; +const subscriptionScope = `${prefix}.subscription`; +const radioOptionsScope = `${prefix}.radioOptions`; + +const fields = defineMessages({ + type: { + id: `${dataField}.type`, + defaultMessage: 'Type', + }, + email: { + id: `${dataField}.email`, + defaultMessage: 'Email', + }, + code: { + id: `${dataField}.code`, + defaultMessage: 'Code', + }, + name: { + id: `${dataField}.name`, + defaultMessage: 'Name', + }, + tags: { + id: `${dataField}.tags`, + defaultMessage: 'Tags', + }, + group: { + id: `${dataField}.group`, + defaultMessage: 'Group', + }, + subGroup: { + id: `${dataField}.subGroup`, + defaultMessage: 'Sub Group', + }, + debtorStatus: { + id: `${dataField}.debtorStatus`, + defaultMessage: 'Debtor Status', + }, + billingAddress: { + id: `${dataField}.billingAddress`, + defaultMessage: 'Billing Address', + }, + status: { + id: `${dataField}.status`, + defaultMessage: 'Status', + }, + addressCountry: { + id: `${dataField}.addressCountry`, + defaultMessage: 'Address Country', + }, + market: { + id: `${dataField}.market`, + defaultMessage: 'Market', + }, + all: { + id: `${dataField}.all`, + defaultMessage: 'All', + }, + brand: { + id: `${dataField}.brand`, + defaultMessage: 'Brand', + }, + driverLicenceCondition: { + id: `${dataField}.driverLicenceCondition`, + defaultMessage: 'Driver Licence Condition', + }, + channel: { + id: `${dataField}.channel`, + defaultMessage: 'Channel', + }, + fileType: { + id: `${dataField}.fileType `, + defaultMessage: 'File Type', + }, +}); + +const buttons = defineMessages({ + create: { + id: `${btnScope}.createButton`, + defaultMessage: 'Create Trade Partner', + }, + update: { + id: `${btnScope}.updateButton`, + defaultMessage: 'Save Trade Partner', + }, + add: { + id: `${btnScope}.addButton`, + defaultMessage: 'Add Trade Partner', + }, + createAgentGroup: { + id: `${btnScope}.create.agent.group`, + defaultMessage: 'Create Trade Partner Group', + }, + createMarketCode: { + id: `${btnScope}.create.market.code`, + defaultMessage: 'Create Market Code', + }, + exportToCsv: { + id: `${btnScope}.exportCSV`, + defaultMessage: `Export To CSV`, + }, + addSubscription: { + id: `${btnScope}.addSubscription`, + defaultMessage: 'Add Subscription', + }, +}); + +const titles = defineMessages({ + list: { + id: `${titleScope}.list`, + defaultMessage: 'Trade Partners - MicroFE', + }, + create: { + id: `${titleScope}.create`, + defaultMessage: 'Create Trade Partner', + }, + detail: { + id: `${titleScope}.detail`, + defaultMessage: 'Trade Partner', + }, + addAgentGroup: { + id: `${titleScope}.add.agent.group`, + defaultMessage: 'Add trade partner group', + }, + addMarketCode: { + id: `${titleScope}.add.market.code`, + defaultMessage: 'Add market code', + }, + flexList: { + id: `${titleScope}.flexList`, + defaultMessage: 'Flex File Subscription', + }, + createSubscription: { + id: `${titleScope}.createSubscription`, + defaultMessage: 'Create Subscription', + }, + editSubscription: { + id: `${titleScope}.editSubscription`, + defaultMessage: 'Edit Subscription', + }, +}); + +const validation = defineMessages({ + codeRequired: { + id: `${validationScope}.code.required`, + defaultMessage: 'Code is required', + }, + nameRequired: { + id: `${validationScope}.name.required`, + defaultMessage: 'Name is required', + }, + emailValidation: { + id: `${validationScope}.email.format`, + defaultMessage: 'Email address is invalid', + }, + emailDuplicatedValidation: { + id: `${validationScope}.email.duplicated`, + defaultMessage: 'Email address is duplicated', + }, + requiredValidation: { + id: `${validationScope}.required`, + defaultMessage: 'This is required', + }, +}); + +const links = defineMessages({ + details: { + id: `${linkScope}.details`, + defaultMessage: 'Details', + }, + staffContacts: { + id: `${linkScope}.staffContacts`, + defaultMessage: 'Staff Contacts', + }, +}); + +const contents = defineMessages({}); + +const options = defineMessages({ + statusAll: { + id: `${optionScope}.statusAll`, + defaultMessage: 'All', + }, + statusActive: { + id: `${optionScope}.statusActive`, + defaultMessage: 'Active', + }, + statusInactive: { + id: `${optionScope}.statusInactive`, + defaultMessage: 'Inactive', + }, + + statusCredit: { + id: `${optionScope}.statusCredit`, + defaultMessage: 'Credit', + }, + statusPrepay: { + id: `${optionScope}.statusPrepay`, + defaultMessage: 'Prepay', + }, + statusCredit20: { + id: `${optionScope}.statusCredit20`, + defaultMessage: 'Credit 20th FM', + }, + statusPrepay60: { + id: `${optionScope}.statusPrepay60`, + defaultMessage: 'Prepay 60 days', + }, +}); + +const editProductRates = defineMessages({ + agentsSelected: { + id: `${linkScope}.agentsSelected`, + defaultMessage: '{selected} Trade Partners selected', + }, + title: { + id: `${linkScope}.title`, + defaultMessage: 'Edit Product Rates', + }, + clear: { + id: `${linkScope}.clear`, + defaultMessage: 'Clear', + }, + code: { + id: `${contentScope}.code`, + defaultMessage: 'Code', + }, + selectAProductRate: { + id: `${contentScope}.selectAProductRate`, + defaultMessage: 'Select a product rate', + }, + remove: { + id: `${btnScope}.remove`, + defaultMessage: 'Remove', + }, + add: { + id: `${btnScope}.add`, + defaultMessage: 'Add', + }, + addSucceed: { + id: `${btnScope}.addSucceed`, + defaultMessage: '{code} has been added to Trade Partners', + }, + removeSucceed: { + id: `${btnScope}.removeSucceed`, + defaultMessage: '{code} has been removed from Trade Partners', + }, +}); + +const subscription = defineMessages({ + brand: { + id: `${subscriptionScope}.brand`, + defaultMessage: 'Brand', + }, + driverLicenceCondition: { + id: `${subscriptionScope}.driverLicenceCondition`, + defaultMessage: 'Driver Licence Condition', + }, + channel: { + id: `${subscriptionScope}.channel`, + defaultMessage: 'Channel', + }, + fileType: { + id: `${subscriptionScope}.fileType`, + defaultMessage: 'File Type', + }, + selectChannelType: { + id: `${subscriptionScope}.selectChannelType`, + defaultMessage: 'Select Channel Type', + }, + emailAddress: { + id: `${subscriptionScope}.emailAddress`, + defaultMessage: 'Email Address', + }, + createSubscription: { + id: `${subscriptionScope}.createSubscription`, + defaultMessage: 'Create Subscription', + }, + saveSubscription: { + id: `${titleScope}.saveSubscription`, + defaultMessage: 'Save Subscription', + }, + statusSelectPlaceHolder: { + id: `${subscriptionScope}.statusSelectPlaceHolder`, + defaultMessage: 'Select status', + }, +}); + +const radioOptions = defineMessages({ + ftp: { + id: `${radioOptionsScope}.ftp`, + defaultMessage: 'FTP', + }, + email: { + id: `${radioOptionsScope}.email`, + defaultMessage: 'Email', + }, + domestic: { + id: `${radioOptionsScope}.domestic`, + defaultMessage: 'Domestic', + }, + international: { + id: `${radioOptionsScope}.international`, + defaultMessage: 'International', + }, + all: { + id: `${radioOptionsScope}.all`, + defaultMessage: 'All', + }, +}); + +export default { + fields, + buttons, + titles, + validation, + contents, + links, + options, + editProductRates, + subscription, + radioOptions, +}; diff --git a/src/components/tradepartners/selectors.ts b/src/components/tradepartners/selectors.ts new file mode 100644 index 0000000..f4301f8 --- /dev/null +++ b/src/components/tradepartners/selectors.ts @@ -0,0 +1,20 @@ +import { createSelector } from 'reselect'; + +export const getTradePartnersRaw = (state) => state.tradePartners?.list; + +export const getTradePartners = createSelector(getTradePartnersRaw, (tradePartners) => { + const data = (tradePartners?.data || []).map((item) => { + const billingAddress = item.addresses?.filter( + (address) => address.type === 'Billing', + )[0]; + + return { + ...item, + type: item.type?.name, + group: item.groups?.[0]?.parent?.name, + subGroup: item.groups?.[0]?.name, + billingAddress: billingAddress?.country, + }; + }); + return { ...tradePartners, data }; +}); diff --git a/src/components/tradepartners/types.ts b/src/components/tradepartners/types.ts new file mode 100644 index 0000000..97a9e0c --- /dev/null +++ b/src/components/tradepartners/types.ts @@ -0,0 +1,47 @@ +import { OptionProps, SelectOptionProps } from 'cosmos-components'; + +export const Status = { + All: 'All', + Active: 'Active', + Inactive: 'Inactive', +} as const; + +export enum FilterName { + Status = 'status', + Type = 'type', + Group = 'group', + SubGroups = 'subGroups', + Market = 'market', + BillingCountryCode = 'billingCountryCode', +} + +export interface FilterProps { + status?: SelectOptionProps; + type?: SelectOptionProps; + group?: SelectOptionProps; + subGroups?: SelectOptionProps[]; + market?: SelectOptionProps; + billingCountryCode?: SelectOptionProps; +} + +export type FilterValue = OptionProps & SelectOptionProps; + +export interface FormattedFilters { + status?: string; + type?: string; + group?: string; + subGroupIds?: string; + market?: string; + billingCountryCode?: string; +} + +const testId = 'agent-list'; +export const testIds = { + agentTableTestId: `${testId}-agent-table`, + searchAgentTestId: `${testId}-search-agent`, + statusSelectAgentTestId: `${testId}-status-agent`, + createAgentTestId: `${testId}-create-agent`, + editAgentTestId: `${testId}-edit-agent`, + allocateProductRateModal: `${testId}-allocate-product-modal`, + agentCsvTestId: `${testId}-agent-csv-download-link`, +}; diff --git a/src/components/tradepartners/view/ExternalTradePartners.tsx b/src/components/tradepartners/view/ExternalTradePartners.tsx new file mode 100644 index 0000000..8b374bf --- /dev/null +++ b/src/components/tradepartners/view/ExternalTradePartners.tsx @@ -0,0 +1,14 @@ +import { TradePartners } from './TradePartners'; +import { ExternalWrapper } from '../../../externals/external-wrapper/ExternalWrapper'; +import { FC } from 'react'; +import { RouteConfig } from 'react-router-config'; + +export const ExternalTradePartners: FC<{ location: RouteConfig['location'] }> = ({ + location, +}) => { + return ( + + + + ); +}; diff --git a/src/components/tradepartners/view/TradePartners.tsx b/src/components/tradepartners/view/TradePartners.tsx new file mode 100644 index 0000000..8c63af2 --- /dev/null +++ b/src/components/tradepartners/view/TradePartners.tsx @@ -0,0 +1,243 @@ +import React, { FC, Fragment, useContext, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useSelector } from 'react-redux'; +import { RouteConfig } from 'react-router-config'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +// components +import { + H1, + Page, + Select, + HorizontalField, + //SidepanelContext, + TextButton, + Modal, + Pagination, +} from 'cosmos-components'; + +import { Table } from 'cosmos-components/dist/components/Table'; +import SearchBox from '../../search'; +import { Filters } from '../../filters/Filters' + +// selectors +import { getTradePartners } from '../selectors'; + +// messages +import msg from '../messages'; + +// hooks +import { useAgentsSearch } from '../hooks'; +import { AllocateRateModalContent } from '../../allocate-rate-modal/AllocateRateModalContent'; +import { useAgentsTable } from '../hooks/useAgentsTable'; +import { testIds } from '../types'; + +export const TradePartners: FC<{ location: RouteConfig['location'] }> = ({ + location, +}) => { + const intl = useIntl(); + //const { pushContent, popContent } = useContext(SidepanelContext); + const [filters, setFilters] = useState({}); + const [editRatesModalOpen, setEditRatesModalOpen] = useState(false); + + const { loading, data, totalCount } = useSelector(getTradePartners); + + const { + selectedAgents, + setSelectedAgents, + tableConfiguration, + selectHandler, + } = useAgentsTable(); + + const { + page, + pageSize, + offset, + text: searchText, + handlePagination, + handlePaginationSize, + setSearchText, + resetPagination, + initiateDownload, + } = useAgentsSearch({ filters }); + + /*useEffect(() => { + pushContent( + , + ); + return popContent; + }, [location.state]);*/ + + useEffect(() => { + if (loading) { + setSelectedAgents([]); + } + }, [loading]); + + const onLastPage = page * pageSize >= totalCount; + const pageCount = onLastPage ? totalCount - offset : pageSize; + + const showAll = 'ALL'; + const paginationSizes = [20, 50, 100, 250, showAll]; + const paginationSize = pageSize === totalCount ? showAll : pageSize; + const isLoaded = !loading && totalCount; + if (isLoaded && paginationSizes.indexOf(paginationSize) === -1) { + handlePaginationSize(); + } + const handlePaginationSizeEvent = (event) => { + const onChangeSize = + event.target.value === showAll + ? totalCount + : Number.parseInt(event.target.value, 10); + handlePaginationSize(onChangeSize); + }; + + return ( + +

{intl.formatMessage(msg.titles.list)}

+ {React.version} + + + {intl.formatMessage(msg.buttons.exportToCsv)} + + + {intl.formatMessage(msg.buttons.add)} + + + + } + > + + +