diff --git a/.env b/.env index 92b05c8..cec74cd 100644 --- a/.env +++ b/.env @@ -5,4 +5,6 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1 # VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true # disables controls for default application routing to carrier for SP and account level users -#VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING=true \ No newline at end of file +#VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING=true +## disables Least cost routing feature +#VITE_APP_LCR_DISABLED=true \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b4f76fe..7d3a18e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,10 @@ "dependencies": { "@jambonz/ui-kit": "^0.0.21", "dayjs": "^1.11.5", + "immutability-helper": "^3.1.1", "react": "^18.0.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "^18.0.0", "react-feather": "^2.0.10", "react-router-dom": "^6.3.0" @@ -23,6 +26,7 @@ "@types/node": "^18.6.1", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", "@vitejs/plugin-react": "^1.3.0", @@ -743,6 +747,21 @@ "node": ">= 8" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "node_modules/@rollup/pluginutils": { "version": "4.2.1", "dev": true, @@ -842,11 +861,11 @@ "version": "18.6.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.1.tgz", "integrity": "sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==", - "dev": true + "devOptional": true }, "node_modules/@types/prop-types": { "version": "15.7.5", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -863,7 +882,7 @@ }, "node_modules/@types/react": { "version": "18.0.15", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -881,7 +900,7 @@ }, "node_modules/@types/scheduler": { "version": "0.16.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/serve-static": { @@ -906,6 +925,12 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", @@ -2284,7 +2309,7 @@ }, "node_modules/csstype": { "version": "3.1.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cypress": { @@ -2640,6 +2665,16 @@ "node": ">=8" } }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "dev": true, @@ -3341,7 +3376,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3833,6 +3867,14 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3925,6 +3967,11 @@ "node": ">= 4" } }, + "node_modules/immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "node_modules/immutable": { "version": "4.1.0", "dev": true, @@ -5333,6 +5380,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "license": "MIT", @@ -5399,6 +5483,14 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.9", "license": "MIT" @@ -7048,6 +7140,21 @@ "fastq": "^1.6.0" } }, + "@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "@rollup/pluginutils": { "version": "4.2.1", "dev": true, @@ -7142,11 +7249,11 @@ "version": "18.6.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.1.tgz", "integrity": "sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==", - "dev": true + "devOptional": true }, "@types/prop-types": { "version": "15.7.5", - "dev": true + "devOptional": true }, "@types/qs": { "version": "6.9.7", @@ -7162,7 +7269,7 @@ }, "@types/react": { "version": "18.0.15", - "dev": true, + "devOptional": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -7178,7 +7285,7 @@ }, "@types/scheduler": { "version": "0.16.2", - "dev": true + "devOptional": true }, "@types/serve-static": { "version": "1.15.0", @@ -7202,6 +7309,12 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "@types/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", @@ -8042,7 +8155,7 @@ }, "csstype": { "version": "3.1.0", - "dev": true + "devOptional": true }, "cypress": { "version": "10.8.0", @@ -8288,6 +8401,16 @@ "path-type": "^4.0.0" } }, + "dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "requires": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "doctrine": { "version": "2.1.0", "dev": true, @@ -8782,8 +8905,7 @@ "dev": true }, "fast-deep-equal": { - "version": "3.1.3", - "dev": true + "version": "3.1.3" }, "fast-glob": { "version": "3.2.11", @@ -9127,6 +9249,14 @@ "@babel/runtime": "^7.7.6" } }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -9178,6 +9308,11 @@ "version": "5.2.0", "dev": true }, + "immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "immutable": { "version": "4.1.0", "dev": true @@ -10059,6 +10194,26 @@ "loose-envify": "^1.1.0" } }, + "react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "requires": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "requires": { + "dnd-core": "^16.0.1" + } + }, "react-dom": { "version": "18.2.0", "requires": { @@ -10099,6 +10254,14 @@ "picomatch": "^2.2.1" } }, + "redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerator-runtime": { "version": "0.13.9" }, diff --git a/package.json b/package.json index b896929..59fc7bf 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,10 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-feather": "^2.0.10", - "react-router-dom": "^6.3.0" + "react-router-dom": "^6.3.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "immutability-helper": "^3.1.1" }, "devDependencies": { "@types/cors": "^2.8.12", @@ -54,6 +57,7 @@ "@types/node": "^18.6.1", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", "@vitejs/plugin-react": "^1.3.0", diff --git a/src/api/constants.ts b/src/api/constants.ts index 6cc1cfc..f832936 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -33,6 +33,11 @@ export const DISABLE_CUSTOM_SPEECH: boolean = JSON.parse( import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false" ); +/** Disable Lcr */ +export const DISABLE_LCR: boolean = JSON.parse( + import.meta.env.VITE_APP_LCR_DISABLED || "false" +); + /** TCP Max Port */ export const TCP_MAX_PORT = 65535; @@ -74,7 +79,6 @@ export const DEFAULT_SMPP_GATEWAY: SmppGateway = { inbound: 1, outbound: 1, }; - /** Netmask Bits */ export const NETMASK_BITS = Array(32) .fill(0) @@ -207,3 +211,6 @@ export const API_SMPP_GATEWAY = `${API_BASE_URL}/SmppGateways`; export const API_SIP_GATEWAY = `${API_BASE_URL}/SipGateways`; export const API_PASSWORD_SETTINGS = `${API_BASE_URL}/PasswordSettings`; export const API_SYSTEM_INFORMATION = `${API_BASE_URL}/SystemInformation`; +export const API_LCRS = `${API_BASE_URL}/Lcrs`; +export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`; +export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`; diff --git a/src/api/index.ts b/src/api/index.ts index addeb33..4588436 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -20,6 +20,9 @@ import { USER_ACCOUNT, API_LOGOUT, API_SYSTEM_INFORMATION, + API_LCR_ROUTES, + API_LCR_CARRIER_SET_ENTRIES, + API_LCRS, } from "./constants"; import { ROUTE_LOGIN } from "src/router/routes"; import { @@ -61,6 +64,9 @@ import type { LimitCategories, PasswordSettings, SystemInformation, + Lcr, + LcrRoute, + LcrCarrierSetEntry, } from "./types"; import { StatusCodes } from "./types"; @@ -360,6 +366,23 @@ export const postSystemInformation = (payload: Partial) => { payload ); }; + +export const postLcr = (payload: Partial) => { + return postFetch>(API_LCRS, payload); +}; + +export const postLcrRoute = (payload: Partial) => { + return postFetch>(API_LCR_ROUTES, payload); +}; + +export const postLcrCarrierSetEntry = ( + payload: Partial +) => { + return postFetch>( + API_LCR_CARRIER_SET_ENTRIES, + payload + ); +}; /** Named wrappers for `putFetch` */ export const putUser = (sid: string, payload: Partial) => { @@ -452,6 +475,27 @@ export const putSmppGateway = (sid: string, payload: Partial) => { ); }; +export const putLcr = (sid: string, payload: Partial) => { + return putFetch>(`${API_LCRS}/${sid}`, payload); +}; + +export const putLcrRoutes = (sid: string, payload: Partial) => { + return putFetch>( + `${API_LCR_ROUTES}/${sid}`, + payload + ); +}; + +export const putLcrCarrierSetEntries = ( + sid: string, + payload: Partial +) => { + return putFetch>( + `${API_LCR_CARRIER_SET_ENTRIES}/${sid}`, + payload + ); +}; + /** Named wrappers for `deleteFetch` */ export const deleteUser = (sid: string) => { @@ -515,6 +559,14 @@ export const deleteAccountLimit = (sid: string, cat: LimitCategories) => { ); }; +export const deleteLcr = (sid: string) => { + return deleteFetch(`${API_LCRS}/${sid}`); +}; + +export const deleteLcrRoute = (sid: string) => { + return deleteFetch(`${API_LCR_ROUTES}/${sid}`); +}; + /** Named wrappers for `getFetch` */ export const getUser = (sid: string) => { @@ -531,6 +583,28 @@ export const getAccountWebhook = (sid: string) => { ); }; +export const getLcrs = () => { + return getFetch(API_LCRS); +}; + +export const getLcr = (sid: string) => { + return getFetch(`${API_LCRS}/${sid}`); +}; + +export const getLcrRoutes = (sid: string) => { + return getFetch(`${API_LCR_ROUTES}?lcr_sid=${sid}`); +}; + +export const getLcrRoute = (sid: string) => { + return getFetch(`${API_LCR_ROUTES}/${sid}`); +}; + +export const getLcrCarrierSetEtries = (sid: string) => { + return getFetch( + `${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}` + ); +}; + /** Wrappers for APIs that can have a mock dev server response */ export const getRecentCalls = (sid: string, query: Partial) => { diff --git a/src/api/types.ts b/src/api/types.ts index cd4786d..0bb5959 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -178,6 +178,7 @@ export interface ServiceProvider { name: string; ms_teams_fqdn: null | string; service_provider_sid: string; + lcr_sid: null | string; } export interface Limit { @@ -237,6 +238,7 @@ export interface Account { registration_hook: null | WebHook; service_provider_sid: string; device_calling_application_sid: null | string; + lcr_sid: null | string; } export interface Application { @@ -390,6 +392,33 @@ export interface SmppGateway extends Gateway { use_tls: boolean; } +export interface Lcr { + lcr_sid?: null | string; + is_active: boolean; + name: null | string; + default_carrier_set_entry_sid?: null | string; + account_sid: null | string; + service_provider_sid: null | string; + number_routes?: number; +} + +export interface LcrRoute { + lcr_route_sid?: null | string; + lcr_sid: null | string; + regex: null | string; + desciption?: null | string; + priority: number; + lcr_carrier_set_entries?: LcrCarrierSetEntry[]; +} + +export interface LcrCarrierSetEntry { + lcr_carrier_set_entry_sid?: null | string; + workload?: number; + lcr_route_sid: null | string; + voip_carrier_sid: null | string; + priority: number; +} + export interface PageQuery { page: number; count: number; diff --git a/src/components/icons.ts b/src/components/icons.ts index 7f3e2f9..7dd74f1 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -39,6 +39,9 @@ import { PhoneOutgoing, PhoneIncoming, MoreHorizontal, + Share2, + ArrowUp, + ArrowDown, } from "react-feather"; import type { Icon } from "react-feather"; @@ -88,4 +91,7 @@ export const Icons: IconMap = { PhoneOutgoing, PhoneIncoming, MoreHorizontal, + Share2, + ArrowUp, + ArrowDown, }; diff --git a/src/constants/index.tsx b/src/constants/index.tsx index 1f89184..6ac11ab 100644 --- a/src/constants/index.tsx +++ b/src/constants/index.tsx @@ -34,3 +34,4 @@ export const MSG_WEBHOOK_FIELDS = ( password fields are required. ); +export const NOT_AVAILABLE_PREFIX = "NotAvalable"; diff --git a/src/containers/internal/navi/index.tsx b/src/containers/internal/navi/index.tsx index 654b65d..ee0fb10 100644 --- a/src/containers/internal/navi/index.tsx +++ b/src/containers/internal/navi/index.tsx @@ -21,6 +21,7 @@ import { ScopedAccess } from "src/components/scoped-access"; import { Scope, UserData } from "src/store/types"; import { USER_ADMIN } from "src/api/constants"; import { ROUTE_LOGIN } from "src/router/routes"; +import { Lcr } from "src/api/types"; type CommonProps = { handleMenu: () => void; @@ -35,16 +36,17 @@ type NaviProps = CommonProps & { type ItemProps = CommonProps & { item: NaviItem; user?: UserData; + lcr?: Lcr; }; -const Item = ({ item, user, handleMenu }: ItemProps) => { +const Item = ({ item, user, lcr, handleMenu }: ItemProps) => { const location = useLocation(); const active = location.pathname.includes(item.route(user)); return (
  • @@ -64,6 +66,7 @@ export const Navi = ({ const dispatch = useDispatch(); const navigate = useNavigate(); const user = useSelectState("user"); + const lcr = useSelectState("lcr"); const accessControl = useSelectState("accessControl"); const serviceProviders = useSelectState("serviceProviders"); const currentServiceProvider = useSelectState("currentServiceProvider"); @@ -133,6 +136,7 @@ export const Navi = ({ useEffect(() => { dispatch({ type: "user" }); dispatch({ type: "serviceProviders" }); + dispatch({ type: "lcr" }); }, []); return ( @@ -221,6 +225,7 @@ export const Navi = ({ diff --git a/src/containers/internal/navi/items.ts b/src/containers/internal/navi/items.ts index 4fad1bb..d42d196 100644 --- a/src/containers/internal/navi/items.ts +++ b/src/containers/internal/navi/items.ts @@ -9,17 +9,20 @@ import { ROUTE_INTERNAL_SPEECH, ROUTE_INTERNAL_PHONE_NUMBERS, ROUTE_INTERNAL_MS_TEAMS_TENANTS, + ROUTE_INTERNAL_LEST_COST_ROUTING, } from "src/router/routes"; import { Icons } from "src/components"; import { Scope, UserData } from "src/store/types"; import type { Icon } from "react-feather"; import type { ACL } from "src/store/types"; +import { Lcr } from "src/api/types"; +import { DISABLE_LCR } from "src/api/constants"; export interface NaviItem { label: string; icon: Icon; - route: (user?: UserData) => string; + route: (user?: UserData, lcr?: Lcr) => string; acl?: keyof ACL; scope?: Scope; restrict?: boolean; @@ -89,4 +92,21 @@ export const naviByo: NaviItem[] = [ route: () => ROUTE_INTERNAL_MS_TEAMS_TENANTS, acl: "hasMSTeamsFqdn", }, + ...(DISABLE_LCR === false + ? [ + { + label: "Outbound Call Routing", + icon: Icons.Share2, + route: (user, lcr) => { + if (user?.access === Scope.admin) { + return ROUTE_INTERNAL_LEST_COST_ROUTING; + } + if (lcr && lcr.lcr_sid) { + return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`; + } + return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`; + }, + } as NaviItem, + ] + : []), ]; diff --git a/src/containers/internal/views/carriers/index.tsx b/src/containers/internal/views/carriers/index.tsx index 8267726..f61b424 100644 --- a/src/containers/internal/views/carriers/index.tsx +++ b/src/containers/internal/views/carriers/index.tsx @@ -52,7 +52,6 @@ export const Carriers = () => { setAccountSid(getAccountFilter()); if (user?.account_sid && user?.scope === USER_ACCOUNT) { setAccountSid(user?.account_sid); - return carriers; } return carriers diff --git a/src/containers/internal/views/least-cost-routing/add.tsx b/src/containers/internal/views/least-cost-routing/add.tsx new file mode 100644 index 0000000..6e45494 --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/add.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { H1 } from "@jambonz/ui-kit"; + +import { LcrForm } from "./form"; + +export const AddLcr = () => { + return ( + <> +

    Add outbound call routes

    + + + ); +}; + +export default AddLcr; diff --git a/src/containers/internal/views/least-cost-routing/card.tsx b/src/containers/internal/views/least-cost-routing/card.tsx new file mode 100644 index 0000000..0bbacbc --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/card.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { Icon } from "@jambonz/ui-kit"; +import { Identifier, XYCoord } from "dnd-core"; +import { useRef } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { LcrRoute } from "src/api/types"; +import { Icons } from "src/components"; +import { Selector } from "src/components/forms"; +import { SelectorOption } from "src/components/forms/selector"; +import "./styles.scss"; + +interface DragItem { + index: number; + type: string; +} + +const ItemTypes = { + CARD: "card", +}; + +type CardProps = { + lr: LcrRoute; + index: number; + moveCard: (dragIndex: number, hoverIndex: number) => void; + updateLcrRoute: (index: number, key: string, value: unknown) => void; + updateLcrCarrierSetEntries: ( + index1: number, + index2: number, + key: string, + value: unknown + ) => void; + handleRouteDelete: (lr: LcrRoute, index: number) => void; + carrierSelectorOptions: SelectorOption[]; +}; + +export const Card = ({ + lr, + index, + moveCard, + updateLcrRoute, + updateLcrCarrierSetEntries, + handleRouteDelete, + carrierSelectorOptions, +}: CardProps) => { + const ref = useRef(null); + const [{ handlerId }, drop] = useDrop< + DragItem, + void, + { handlerId: Identifier | null } + >({ + accept: ItemTypes.CARD, + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) { + if (!ref.current) { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = + (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + // Time to actually perform the action + moveCard(dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [{ isDragging }, drag] = useDrag({ + type: ItemTypes.CARD, + item: () => { + return { index }; + }, + collect: (monitor) => { + return { isDragging: monitor.isDragging() }; + }, + }); + + drag(drop(ref)); + + return ( +
    +
    + { + updateLcrRoute(index, "regex", e.target.value); + }} + /> + + 0 + ? lr.lcr_carrier_set_entries[0].voip_carrier_sid + ? lr.lcr_carrier_set_entries[0].voip_carrier_sid + : "" + : "" + } + required + options={carrierSelectorOptions} + onChange={(e) => { + updateLcrCarrierSetEntries( + index, + 0, + "voip_carrier_sid", + e.target.value + ); + }} + /> +
    + +
    + ); +}; +export default Card; diff --git a/src/containers/internal/views/least-cost-routing/container.tsx b/src/containers/internal/views/least-cost-routing/container.tsx new file mode 100644 index 0000000..948e463 --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/container.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { LcrRoute } from "src/api/types"; +import Card from "./card"; +import { hasLength } from "src/utils"; +import update from "immutability-helper"; +import { deleteLcrRoute } from "src/api"; +import { toastError, toastSuccess } from "src/store"; +import { SelectorOption } from "src/components/forms/selector"; +import { NOT_AVAILABLE_PREFIX } from "src/constants"; + +type ContainerProps = { + lcrRoute: [LcrRoute[], React.Dispatch>]; + carrierSelectorOptions: SelectorOption[]; +}; + +export const Container = ({ + lcrRoute: [lcrRoutes, setLcrRoutes], + carrierSelectorOptions, +}: ContainerProps) => { + const moveCard = (dragIndex: number, hoverIndex: number) => { + setLcrRoutes((prevCards) => + update(prevCards, { + $splice: [ + [dragIndex, 1], + [hoverIndex, 0, prevCards[dragIndex]], + ], + }) + ); + }; + + const updateLcrRoute = (index: number, key: string, value: unknown) => { + setLcrRoutes( + lcrRoutes.map((lr, i) => (i === index ? { ...lr, [key]: value } : lr)) + ); + }; + + const updateLcrCarrierSetEntries = ( + index1: number, + index2: number, + key: string, + value: unknown + ) => { + setLcrRoutes( + lcrRoutes.map((lr, i) => + i === index1 + ? { + ...lr, + lcr_carrier_set_entries: lr.lcr_carrier_set_entries?.map( + (entry, j) => + j === index2 + ? { + ...entry, + [key]: value, + } + : entry + ), + } + : lr + ) + ); + }; + + const handleRouteDelete = (r: LcrRoute | undefined, index: number) => { + if ( + r && + r.lcr_route_sid && + !r.lcr_route_sid.startsWith(NOT_AVAILABLE_PREFIX) + ) { + deleteLcrRoute(r.lcr_route_sid) + .then(() => { + toastSuccess("Least cost routing rule successfully deleted"); + }) + .catch((error) => { + toastError(error); + }); + } + setLcrRoutes(lcrRoutes.filter((g2, i) => i !== index)); + }; + + return ( + <> + {hasLength(lcrRoutes) && + lcrRoutes.map((lr, i) => ( + + ))} + + ); +}; + +export default Container; diff --git a/src/containers/internal/views/least-cost-routing/delete.tsx b/src/containers/internal/views/least-cost-routing/delete.tsx new file mode 100644 index 0000000..161168c --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/delete.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { P } from "@jambonz/ui-kit"; +import { Modal } from "src/components"; +import { Lcr } from "src/api/types"; + +type DeleteProps = { + lcr: Lcr; + handleCancel: () => void; + handleSubmit: () => void; +}; + +export const DeleteLcr = ({ lcr, handleCancel, handleSubmit }: DeleteProps) => { + return ( + <> + +

    + Are you sure you want to delete least cost routing{" "} + {lcr.name}? +

    +
    + + ); +}; + +export default DeleteLcr; diff --git a/src/containers/internal/views/least-cost-routing/edit.tsx b/src/containers/internal/views/least-cost-routing/edit.tsx new file mode 100644 index 0000000..1e29f12 --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/edit.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { H1 } from "@jambonz/ui-kit"; +import LcrForm from "./form"; +import { useApiData } from "src/api"; +import { Lcr, LcrRoute } from "src/api/types"; +import { useParams } from "react-router-dom"; +export const EditLcr = () => { + const params = useParams(); + const [lcrData, lcrRefect, lcrError] = useApiData( + `Lcrs/${params.lcr_sid}` + ); + const [lcrRouteData, lcrRouteRefect, lcrRouteError] = useApiData( + `LcrRoutes?lcr_sid=${params.lcr_sid}` + ); + return ( + <> +

    Edit outbound call routes

    + + + ); +}; + +export default EditLcr; diff --git a/src/containers/internal/views/least-cost-routing/form.tsx b/src/containers/internal/views/least-cost-routing/form.tsx new file mode 100644 index 0000000..d40321c --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/form.tsx @@ -0,0 +1,598 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit"; +import { Icons, Section } from "src/components"; +import { + toastError, + toastSuccess, + useDispatch, + useSelectState, +} from "src/store"; +import { MSG_REQUIRED_FIELDS, NOT_AVAILABLE_PREFIX } from "src/constants"; +import { setLocation } from "src/store/localStore"; +import { AccountSelect, Message, Selector } from "src/components/forms"; +import type { + Account, + Carrier, + Lcr, + LcrCarrierSetEntry, + LcrRoute, + UseApiDataMap, +} from "src/api/types"; +import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes"; +import { + deleteLcr, + postLcrCarrierSetEntry, + putLcrCarrierSetEntries, + putLcrRoutes, + putLcr, + useApiData, + useServiceProviderData, + getLcrRoute, +} from "src/api"; +import { USER_ACCOUNT, USER_ADMIN } from "src/api/constants"; +import { postLcr } from "src/api"; +import { postLcrRoute } from "src/api"; +import DeleteLcr from "./delete"; +import { Scope } from "src/store/types"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import Container from "./container"; +import { v4 } from "uuid"; + +type LcrFormProps = { + lcrDataMap?: UseApiDataMap; + lcrRouteDataMap?: UseApiDataMap; +}; + +export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => { + const LCR_ROUTE_TEMPLATE: LcrRoute = { + lcr_route_sid: `${NOT_AVAILABLE_PREFIX}${v4()}`, + regex: "", + lcr_sid: "", + priority: 0, + lcr_carrier_set_entries: [ + { + lcr_route_sid: "", + voip_carrier_sid: "", + priority: 0, + }, + ], + }; + + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [errorMessage, setErrorMessage] = useState(""); + const [lcrName, setLcrName] = useState(""); + const [defaultLcrCarrier, setDefaultLcrCarrier] = useState(""); + const [defaultLcrCarrierSetEntrySid, setDefaultLcrCarrierSetEntrySid] = + useState(); + const [defaultLcrRouteSid, setDefaultLcrRouteSid] = useState(""); + const [defaultCarrier, setDefaultCarrier] = useState(""); + const [apiUrl, setApiUrl] = useState(""); + const [accountSid, setAccountSid] = useState(""); + const [isActive, setIsActive] = useState(true); + const [lcrRoutes, setLcrRoutes] = useState([LCR_ROUTE_TEMPLATE]); + const [previousLcrRoutes, setPreviousLcrRoutes] = useState([ + LCR_ROUTE_TEMPLATE, + ]); + const [previouseLcr, setPreviousLcr] = useState(); + const [accounts] = useServiceProviderData("Accounts"); + const [lcrForDelete, setLcrForDelete] = useState(); + + const user = useSelectState("user"); + const currentServiceProvider = useSelectState("currentServiceProvider"); + const [carriers] = useApiData(apiUrl); + + useEffect(() => { + setLocation(); + if (currentServiceProvider) { + setApiUrl( + `ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers` + ); + } + }, [user, currentServiceProvider, accountSid]); + + const carrierSelectorOptions = useMemo(() => { + if (user?.account_sid && user?.scope === USER_ACCOUNT) { + setAccountSid(user?.account_sid); + } + + const carriersFiltered = carriers + ? carriers.filter((carrier) => + accountSid + ? carrier.account_sid === accountSid + : carrier.account_sid === null + ) + : []; + + const ret = carriersFiltered + ? carriersFiltered.map((c: Carrier, i) => { + if (i === 0) { + setDefaultCarrier(c.voip_carrier_sid); + } + return { + name: c.name, + value: c.voip_carrier_sid, + }; + }) + : []; + if (carriers && ret.length === 0) { + setErrorMessage( + accountSid + ? "There are no available carriers defined for this account" + : "There are no available carriers" + ); + } else { + setErrorMessage(""); + } + return ret; + }, [accountSid, carriers]); + + if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) { + setLcrName(lcrDataMap.data.name || ""); + setIsActive(lcrDataMap.data.is_active); + setPreviousLcr(lcrDataMap.data); + } + + if ( + lcrRouteDataMap && + lcrRouteDataMap.data && + lcrRouteDataMap.data !== previousLcrRoutes + ) { + setPreviousLcrRoutes(lcrRouteDataMap.data); + // Find default carrier + lcrRouteDataMap.data.forEach((lr) => { + lr.lcr_carrier_set_entries?.forEach((entry) => { + if ( + entry.lcr_carrier_set_entry_sid === + lcrDataMap?.data?.default_carrier_set_entry_sid + ) { + setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier); + setDefaultLcrCarrierSetEntrySid( + entry.lcr_carrier_set_entry_sid || null + ); + setDefaultLcrRouteSid(entry.lcr_route_sid || ""); + } + }); + }); + } + + useMemo(() => { + if (lcrRouteDataMap && lcrRouteDataMap.data) + setLcrRoutes( + lcrRouteDataMap.data.filter( + (route) => route.lcr_route_sid !== defaultLcrRouteSid + ) + ); + }, [defaultLcrRouteSid]); + + const addLcrRoutes = () => { + const ls = [ + ...lcrRoutes, + { + ...LCR_ROUTE_TEMPLATE, + priority: lcrRoutes.length, + }, + ]; + setLcrRoutes(ls); + }; + + const getLcrPayload = (): Lcr => { + return { + name: lcrName, + is_active: isActive, + account_sid: accountSid, + service_provider_sid: + currentServiceProvider?.service_provider_sid || null, + default_carrier_set_entry_sid: defaultLcrCarrierSetEntrySid, + }; + }; + + const handleLcrPost = () => { + const lcrPayload: Lcr = getLcrPayload(); + postLcr(lcrPayload) + .then(({ json }) => { + Promise.all( + lcrRoutes.map((route, i) => handleLcrRoutePost(json.sid, route, i)) + ) + .then(() => { + handleLcrDefaultCarrierPost(json.sid); + }) + .catch(({ msg }) => { + toastError(msg); + }); + }) + .catch(({ msg }) => { + toastError(msg); + }); + }; + + const handleLcrDefaultCarrierPost = (lcr_sid: string) => { + const defaultRoute = { + lcr_sid: lcr_sid, + regex: ".*", + desciption: "System Default Route", + priority: 9999, + lcr_carrier_set_entries: [ + { + lcr_route_sid: "", + voip_carrier_sid: defaultLcrCarrier, + priority: 0, + }, + ], + }; + + handleLcrRoutePost(lcr_sid, defaultRoute, 9999).then((lcr_route_sid) => { + // There is small hack here to wait the data commited in bd for lcr entries + new Promise(async (r) => setTimeout(() => r(0), 300)).then(() => { + getLcrRoute(lcr_route_sid).then(({ json }) => { + if (json.lcr_carrier_set_entries?.length) { + const lcr_carrier_set_entry_sid = + json.lcr_carrier_set_entries[0].lcr_carrier_set_entry_sid; + putLcr(lcr_sid, { + default_carrier_set_entry_sid: lcr_carrier_set_entry_sid, + }) + .then(() => { + if (lcrDataMap) { + toastSuccess("Least cost routing successfully updated"); + } else { + toastSuccess("Least cost routing successfully created"); + if (user?.access === Scope.admin) { + navigate(ROUTE_INTERNAL_LEST_COST_ROUTING); + } else { + navigate( + `${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr_sid}/edit` + ); + } + // Update global state + dispatch({ type: "lcr" }); + } + }) + .catch((error) => { + toastError(error); + }); + } + }); + }); + }); + }; + + const handleLcrRoutePost = ( + lcr_sid: string, + route: LcrRoute, + priority: number + ): Promise => { + return new Promise(async (resolve, reject) => { + const lcrRoutePayload: LcrRoute = { + lcr_sid, + regex: route.regex, + priority, + }; + postLcrRoute(lcrRoutePayload) + .then(({ json }) => { + if (route.lcr_carrier_set_entries) { + Promise.all( + route.lcr_carrier_set_entries.map((entry) => { + handleLcrCarrierSetEntryPost(json.sid, entry); + }) + ) + .then(() => { + resolve(json.sid); + }) + .catch((error) => { + reject(error); + }); + } + }) + .catch((error) => { + reject(error); + }); + }); + }; + + const handleLcrCarrierSetEntryPost = ( + lcr_route_sid: string, + entry: LcrCarrierSetEntry + ): Promise => { + const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = { + ...entry, + voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier, + lcr_route_sid, + }; + return new Promise(async (r, e) => { + postLcrCarrierSetEntry(lcrCarrierSetEntryPayload) + .then(({ json }) => r(json.sid)) + .catch((error) => e(error)); + }); + }; + + const handleLcrPut = () => { + if (lcrDataMap && lcrDataMap.data && lcrDataMap.data.lcr_sid) { + // update LCR + const lcrPayload: Lcr = getLcrPayload(); + putLcr(lcrDataMap.data.lcr_sid, lcrPayload).then(() => { + Promise.all( + lcrRoutes.map((route, i) => { + if ( + route.lcr_route_sid && + !route.lcr_route_sid?.startsWith(NOT_AVAILABLE_PREFIX) + ) { + handleLcrRoutePut( + lcrDataMap.data?.lcr_sid || "", + route.lcr_route_sid, + route, + i + ); + } else { + handleLcrRoutePost(lcrDataMap.data?.lcr_sid || "", route, i); + } + }) + ) + .then(() => { + if (defaultLcrCarrierSetEntrySid) { + const defaultEntry: LcrCarrierSetEntry = { + lcr_route_sid: defaultLcrRouteSid, + voip_carrier_sid: defaultLcrCarrier, + priority: 0, + }; + + handleLcrCarrierEntryPut( + defaultLcrRouteSid, + defaultLcrCarrierSetEntrySid, + defaultEntry + ).then(() => { + toastSuccess("Least cost routing rule successfully updated"); + }); + } + }) + .catch((error) => toastError(error)); + }); + } + + const handleLcrRoutePut = ( + lcr_sid: string, + lcr_route_sid: string, + route: LcrRoute, + priority: number + ): Promise => { + return new Promise(async (resolve, reject) => { + const lcrRoutePayload: LcrRoute = { + lcr_sid, + regex: route.regex, + priority, + }; + putLcrRoutes(lcr_route_sid, lcrRoutePayload).then(() => { + if ( + route.lcr_carrier_set_entries && + route.lcr_carrier_set_entries.length > 0 + ) { + Promise.all( + route.lcr_carrier_set_entries.map((entry) => { + if (entry.lcr_carrier_set_entry_sid) { + return handleLcrCarrierEntryPut( + entry.lcr_route_sid || lcr_route_sid, + entry.lcr_carrier_set_entry_sid, + entry + ); + } else { + return handleLcrCarrierSetEntryPost(lcr_route_sid, entry); + } + }) + ) + .then(() => { + resolve("Least cost routing rule successfully updated"); + }) + .catch((error) => { + reject(error); + }); + } + }); + }); + }; + + const handleLcrCarrierEntryPut = ( + lcr_route_sid: string, + lcr_carrier_set_entry_sid: string, + entry: LcrCarrierSetEntry + ): Promise => { + const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = { + lcr_route_sid, + workload: entry.workload, + voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier, + priority: entry.priority, + }; + return new Promise(async (r, e) => { + putLcrCarrierSetEntries( + lcr_carrier_set_entry_sid, + lcrCarrierSetEntryPayload + ) + .then(() => r("success")) + .catch((error) => e(error)); + }); + }; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (lcrDataMap) { + handleLcrPut(); + } else { + handleLcrPost(); + } + }; + + const handleDelete = () => { + if (lcrForDelete) { + deleteLcr(lcrForDelete.lcr_sid || "") + .then(() => { + toastSuccess( + <> + Deleted least cost routing {lcrForDelete?.name} + + ); + setLcrForDelete(null); + if (user?.access === Scope.admin) { + navigate(ROUTE_INTERNAL_LEST_COST_ROUTING); + } else { + navigate(`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`); + } + dispatch({ type: "lcr" }); + }) + .catch((error) => { + toastError(error.msg); + }); + } + }; + + return ( + <> +
    +
    +
    + {MSG_REQUIRED_FIELDS} + {errorMessage && } +
    +
    +
    +
    + + setLcrName(e.target.value)} + /> +
    +
    + +
    + + { + setDefaultLcrCarrier(e.target.value); + }} + /> +
    +
    + {user?.scope === USER_ADMIN && ( +
    + +
    + )} +
    + + + Drag and drop to rearrange the order. + + + + + + + + +
    +
    +
    +
    +
    + + {user?.access === Scope.admin && ( + + )} + + +
    +
    +
    + {user?.scope !== USER_ADMIN && + lcrDataMap && + lcrDataMap.data && + lcrDataMap.data.lcr_sid && ( + + + + )} +
    +
    +
    +
    +
    +
    + {lcrForDelete && ( + setLcrForDelete(null)} + handleSubmit={handleDelete} + /> + )} + + ); +}; + +export default LcrForm; diff --git a/src/containers/internal/views/least-cost-routing/index.tsx b/src/containers/internal/views/least-cost-routing/index.tsx new file mode 100644 index 0000000..e1eb91e --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/index.tsx @@ -0,0 +1,217 @@ +import React, { useMemo } from "react"; +import { Button, H1, Icon, M } from "@jambonz/ui-kit"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { deleteLcr, useApiData, useServiceProviderData } from "src/api"; +// import { USER_ACCOUNT } from "src/api/constants"; +import type { Account, Lcr } from "src/api/types"; +import { + AccountFilter, + Icons, + SearchFilter, + Section, + Spinner, +} from "src/components"; +import { ScopedAccess } from "src/components/scoped-access"; +import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes"; +import { toastSuccess, toastError, useSelectState } from "src/store"; +// import { getAccountFilter, setLocation } from "src/store/localStore"; +import { Scope } from "src/store/types"; +import { + hasLength, + hasValue, + useFilteredResults, + useScopedRedirect, +} from "src/utils"; +import { USER_ACCOUNT } from "src/api/constants"; +import DeleteLcr from "./delete"; + +export const Lcrs = () => { + const user = useSelectState("user"); + useScopedRedirect( + Scope.admin, + `${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`, + user, + "You do not have permissions to manage all outbound call routes" + ); + const [lcrs, refetch] = useApiData("Lcrs"); + const [filter, setFilter] = useState(""); + const [accountSid, setAccountSid] = useState(""); + + const currentServiceProvider = useSelectState("currentServiceProvider"); + const [lcr, setLcr] = useState(); + const [accounts] = useServiceProviderData("Accounts"); + + const lcrsFiltered = useMemo(() => { + if (user?.account_sid && user?.scope === USER_ACCOUNT) { + setAccountSid(user?.account_sid); + return lcrs; + } + + return lcrs + ? lcrs.filter((lcr) => + accountSid + ? lcr.account_sid === accountSid + : currentServiceProvider?.service_provider_sid + ? lcr.service_provider_sid == + currentServiceProvider.service_provider_sid + : lcr.account_sid === null + ) + : []; + }, [accountSid, lcrs]); + const filteredLcrs = useFilteredResults(filter, lcrsFiltered); + + const handleDelete = () => { + if (lcr) { + deleteLcr(lcr.lcr_sid || "") + .then(() => { + toastSuccess( + <> + Deleted outbound call route {lcr?.name} + + ); + setLcr(null); + refetch(); + }) + .catch((error) => { + toastError(error.msg); + }); + } + }; + + return ( + <> +
    +

    Outbound call routing

    + + {" "} + + + + +
    +
    + + + + +
    +
    +
    + {!hasValue(filteredLcrs) && hasLength(accounts) ? ( + + ) : hasLength(filteredLcrs) ? ( + filteredLcrs.map((lcr) => ( +
    +
    +
    + + + {lcr.name} + + + +
    +
    +
    +
    + {lcr.is_active ? ( + + ) : ( + + )} + {lcr.is_active ? "Active" : "Inactive"} +
    +
    +
    +
    + + + {lcr.account_sid + ? accounts?.find( + (acct) => acct.account_sid === lcr.account_sid + )?.name + : currentServiceProvider?.name} + +
    +
    +
    +
    + + {`${ + lcr.number_routes && lcr.number_routes > 1 + ? lcr.number_routes - 1 + : 0 + } routes`} +
    +
    +
    +
    + +
    + + + + +
    +
    +
    + )) + ) : ( + No outbound call routes. + )} +
    +
    +
    + +
    + {lcr && ( + setLcr(null)} + handleSubmit={handleDelete} + /> + )} + + ); +}; + +export default Lcrs; diff --git a/src/containers/internal/views/least-cost-routing/styles.scss b/src/containers/internal/views/least-cost-routing/styles.scss new file mode 100644 index 0000000..a099b0c --- /dev/null +++ b/src/containers/internal/views/least-cost-routing/styles.scss @@ -0,0 +1,17 @@ +.lcr-card { + background-color: white; + cursor: pointer; +} + +.lcr-card:hover { + box-shadow: -7px 7px 5px #d5d7db, -5px -5px 10px #ffffff; + transform: translateY(-3px) translateX(-3px); +} + +.lcr-card-appear { + opacity: 1; +} + +.lcr-card-disappear { + opacity: 0; +} diff --git a/src/router/index.tsx b/src/router/index.tsx index 9620994..a35e76a 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -37,6 +37,9 @@ import PhoneNumbersEdit from "src/containers/internal/views/phone-numbers/edit"; import MSTeamsTenants from "src/containers/internal/views/ms-teams-tenants"; import MSTeamsTenantsAdd from "src/containers/internal/views/ms-teams-tenants/add"; import MSTeamsTenantsEdit from "src/containers/internal/views/ms-teams-tenants/edit"; +import Lcrs from "src/containers/internal/views/least-cost-routing"; +import LcrsAdd from "src/containers/internal/views/least-cost-routing/add"; +import LcrsEdit from "src/containers/internal/views/least-cost-routing/edit"; export const Router = () => { const toast = useSelectState("toast"); @@ -123,6 +126,12 @@ export const Router = () => { path="ms-teams-tenants/:ms_teams_tenant_sid/edit" element={} /> + } /> + } /> + } + /> {/* 404 page not found */} } /> diff --git a/src/router/routes.ts b/src/router/routes.ts index 23be234..68c3b21 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -10,3 +10,4 @@ export const ROUTE_INTERNAL_CARRIERS = "/internal/carriers"; export const ROUTE_INTERNAL_SPEECH = "/internal/speech-services"; export const ROUTE_INTERNAL_PHONE_NUMBERS = "/internal/phone-numbers"; export const ROUTE_INTERNAL_MS_TEAMS_TENANTS = "/internal/ms-teams-tenants"; +export const ROUTE_INTERNAL_LEST_COST_ROUTING = "/internal/least-cost-routing"; diff --git a/src/store/actions.ts b/src/store/actions.ts index 668efb7..b02db80 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,10 +1,10 @@ -import { getServiceProviders } from "src/api"; +import { getLcrs, getServiceProviders } from "src/api"; import { sortLocaleName } from "src/utils"; import { getToken, parseJwt } from "src/router/auth"; import type { State, Action, UserData } from "./types"; import { Scope } from "./types"; -import { ServiceProvider } from "src/api/types"; +import { Lcr, ServiceProvider } from "src/api/types"; /** A generic action assumes action.type is ALWAYS our state key */ /** Since this is how we're designing our state interface we cool */ @@ -76,3 +76,11 @@ export const serviceProvidersAsyncAction = async (): Promise< const response = await getServiceProviders(); return response.json; }; + +export const lcrAsyncAction = async (): Promise => { + const { json } = await getLcrs(); + if (json && json.length > 0) { + return json[0]; + } + return {} as Lcr; +}; diff --git a/src/store/index.tsx b/src/store/index.tsx index 8fb0722..0edf4c8 100644 --- a/src/store/index.tsx +++ b/src/store/index.tsx @@ -7,6 +7,7 @@ import { serviceProvidersAction, serviceProvidersAsyncAction, currentServiceProviderAction, + lcrAsyncAction, } from "./actions"; import type { @@ -36,12 +37,14 @@ export const initialState: State = { const reducer: React.Reducer> = (state, action) => { switch (action.type) { case "user": + case "lcr": case "toast": return genericAction(state, action); case "serviceProviders": return serviceProvidersAction(state, action); case "currentServiceProvider": return currentServiceProviderAction(state, action); + default: throw new Error(); } @@ -67,6 +70,10 @@ const middleware: MiddleWare = (dispatch) => { return userAsyncAction().then((payload) => { dispatch({ ...action, payload }); }); + case "lcr": + return lcrAsyncAction().then((payload) => { + dispatch({ ...action, payload }); + }); case "serviceProviders": return serviceProvidersAsyncAction().then((payload) => { dispatch({ ...action, payload }); diff --git a/src/store/types.ts b/src/store/types.ts index 14ddf24..1446a1e 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,6 @@ import React from "react"; -import type { UserJWT, ServiceProvider } from "src/api/types"; +import type { UserJWT, ServiceProvider, Lcr } from "src/api/types"; export type IMessage = string | JSX.Element; @@ -39,6 +39,8 @@ export interface State { accessControl: ACL; /** available service providers */ serviceProviders: ServiceProvider[]; + /** Least route routing */ + lcr?: Lcr; /** current selected service provider */ currentServiceProvider?: ServiceProvider; } diff --git a/src/styles/_forms.scss b/src/styles/_forms.scss index 9cb7209..8e3e473 100644 --- a/src/styles/_forms.scss +++ b/src/styles/_forms.scss @@ -268,3 +268,22 @@ fieldset { } } } + +.lcr { + @extend .gateway; + + > div { + display: grid; + grid-gap: ui-vars.$px02; + align-items: center; + margin-left: 3%; + + &:nth-child(1) { + grid-template-columns: [col] calc(50% - #{ui-vars.$px02 * 2}) [col] 50%; + + @include mixins.small() { + grid-template-columns: [col] 100%; + } + } + } +}