diff --git a/src/assets/images/icon-menu-item-roles.svg b/src/assets/images/icon-menu-item-roles.svg
new file mode 100644
index 0000000..b3c8674
--- /dev/null
+++ b/src/assets/images/icon-menu-item-roles.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-role-fallback.svg b/src/assets/images/icon-role-fallback.svg
new file mode 100644
index 0000000..47be19a
--- /dev/null
+++ b/src/assets/images/icon-role-fallback.svg
@@ -0,0 +1,22 @@
+
diff --git a/src/assets/images/icon-search.svg b/src/assets/images/icon-search.svg
new file mode 100644
index 0000000..f694173
--- /dev/null
+++ b/src/assets/images/icon-search.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/src/components/Icons/Roles/index.jsx b/src/components/Icons/Roles/index.jsx
new file mode 100644
index 0000000..8ed4edb
--- /dev/null
+++ b/src/components/Icons/Roles/index.jsx
@@ -0,0 +1,29 @@
+import React from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import IconWrapper from "components/IconWrapper";
+import IconRoleManagement from "../../../assets/images/icon-menu-item-roles.svg";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a "role management" icon used in navigation menu.
+ *
+ * @param {Object} props component props
+ * @param {string} [props.className] class name added to root element
+ * @param {boolean} [props.isActive] a flag indicating whether the icon is active
+ * @returns {JSX.Element}
+ */
+const Roles = ({ className, isActive = false }) => (
+
+
+
+);
+
+Roles.propTypes = {
+ className: PT.string,
+ isActive: PT.bool,
+};
+
+export default Roles;
diff --git a/src/components/Icons/Roles/styles.module.scss b/src/components/Icons/Roles/styles.module.scss
new file mode 100644
index 0000000..8e4adfc
--- /dev/null
+++ b/src/components/Icons/Roles/styles.module.scss
@@ -0,0 +1,19 @@
+.container {
+ svg {
+ display: block;
+ width: auto;
+ height: 100%;
+
+ path {
+ fill: #7f7f7f;
+ }
+ }
+
+ &.isActive {
+ svg {
+ path {
+ fill: #06d6a0;
+ }
+ }
+ }
+}
diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx
index 1424f71..c3d6bab 100644
--- a/src/components/Sidebar/index.jsx
+++ b/src/components/Sidebar/index.jsx
@@ -5,6 +5,7 @@ import NavMenu from "components/NavMenu";
import styles from "./styles.module.scss";
import WorkPeriods from "components/Icons/WorkPeriods";
import Freelancers from "components/Icons/Freelancers";
+import Roles from "components/Icons/Roles";
import { APP_BASE_PATH } from "../../constants";
/**
@@ -38,6 +39,11 @@ const NAV_ITEMS = [
label: "Freelancers",
path: `${APP_BASE_PATH}/freelancers`,
},
+ {
+ icon: Roles,
+ label: "Roles",
+ path: `${APP_BASE_PATH}/roles`,
+ },
];
export default Sidebar;
diff --git a/src/components/SearchHandleField/index.jsx b/src/components/Typeahead/index.jsx
similarity index 89%
rename from src/components/SearchHandleField/index.jsx
rename to src/components/Typeahead/index.jsx
index cc6c3e2..9d71979 100644
--- a/src/components/SearchHandleField/index.jsx
+++ b/src/components/Typeahead/index.jsx
@@ -1,9 +1,9 @@
import React, { useCallback, useRef, useState } from "react";
import PT from "prop-types";
import cn from "classnames";
+import get from "lodash/get";
import throttle from "lodash/throttle";
import Select, { components } from "react-select";
-import { getMemberSuggestions } from "services/teams";
import { useUpdateEffect } from "utils/hooks";
import styles from "./styles.module.scss";
@@ -75,9 +75,11 @@ const selectComponents = {
* @param {function} [props.onInputChange] function called when input value changes
* @param {function} [props.onBlur] function called on input blur
* @param {string} props.value input value
+ * @param {function} props.getSuggestions the function to get suggestions
+ * @param {string} props.targetProp the target property of the returned object from getSuggestions
* @returns {JSX.Element}
*/
-const SearchHandleField = ({
+const Typeahead = ({
className,
id,
name,
@@ -87,6 +89,8 @@ const SearchHandleField = ({
onBlur,
placeholder,
value,
+ getSuggestions,
+ targetProp,
}) => {
const [inputValue, setInputValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
@@ -165,11 +169,15 @@ const SearchHandleField = ({
return;
}
setIsLoading(true);
- const options = await loadSuggestions(value);
+ setIsMenuOpen(true);
+ const options = await loadSuggestions(
+ getSuggestions,
+ value,
+ targetProp
+ );
if (!isChangeAppliedRef.current) {
setOptions(options);
setIsLoading(false);
- setIsMenuOpen(true);
}
},
300,
@@ -223,17 +231,17 @@ const SearchHandleField = ({
);
};
-const loadSuggestions = async (inputValue) => {
+const loadSuggestions = async (getSuggestions, inputValue, targetProp) => {
let options = [];
if (inputValue.length < 3) {
return options;
}
try {
- const res = await getMemberSuggestions(inputValue);
- const users = res.data.slice(0, 100);
+ const res = await getSuggestions(inputValue);
+ const items = res.data.slice(0, 100);
let match = null;
- for (let i = 0, len = users.length; i < len; i++) {
- let value = users[i].handle;
+ for (let i = 0, len = items.length; i < len; i++) {
+ let value = get(items[i], targetProp);
if (value === inputValue) {
match = { value, label: value };
} else {
@@ -250,7 +258,7 @@ const loadSuggestions = async (inputValue) => {
return options;
};
-SearchHandleField.propTypes = {
+Typeahead.propTypes = {
className: PT.string,
id: PT.string.isRequired,
size: PT.oneOf(["medium", "small"]),
@@ -260,6 +268,8 @@ SearchHandleField.propTypes = {
onBlur: PT.func,
placeholder: PT.string,
value: PT.oneOfType([PT.number, PT.string]),
+ getSuggestions: PT.func,
+ targetProp: PT.string,
};
-export default SearchHandleField;
+export default Typeahead;
diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/Typeahead/styles.module.scss
similarity index 100%
rename from src/components/SearchHandleField/styles.module.scss
rename to src/components/Typeahead/styles.module.scss
diff --git a/src/constants/index.js b/src/constants/index.js
index 0f93a11..1e2321f 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -8,6 +8,8 @@ export const WORK_PERIODS_PATH = `${APP_BASE_PATH}/work-periods`;
export const FREELANCERS_PATH = `${APP_BASE_PATH}/freelancers`;
+export const ROLES_PATH = `${APP_BASE_PATH}/roles`;
+
export const TAAS_BASE_PATH = "/taas";
export const ADMIN_ROLES = ["bookingmanager", "administrator"];
diff --git a/src/root.component.jsx b/src/root.component.jsx
index 017dd42..bd83804 100644
--- a/src/root.component.jsx
+++ b/src/root.component.jsx
@@ -5,10 +5,12 @@ import store from "store";
import { disableSidebarForRoute } from "@topcoder/micro-frontends-navbar-app";
import WorkPeriods from "routes/WorkPeriods";
import Freelancers from "routes/Freelancers";
+import Roles from "routes/Roles";
import {
APP_BASE_PATH,
FREELANCERS_PATH,
WORK_PERIODS_PATH,
+ ROLES_PATH,
} from "./constants";
import "styles/global.scss";
@@ -23,6 +25,7 @@ export default function Root() {
+
);
diff --git a/src/routes/Roles/components/RoleForm/index.jsx b/src/routes/Roles/components/RoleForm/index.jsx
new file mode 100644
index 0000000..93c9173
--- /dev/null
+++ b/src/routes/Roles/components/RoleForm/index.jsx
@@ -0,0 +1,412 @@
+/**
+ * Role Form
+ * Form component for role creation & edit operations.
+ */
+import React, { useState, useCallback } from "react";
+import { useSelector, useDispatch } from "react-redux";
+import cn from "classnames";
+import { getRolesModalRole, getRolesModalError } from "store/selectors/roles";
+import { setModalRole, setModalError } from "store/actions/roles";
+import { searchSkills } from "services/roles";
+import FallbackIcon from "../../../../assets/images/icon-role-fallback.svg";
+import Typeahead from "components/Typeahead";
+import IntegerField from "components/IntegerField";
+import IconArrowSmall from "components/Icons/ArrowSmall";
+import styles from "./styles.module.scss";
+
+function RoleForm() {
+ const dispatch = useDispatch();
+ const roleState = useSelector(getRolesModalRole);
+ const modalError = useSelector(getRolesModalError);
+ const [typeaheadInputValue, setTypeaheadInputValue] = useState("");
+ const [expandedRateIdx, setExpandedRateIdx] = useState(null);
+ const [error, setError] = useState(false);
+ const onImgError = useCallback(() => setError(true), []);
+
+ const onChange = useCallback(
+ (changes) => {
+ dispatch(setModalRole({ ...roleState, ...changes }));
+ if ("imageUrl" in changes) {
+ setError(false);
+ }
+ },
+ [dispatch, roleState]
+ );
+
+ const toggleRate = useCallback(
+ (index) => {
+ if (expandedRateIdx === index) {
+ // collapse
+ setExpandedRateIdx(null);
+ } else {
+ // expand
+ setExpandedRateIdx(index);
+ }
+ },
+ [expandedRateIdx]
+ );
+
+ const addSkill = useCallback(
+ (value) => {
+ if (value && !roleState.listOfSkills.includes(value)) {
+ dispatch(
+ setModalRole({
+ ...roleState,
+ listOfSkills: [...roleState.listOfSkills, value],
+ })
+ );
+ setTypeaheadInputValue("");
+ }
+ },
+ [dispatch, roleState]
+ );
+
+ // add new rates with initial values
+ const addRate = useCallback(() => {
+ dispatch(
+ setModalRole({
+ ...roleState,
+ rates: [
+ ...roleState.rates,
+ {
+ global: 0,
+ inCountry: 0,
+ offShore: 0,
+ niche: 0,
+ rate30Niche: 0,
+ rate30Global: 0,
+ rate30InCountry: 0,
+ rate30OffShore: 0,
+ rate20Niche: 0,
+ rate20Global: 0,
+ rate20InCountry: 0,
+ rate20OffShore: 0,
+ },
+ ],
+ })
+ );
+ }, [dispatch, roleState]);
+
+ const editRate = useCallback(
+ (index, changes) => {
+ dispatch(
+ setModalRole({
+ ...roleState,
+ rates: [
+ ...roleState.rates.slice(0, index),
+ { ...roleState.rates[index], ...changes },
+ ...roleState.rates.slice(index + 1),
+ ],
+ })
+ );
+ },
+ [dispatch, roleState]
+ );
+
+ const deleteRate = useCallback(
+ (index) => {
+ dispatch(
+ setModalRole({
+ ...roleState,
+ rates: [
+ ...roleState.rates.slice(0, index),
+ ...roleState.rates.slice(index + 1),
+ ],
+ })
+ );
+ setExpandedRateIdx(null);
+ },
+ [dispatch, roleState]
+ );
+
+ const removeSkill = useCallback(
+ (value) => {
+ dispatch(
+ setModalRole({
+ ...roleState,
+ listOfSkills: roleState.listOfSkills.filter((s) => s !== value),
+ })
+ );
+ },
+ [dispatch, roleState]
+ );
+
+ return (
+
+
+ {roleState.imageUrl && !error ? (
+

+ ) : (
+
+ )}
+ Role Icon Preview
+
+ {modalError && (
+
+ {modalError}
+
+
+ )}
+
+
+
+ {roleState.listOfSkills.length > 0 ? (
+ roleState.listOfSkills.map((s, i) => (
+
+ ))
+ ) : (
+
No skills added.
+ )}
+
+
+
+
# of Members |
+ onChange({ numberOfMembers: num })}
+ />
+
+
+
# of Available Members |
+ onChange({ numberOfMembersAvailable: num })}
+ />
+
+
+
Time to Candidate |
+ onChange({ timeToCandidate: num })}
+ />
+
+
+
Time to Interview |
+ onChange({ timeToInterview: num })}
+ />
+
+
+
+
+
+ );
+}
+
+export default RoleForm;
diff --git a/src/routes/Roles/components/RoleForm/styles.module.scss b/src/routes/Roles/components/RoleForm/styles.module.scss
new file mode 100644
index 0000000..aa64184
--- /dev/null
+++ b/src/routes/Roles/components/RoleForm/styles.module.scss
@@ -0,0 +1,217 @@
+@import "styles/mixins";
+@import "styles/variables";
+
+.form {
+ display: flex;
+ flex-direction: column;
+
+ .modal-error {
+ position: relative;
+ background-color: $error-color;
+ color: white;
+ padding: 15px;
+ border-radius: 4px;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ button {
+ position: absolute;
+ color: white;
+ top: 5px;
+ right: 5px;
+ font-size: 16px;
+ font-weight: 500;
+ outline: none;
+ background: none;
+ border: none;
+ }
+ }
+
+ .two-col {
+ display: flex;
+ justify-content: space-between;
+ .left {
+ display: flex;
+ flex-direction: column;
+ width: 49.5%;
+ }
+ .right {
+ display: flex;
+ flex-direction: column;
+ width: 49.5%;
+ }
+ }
+
+ .input {
+ font-size: 16px;
+ line-height: 20px;
+ &::placeholder {
+ text-transform: none;
+ color: #aaa;
+ }
+ }
+
+ .preview {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 12px;
+ .role-icon {
+ width: 42px;
+ height: 42px;
+ object-fit: cover;
+ }
+ }
+
+ .skills {
+ margin: 10px 0;
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ list-style: none;
+ .skill {
+ width: fit-content;
+ background-color: #e9e9e9;
+ border: none;
+ border-radius: 5px;
+ padding: 6px 9px;
+ margin-right: 6px;
+ margin-bottom: 10px;
+ font-size: 12px;
+ &:hover {
+ color: red;
+ cursor: pointer;
+ }
+ }
+ }
+
+ .table {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ .cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ th {
+ border-bottom: 1px;
+ }
+
+ .number {
+ width: 80px;
+ }
+ }
+ }
+
+ textarea {
+ height: 80px;
+ max-width: 100%;
+ min-width: 100%;
+ }
+
+ .rates {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 5px;
+ .rate {
+ display: flex;
+ width: 100%;
+ height: 30px;
+ border: 1px solid $control-border-color;
+ box-shadow: 0 0 0 1px #e9e9e9;
+ &:first-child {
+ border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+ }
+ &:nth-last-child(2) {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ }
+ &:not(:nth-last-child(2)) {
+ border-bottom: none;
+ }
+ align-items: center;
+ justify-content: space-between;
+ padding: 7px;
+ font-size: 15px;
+ &:hover {
+ cursor: pointer;
+ }
+
+ .arrow {
+ width: 20px;
+ height: 20px;
+ transition: transform 0.5s;
+ svg {
+ margin-top: 3px;
+ }
+ }
+ }
+
+ .rate-content {
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+ border: 1px solid $control-border-color;
+ &:nth-last-child(2) {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ }
+
+ th {
+ width: 100%;
+ text-align: center;
+ background-color: #e9e9e9;
+ border: 1px solid $control-border-color;
+ &:not(:last-child) {
+ border-right: none;
+ }
+ }
+
+ .cell {
+ width: 20%;
+ }
+
+ .col-group {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .content-group {
+ border-left: 1px solid $control-border-color;
+ border-right: 1px solid $control-border-color;
+ padding: 7px;
+ display: flex;
+ justify-content: space-between;
+ &:nth-last-child(2) {
+ border-bottom: 1px solid $control-border-color;
+ }
+ }
+ }
+
+ .link-button {
+ width: fit-content;
+ font-size: 14px;
+ line-height: 22px;
+ padding: 0;
+ outline: none;
+ background: none;
+ color: #0d61bf;
+ border: none;
+ text-align: left;
+
+ &.hover-red {
+ &:hover {
+ color: red;
+ }
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/src/routes/Roles/components/RoleItem/index.jsx b/src/routes/Roles/components/RoleItem/index.jsx
new file mode 100644
index 0000000..eaa0843
--- /dev/null
+++ b/src/routes/Roles/components/RoleItem/index.jsx
@@ -0,0 +1,51 @@
+/**
+ * Role Item
+ * An item for the Role List component.
+ * Shows an image and the name of the role, with additional controls for editing and deleting.
+ */
+import React, { useState, useCallback } from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import FallbackIcon from "../../../../assets/images/icon-role-fallback.svg";
+import styles from "./styles.module.scss";
+
+function RoleItem({ name, imageUrl, onEditClick, onDeleteClick }) {
+ const [error, setError] = useState(false);
+ const onImgError = useCallback(() => setError(true), []);
+
+ return (
+
+ {imageUrl && !error ? (
+

+ ) : (
+
+ )}
+
{name}
+
+
+
+
+
+ );
+}
+
+RoleItem.propTypes = {
+ name: PT.string,
+ imageUrl: PT.string,
+ onEditClick: PT.func,
+ onDeleteClick: PT.func,
+};
+
+export default RoleItem;
diff --git a/src/routes/Roles/components/RoleItem/styles.module.scss b/src/routes/Roles/components/RoleItem/styles.module.scss
new file mode 100644
index 0000000..eab3e72
--- /dev/null
+++ b/src/routes/Roles/components/RoleItem/styles.module.scss
@@ -0,0 +1,76 @@
+@import "styles/mixins";
+@import "styles/variables";
+
+.item {
+ border: 1px solid #d4d4d4;
+ border-radius: 5px;
+ padding: 12px 16px;
+ width: 212px;
+ height: 136px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ align-items: flex-start;
+
+ .role-icon {
+ width: 42px;
+ height: 42px;
+ margin-left: 8px;
+ object-fit: cover;
+ }
+
+ .item-text {
+ @include barlow-bold;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 20px;
+ text-transform: uppercase;
+ }
+
+ &:hover {
+ .controls {
+ visibility: visible;
+ }
+ }
+
+ .controls {
+ display: flex;
+ width: 40%;
+ justify-content: space-between;
+ visibility: hidden;
+
+ .button {
+ font-size: 14px;
+ line-height: 22px;
+ padding: 0;
+ outline: none;
+ background: none;
+ color: #0d61bf;
+ border: none;
+ text-align: left;
+
+ &.red {
+ color: $error-text-color;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
+
+@media screen and (max-width: 600px) {
+ .item {
+ width: 100%;
+ }
+}
+
+@media screen and (max-width: 768px) {
+ .item {
+ .controls {
+ visibility: visible;
+ width: 25%;
+ }
+ }
+}
diff --git a/src/routes/Roles/components/RoleList/index.jsx b/src/routes/Roles/components/RoleList/index.jsx
new file mode 100644
index 0000000..e49c5e9
--- /dev/null
+++ b/src/routes/Roles/components/RoleList/index.jsx
@@ -0,0 +1,69 @@
+/**
+ * Role List
+ * Lists all available roles.
+ * Allows filtering by name, editing & deleting roles.
+ */
+import React, { useState, useEffect } from "react";
+import PT from "prop-types";
+import { useSelector, useDispatch } from "react-redux";
+import {
+ getRoles,
+ getRolesError,
+ getRolesFilter,
+ getRolesIsLoading,
+} from "store/selectors/roles";
+import { loadRoles } from "store/thunks/roles";
+import LoadingIndicator from "components/LoadingIndicator";
+import ContentMessage from "components/ContentMessage";
+import RoleItem from "../RoleItem";
+import styles from "./styles.module.scss";
+
+function RoleList({ onEditClick, onDeleteClick }) {
+ const [filteredRoles, setFilteredRoles] = useState([]);
+ const roles = useSelector(getRoles);
+ const filter = useSelector(getRolesFilter);
+ const error = useSelector(getRolesError);
+ const isLoading = useSelector(getRolesIsLoading);
+ const dispatch = useDispatch();
+
+ // Load roles
+ useEffect(() => {
+ dispatch(loadRoles);
+ }, [dispatch]);
+
+ useEffect(() => {
+ if (filter !== "") {
+ setFilteredRoles(
+ roles.filter((r) => r.name.toLowerCase().includes(filter.toLowerCase()))
+ );
+ } else {
+ setFilteredRoles(roles);
+ }
+ }, [roles, filter]);
+
+ return isLoading || error ? (
+
+ ) : filteredRoles.length > 0 ? (
+
+ {filteredRoles.map((role) => (
+ onEditClick(role)}
+ onDeleteClick={() => onDeleteClick(role)}
+ />
+ ))}
+
+ ) : (
+ No role found.
+ );
+}
+
+RoleList.propTypes = {
+ onEditClick: PT.func,
+ onDeleteClick: PT.func,
+};
+
+export default RoleList;
diff --git a/src/routes/Roles/components/RoleList/styles.module.scss b/src/routes/Roles/components/RoleList/styles.module.scss
new file mode 100644
index 0000000..1c05060
--- /dev/null
+++ b/src/routes/Roles/components/RoleList/styles.module.scss
@@ -0,0 +1,10 @@
+.list {
+ margin-top: 12px;
+ display: grid;
+ grid-gap: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
+
+ .center {
+ left: 50%;
+ }
+}
diff --git a/src/routes/Roles/index.jsx b/src/routes/Roles/index.jsx
new file mode 100644
index 0000000..12e42ce
--- /dev/null
+++ b/src/routes/Roles/index.jsx
@@ -0,0 +1,217 @@
+import React, { useCallback, useState, useMemo } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import debounce from "lodash/debounce";
+import omit from "lodash/omit";
+import withAuthentication from "hoc/withAuthentication";
+import { setFilter, setIsModalOpen, setModalRole } from "store/actions/roles";
+import { createRole, deleteRole, updateRole } from "store/thunks/roles";
+import {
+ getRolesIsModalOpen,
+ getRolesIsModalLoading,
+ getRolesModalRole,
+} from "store/selectors/roles";
+import Content from "components/Content";
+import ContentBlock from "components/ContentBlock";
+import ContentHeader from "components/ContentHeader";
+import Page from "components/Page";
+import PageTitle from "components/PageTitle";
+import Sidebar from "components/Sidebar";
+import Button from "components/Button";
+import Modal from "components/Modal";
+import RoleList from "./components/RoleList";
+import RoleForm from "./components/RoleForm";
+import Spinner from "components/Spinner";
+import styles from "./styles.module.scss";
+import modalStyles from "components/Modal/styles.module.scss";
+
+/**
+ * Displays route component for Roles' route.
+ *
+ * @returns {JSX.Element}
+ */
+const Roles = () => {
+ const dispatch = useDispatch();
+ const isModalOpen = useSelector(getRolesIsModalOpen);
+ const isModalLoading = useSelector(getRolesIsModalLoading);
+ const [modalOperationType, setModalOperationType] = useState("CREATE");
+ const modalRole = useSelector(getRolesModalRole);
+ const onFilterChange = useCallback(
+ debounce(
+ (filter) => {
+ dispatch(setFilter(filter));
+ },
+ 300,
+ { leading: false }
+ ),
+ [dispatch]
+ );
+
+ const onCreateClick = useCallback(() => {
+ setModalOperationType("CREATE");
+ // role template with initial values
+ dispatch(
+ setModalRole({
+ listOfSkills: [],
+ rates: [],
+ numberOfMembers: 0,
+ numberOfMembersAvailable: 0,
+ timeToCandidate: 0,
+ timeToInterview: 0,
+ })
+ );
+ dispatch(setIsModalOpen(true));
+ }, [dispatch]);
+
+ const onEditClick = useCallback(
+ (role) => {
+ setModalOperationType("EDIT");
+ dispatch(setModalRole(role));
+ dispatch(setIsModalOpen(true));
+ },
+ [dispatch]
+ );
+
+ const onDeleteClick = useCallback(
+ (role) => {
+ setModalOperationType("DELETE");
+ dispatch(setModalRole(role));
+ dispatch(setIsModalOpen(true));
+ },
+ [dispatch]
+ );
+
+ const onModalApproveClick = useCallback(() => {
+ if (modalOperationType === "DELETE") {
+ dispatch(deleteRole(modalRole.id));
+ } else if (modalOperationType === "EDIT") {
+ dispatch(
+ updateRole(
+ modalRole.id,
+ omit(modalRole, [
+ "id",
+ "createdBy",
+ "updatedBy",
+ "createdAt",
+ "updatedAt",
+ ])
+ )
+ );
+ } else {
+ dispatch(createRole(modalRole));
+ }
+ }, [dispatch, modalOperationType, modalRole]);
+
+ const onModalDismissClick = useCallback(() => {
+ if (!isModalLoading) {
+ dispatch(setIsModalOpen(false));
+ }
+ }, [dispatch, isModalLoading]);
+
+ const modalTitle = useMemo(() => {
+ if (modalOperationType === "DELETE") {
+ return "Confirm Deletion";
+ } else if (modalOperationType === "CREATE") {
+ return "Create New Role";
+ } else {
+ return "Edit Role";
+ }
+ }, [modalOperationType]);
+
+ const modalButtonApproveTxt = useMemo(() => {
+ if (modalOperationType === "DELETE") {
+ return isModalLoading ? (
+
+ Deleting...
+
+ ) : (
+ "Delete"
+ );
+ } else if (modalOperationType === "CREATE") {
+ return isModalLoading ? (
+
+ Creating...
+
+ ) : (
+ "Create"
+ );
+ } else {
+ return isModalLoading ? (
+
+ Saving...
+
+ ) : (
+ "Save"
+ );
+ }
+ }, [isModalLoading, modalOperationType]);
+
+ const modalButtonApproveColor = useMemo(() => {
+ if (modalOperationType === "DELETE") {
+ return "warning";
+ } else {
+ return "primary";
+ }
+ }, [modalOperationType]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ onFilterChange(event.target.value)}
+ />
+
+
+
+
+
+
+
+ }
+ title={modalTitle}
+ isOpen={isModalOpen}
+ onDismiss={onModalDismissClick}
+ >
+ {modalOperationType === "DELETE" && (
+
+ Are you sure you want to delete the{" "}
+ {modalRole.name} role?
+
+ )}
+ {(modalOperationType === "EDIT" ||
+ modalOperationType === "CREATE") && }
+
+
+
+
+
+ );
+};
+
+export default withAuthentication(Roles);
diff --git a/src/routes/Roles/styles.module.scss b/src/routes/Roles/styles.module.scss
new file mode 100644
index 0000000..50b8e42
--- /dev/null
+++ b/src/routes/Roles/styles.module.scss
@@ -0,0 +1,46 @@
+.page {
+ padding: 25px;
+ display: flex;
+ flex-direction: column;
+ .header {
+ display: flex;
+ justify-content: space-between;
+ .input {
+ width: 300px;
+ line-height: 22px;
+ &::placeholder {
+ color: #aaaaaa;
+ }
+ &:not(:focus) {
+ background-image: url("../../assets/images/icon-search.svg");
+ background-repeat: no-repeat;
+ background-position: 10px center;
+ text-indent: 20px;
+ }
+ }
+ }
+}
+
+strong {
+ font-weight: 700;
+}
+
+.spinner-container {
+ display: flex;
+ align-items: center;
+ .spinner {
+ margin-right: 5px;
+ margin-top: 3px;
+ }
+}
+
+@media screen and (max-width: 600px) {
+ .page {
+ .header {
+ flex-direction: column;
+ .input {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx
index f75adae..a743bd0 100644
--- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx
+++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx
@@ -3,9 +3,10 @@ import { useDispatch, useSelector } from "react-redux";
import debounce from "lodash/debounce";
import PT from "prop-types";
import cn from "classnames";
+import { getMemberSuggestions } from "services/teams";
import Button from "components/Button";
import CheckboxList from "components/CheckboxList";
-import SearchHandleField from "components/SearchHandleField";
+import Typeahead from "components/Typeahead";
import SidebarSection from "components/SidebarSection";
import Toggle from "components/Toggle";
import { PAYMENT_STATUS, ALERT } from "constants/workPeriods";
@@ -97,12 +98,14 @@ const PeriodFilters = ({ className }) => {
onSubmit={preventDefault}
>
-
diff --git a/src/services/roles.js b/src/services/roles.js
new file mode 100644
index 0000000..3eda362
--- /dev/null
+++ b/src/services/roles.js
@@ -0,0 +1,94 @@
+import axios from "./axios";
+import config from "../../config";
+
+// skills cache
+let skills;
+
+/**
+ * Returns the list of roles.
+ *
+ * @return {Promise} Array of roles
+ */
+export function getRoles() {
+ return axios.get(`${config.API.V5}/taas-roles`);
+}
+
+/**
+ * Creates a role.
+ *
+ * @param {Object} body role body
+ * @return {Promise