feat: Lcr (#237)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix: final review version

* fix: review comments

* fix: review comments

* fix: review comments

* fix: review comments

* wip: implement drag and drop

* add box shadow for lcr route

* fix review comment
This commit is contained in:
Hoan Luu Huu
2023-05-05 06:54:46 +07:00
committed by GitHub
parent dbb39db54e
commit a6c8257b60
25 changed files with 1556 additions and 23 deletions
+3 -1
View File
@@ -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
#VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING=true
## disables Least cost routing feature
#VITE_APP_LCR_DISABLED=true
+176 -13
View File
@@ -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"
},
+5 -1
View File
@@ -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",
+8 -1
View File
@@ -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`;
+74
View File
@@ -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<SystemInformation>) => {
payload
);
};
export const postLcr = (payload: Partial<Lcr>) => {
return postFetch<SidResponse, Partial<Lcr>>(API_LCRS, payload);
};
export const postLcrRoute = (payload: Partial<LcrRoute>) => {
return postFetch<SidResponse, Partial<LcrRoute>>(API_LCR_ROUTES, payload);
};
export const postLcrCarrierSetEntry = (
payload: Partial<LcrCarrierSetEntry>
) => {
return postFetch<SidResponse, Partial<LcrCarrierSetEntry>>(
API_LCR_CARRIER_SET_ENTRIES,
payload
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
@@ -452,6 +475,27 @@ export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
);
};
export const putLcr = (sid: string, payload: Partial<Lcr>) => {
return putFetch<EmptyResponse, Partial<Lcr>>(`${API_LCRS}/${sid}`, payload);
};
export const putLcrRoutes = (sid: string, payload: Partial<LcrRoute>) => {
return putFetch<EmptyResponse, Partial<LcrRoute>>(
`${API_LCR_ROUTES}/${sid}`,
payload
);
};
export const putLcrCarrierSetEntries = (
sid: string,
payload: Partial<LcrCarrierSetEntry>
) => {
return putFetch<EmptyResponse, Partial<LcrCarrierSetEntry>>(
`${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<EmptyResponse>(`${API_LCRS}/${sid}`);
};
export const deleteLcrRoute = (sid: string) => {
return deleteFetch<EmptyResponse>(`${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<Lcr[]>(API_LCRS);
};
export const getLcr = (sid: string) => {
return getFetch<Lcr>(`${API_LCRS}/${sid}`);
};
export const getLcrRoutes = (sid: string) => {
return getFetch<LcrRoute[]>(`${API_LCR_ROUTES}?lcr_sid=${sid}`);
};
export const getLcrRoute = (sid: string) => {
return getFetch<LcrRoute>(`${API_LCR_ROUTES}/${sid}`);
};
export const getLcrCarrierSetEtries = (sid: string) => {
return getFetch<LcrCarrierSetEntry[]>(
`${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<CallQuery>) => {
+29
View File
@@ -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;
+6
View File
@@ -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,
};
+1
View File
@@ -34,3 +34,4 @@ export const MSG_WEBHOOK_FIELDS = (
<span>password</span> fields are required.
</>
);
export const NOT_AVAILABLE_PREFIX = "NotAvalable";
+7 -2
View File
@@ -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 (
<li>
<Link
to={item.route(user)}
to={item.route(user, lcr)}
className={classNames({ navi__link: true, "txt--jean": true, active })}
onClick={handleMenu}
>
@@ -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 = ({
<Item
key={item.label}
user={user}
lcr={lcr}
item={item}
handleMenu={handleMenu}
/>
+21 -1
View File
@@ -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,
]
: []),
];
@@ -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
@@ -0,0 +1,15 @@
import React from "react";
import { H1 } from "@jambonz/ui-kit";
import { LcrForm } from "./form";
export const AddLcr = () => {
return (
<>
<H1 className="h2">Add outbound call routes</H1>
<LcrForm />
</>
);
};
export default AddLcr;
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className={`lcr lcr--route lcr-card lcr-card-${
isDragging ? "disappear" : "appear"
}`}
handler-id={handlerId}
>
<div>
<input
id={`lcr_route_regex_${index}`}
name={`lcr_route_regex_${index}`}
type="text"
placeholder="Digit prefix or regex"
required
value={lr.regex || ""}
onChange={(e) => {
updateLcrRoute(index, "regex", e.target.value);
}}
/>
<Selector
id={`lcr_carrier_set_entry_carrier_${index}`}
name={`lcr_carrier_set_entry_carrier_${index}`}
placeholder="Carrier"
value={
lr.lcr_carrier_set_entries && lr.lcr_carrier_set_entries.length > 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
);
}}
/>
</div>
<button
className="btnty btn__delete"
title="Delete route"
type="button"
onClick={() => handleRouteDelete(lr, index)}
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
);
};
export default Card;
@@ -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<React.SetStateAction<LcrRoute[]>>];
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) => (
<Card
key={lr.lcr_route_sid}
lr={lr}
index={i}
moveCard={moveCard}
updateLcrRoute={updateLcrRoute}
updateLcrCarrierSetEntries={updateLcrCarrierSetEntries}
handleRouteDelete={handleRouteDelete}
carrierSelectorOptions={carrierSelectorOptions}
/>
))}
</>
);
};
export default Container;
@@ -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 (
<>
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
<P>
Are you sure you want to delete least cost routing{" "}
<strong>{lcr.name}</strong>?
</P>
</Modal>
</>
);
};
export default DeleteLcr;
@@ -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<Lcr>(
`Lcrs/${params.lcr_sid}`
);
const [lcrRouteData, lcrRouteRefect, lcrRouteError] = useApiData<LcrRoute[]>(
`LcrRoutes?lcr_sid=${params.lcr_sid}`
);
return (
<>
<H1 className="h2">Edit outbound call routes</H1>
<LcrForm
lcrDataMap={{ data: lcrData, refetch: lcrRefect, error: lcrError }}
lcrRouteDataMap={{
data: lcrRouteData,
refetch: lcrRouteRefect,
error: lcrRouteError,
}}
/>
</>
);
};
export default EditLcr;
@@ -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<Lcr>;
lcrRouteDataMap?: UseApiDataMap<LcrRoute[]>;
};
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<string | null>();
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<LcrRoute[]>([LCR_ROUTE_TEMPLATE]);
const [previousLcrRoutes, setPreviousLcrRoutes] = useState<LcrRoute[]>([
LCR_ROUTE_TEMPLATE,
]);
const [previouseLcr, setPreviousLcr] = useState<Lcr | null>();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [lcrForDelete, setLcrForDelete] = useState<Lcr | null>();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [carriers] = useApiData<Carrier[]>(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<string> => {
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<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
...entry,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
lcr_route_sid,
};
return new Promise<string>(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<string> => {
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<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
lcr_route_sid,
workload: entry.workload,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
priority: entry.priority,
};
return new Promise<string>(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 <strong>{lcrForDelete?.name}</strong>
</>
);
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 (
<>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}
</fieldset>
<fieldset>
<div className="multi">
<div className="inp">
<label htmlFor="lcr_name">Name</label>
<input
id="lcr_name"
name="lcr_name"
type="text"
placeholder="name"
value={lcrName}
onChange={(e) => setLcrName(e.target.value)}
/>
</div>
</div>
<label htmlFor="is_active" className="chk">
<input
id="is_active"
name="is_active"
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<div>Active</div>
</label>
<div className="sel sel--preset">
<label htmlFor="predefined_select">
Select a default outbound carrier<span>*</span>
</label>
<Selector
id="defailt_carrier"
name="defailt_carrier"
value={defaultLcrCarrier}
options={carrierSelectorOptions}
required
onChange={(e) => {
setDefaultLcrCarrier(e.target.value);
}}
/>
</div>
</fieldset>
{user?.scope === USER_ADMIN && (
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
label="Used by"
required={false}
defaultOption={true}
disabled={lcrDataMap !== undefined}
/>
</fieldset>
)}
<fieldset>
<label htmlFor="lcr_route">
Route based on first match<span>*</span>
</label>
<MXS>
<em>Drag and drop to rearrange the order.</em>
</MXS>
<label htmlFor="sip_gateways">Digit pattern / Carrier</label>
<DndProvider backend={HTML5Backend}>
<Container
lcrRoute={[lcrRoutes, setLcrRoutes]}
carrierSelectorOptions={carrierSelectorOptions}
/>
</DndProvider>
<ButtonGroup left>
<button
className="btnty"
type="button"
title="Add route"
onClick={() => {
addLcrRoutes();
}}
>
<Icon subStyle="teal">
<Icons.Plus />
</Icon>
</button>
</ButtonGroup>
</fieldset>
<fieldset>
<div className="grid grid--col3">
<div className="grid__row">
<div>
<ButtonGroup left>
{user?.access === Scope.admin && (
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_LEST_COST_ROUTING}
>
Cancel
</Button>
)}
<Button
type="submit"
small
disabled={carrierSelectorOptions.length === 0}
>
Save
</Button>
</ButtonGroup>
</div>
<div />
<div>
{user?.scope !== USER_ADMIN &&
lcrDataMap &&
lcrDataMap.data &&
lcrDataMap.data.lcr_sid && (
<ButtonGroup right>
<Button
type="button"
small
subStyle="grey"
onClick={() => {
setLcrForDelete(lcrDataMap.data);
}}
>
Delete
</Button>
</ButtonGroup>
)}
</div>
</div>
</div>
</fieldset>
</form>
</Section>
{lcrForDelete && (
<DeleteLcr
lcr={lcrForDelete}
handleCancel={() => setLcrForDelete(null)}
handleSubmit={handleDelete}
/>
)}
</>
);
};
export default LcrForm;
@@ -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<Lcr[]>("Lcrs");
const [filter, setFilter] = useState("");
const [accountSid, setAccountSid] = useState("");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [lcr, setLcr] = useState<Lcr | null>();
const [accounts] = useServiceProviderData<Account[]>("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<Lcr>(filter, lcrsFiltered);
const handleDelete = () => {
if (lcr) {
deleteLcr(lcr.lcr_sid || "")
.then(() => {
toastSuccess(
<>
Deleted outbound call route <strong>{lcr?.name}</strong>
</>
);
setLcr(null);
refetch();
})
.catch((error) => {
toastError(error.msg);
});
}
};
return (
<>
<section className="mast">
<H1 className="h2">Outbound call routing</H1>
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}
title="Add a Least cost routing"
>
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
</section>
<section className="filters filters--spaced">
<SearchFilter placeholder="Filter lcrs" filter={[filter, setFilter]} />
<ScopedAccess user={user} scope={Scope.admin}>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
label="Used by"
defaultOption
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredLcrs) && { slim: true })}>
<div className="list">
{!hasValue(filteredLcrs) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredLcrs) ? (
filteredLcrs.map((lcr) => (
<div className="item" key={lcr.lcr_sid}>
<div className="item__info">
<div className="item__title">
<ScopedAccess
user={user}
scope={
!lcr.account_sid
? Scope.service_provider
: Scope.account
}
>
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
title="Edit outbound call routes"
className="i"
>
<strong>{lcr.name}</strong>
<Icons.ArrowRight />
</Link>
</ScopedAccess>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${lcr.is_active ? "teal" : "grey"}`}
>
{lcr.is_active ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>{lcr.is_active ? "Active" : "Inactive"}</span>
</div>
</div>
<div>
<div className={`i txt--teal`}>
<Icons.Activity />
<span>
{lcr.account_sid
? accounts?.find(
(acct) => acct.account_sid === lcr.account_sid
)?.name
: currentServiceProvider?.name}
</span>
</div>
</div>
<div>
<div className={`i txt--teal`}>
<Icons.Share2 />
<span>{`${
lcr.number_routes && lcr.number_routes > 1
? lcr.number_routes - 1
: 0
} routes`}</span>
</div>
</div>
</div>
</div>
<ScopedAccess
user={user}
scope={
!lcr.account_sid ? Scope.service_provider : Scope.account
}
>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
title="Edit carrier"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete outbound call route"
onClick={() => setLcr(lcr)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</ScopedAccess>
</div>
))
) : (
<M>No outbound call routes.</M>
)}
</div>
</Section>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}>
Add outbound call routes
</Button>
</Section>
{lcr && (
<DeleteLcr
lcr={lcr}
handleCancel={() => setLcr(null)}
handleSubmit={handleDelete}
/>
)}
</>
);
};
export default Lcrs;
@@ -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;
}
+9
View File
@@ -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={<MSTeamsTenantsEdit />}
/>
<Route path="least-cost-routing" element={<Lcrs />} />
<Route path="least-cost-routing/add" element={<LcrsAdd />} />
<Route
path="least-cost-routing/:lcr_sid/edit"
element={<LcrsEdit />}
/>
{/* 404 page not found */}
<Route path="*" element={<NotFound />} />
+1
View File
@@ -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";
+10 -2
View File
@@ -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<Lcr> => {
const { json } = await getLcrs();
if (json && json.length > 0) {
return json[0];
}
return {} as Lcr;
};
+7
View File
@@ -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<keyof State>> = (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 });
+3 -1
View File
@@ -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;
}
+19
View File
@@ -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%;
}
}
}
}