diff --git a/Makefile b/Makefile index b7a936dc4..552ebe659 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,12 @@ force_run: rm -f tmp/pids/*.pid overmind start -f Procfile.dev +force_run_tunnel: + lsof -ti:3000 | xargs kill -9 2>/dev/null || true + rm -f ./.overmind.sock + rm -f tmp/pids/*.pid + overmind start -f Procfile.tunnel + debug: overmind connect backend @@ -53,4 +59,4 @@ debug_worker: docker: docker build -t $(APP_NAME) -f ./docker/Dockerfile . -.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker +.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker diff --git a/Procfile.tunnel b/Procfile.tunnel new file mode 100644 index 000000000..bf3541dd0 --- /dev/null +++ b/Procfile.tunnel @@ -0,0 +1,4 @@ +backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000 +# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 +worker: dotenv bundle exec sidekiq -C config/sidekiq.yml +vite: bin/vite build --watch diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b4d5e3fc1..4fbe50902 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] - before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] + before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @contacts_count = resolved_contacts.count @@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) @contacts_count = contacts.count - @contacts = contacts.page(@current_page) + @contacts = fetch_contacts(contacts) end def show; end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index c610dc846..4d675593f 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label) end def check_signup_enabled diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index b210dea19..d665b5684 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController # Overwrite any of the RESTful controller actions to implement custom behavior # For example, you may want to send an email after a foo is updated. # + + # Since account/user page - account user role attribute links to the show page + # Handle with a redirect to the user show page + def show + redirect_to super_admin_user_path(requested_resource.user) + end + def create resource = resource_class.new(resource_params) authorize_resource(resource) diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2eee3f484..025df2122 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -61,6 +61,11 @@ class ContactAPI extends ApiClient { return axios.get(requestURL); } + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; + return axios.get(requestURL); + } + // eslint-disable-next-line default-param-last filter(page = 1, sortAttr = 'name', queryPayload) { let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index b2b0dbfa0..0932a79c7 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -17,6 +17,7 @@ const props = defineProps({ additionalAttributes: { type: Object, default: () => ({}) }, phoneNumber: { type: String, default: '' }, thumbnail: { type: String, default: '' }, + availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, }); @@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id); @@ -87,7 +121,7 @@ const toggleSelected = option => { v-model="searchTerm" autofocus class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" - :placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')" + :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" /> diff --git a/app/javascript/dashboard/components-next/input/DurationInput.vue b/app/javascript/dashboard/components-next/input/DurationInput.vue index 15c6d102a..802e1d500 100644 --- a/app/javascript/dashboard/components-next/input/DurationInput.vue +++ b/app/javascript/dashboard/components-next/input/DurationInput.vue @@ -1,7 +1,8 @@ diff --git a/app/javascript/dashboard/components-next/input/constants.js b/app/javascript/dashboard/components-next/input/constants.js new file mode 100644 index 000000000..b8ed1d4a2 --- /dev/null +++ b/app/javascript/dashboard/components-next/input/constants.js @@ -0,0 +1,5 @@ +export const DURATION_UNITS = { + MINUTES: 'minutes', + HOURS: 'hours', + DAYS: 'days', +}; diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index dbb417533..5d7aac3c3 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -235,6 +235,12 @@ const menuItems = computed(() => { ), activeOn: ['contacts_dashboard_index', 'contacts_edit'], }, + { + name: 'Active', + label: t('SIDEBAR.ACTIVE'), + to: accountScopedRoute('contacts_dashboard_active'), + activeOn: ['contacts_dashboard_active'], + }, { name: 'Segments', icon: 'i-lucide-group', diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js b/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js index e4150d47d..e5eb204ee 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js @@ -9,6 +9,7 @@ const contacts = accountId => ({ 'contacts_edit', 'contacts_edit_segment', 'contacts_edit_label', + 'contacts_dashboard_active', ], menuItems: [ { @@ -18,6 +19,13 @@ const contacts = accountId => ({ toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toStateName: 'contacts_dashboard_index', }, + { + icon: 'visitor-contacts', + label: 'ACTIVE', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/contacts/active`), + toStateName: 'contacts_dashboard_active', + }, ], }); diff --git a/app/javascript/dashboard/composables/useAccount.js b/app/javascript/dashboard/composables/useAccount.js index 9a2ba012b..5a8441d33 100644 --- a/app/javascript/dashboard/composables/useAccount.js +++ b/app/javascript/dashboard/composables/useAccount.js @@ -45,9 +45,10 @@ export function useAccount() { }; }; - const updateAccount = async data => { + const updateAccount = async (data, options) => { await store.dispatch('accounts/update', { ...data, + options, }); }; diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 4c599ffe8..4f0a27cae 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -286,6 +286,7 @@ "HEADER": { "TITLE": "Contacts", "SEARCH_TITLE": "Search contacts", + "ACTIVE_TITLE": "Active contacts", "SEARCH_PLACEHOLDER": "Search...", "MESSAGE_BUTTON": "Message", "SEND_MESSAGE": "Send message", @@ -560,7 +561,8 @@ "SUBTITLE": "Start adding new contacts by clicking on the button below", "BUTTON_LABEL": "Add contact", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search πŸ”", - "LIST_EMPTY_STATE_TITLE": "No contacts available in this view πŸ“‹" + "LIST_EMPTY_STATE_TITLE": "No contacts available in this view πŸ“‹", + "ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment πŸŒ™" } }, diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index 7da76df18..d243c0583 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -46,7 +46,31 @@ }, "AUTO_RESOLVE": { "TITLE": "Auto-resolve conversations", - "NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below." + "NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.", + "DURATION": { + "LABEL": "Inactivity duration", + "HELP": "Time period of inactivity after which conversation is auto-resolved", + "PLACEHOLDER": "30", + "ERROR": "Auto resolve duration should be between 10 minutes and 999 days", + "API": { + "SUCCESS": "Auto resolve settings updated successfully", + "ERROR": "Failed to update auto resolve settings" + } + }, + "MESSAGE": { + "LABEL": "Custom auto-resolution message", + "PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity", + "HELP": "Message sent to the customer after conversation is auto-resolved" + }, + "PREFERENCES": "Preferences", + "LABEL": { + "LABEL": "Add label after auto-resolution", + "PLACEHOLDER": "Select a label" + }, + "IGNORE_WAITING": { + "LABEL": "Skip conversations waiting for agent’s reply" + }, + "UPDATE_BUTTON": "Save Changes" }, "NAME": { "LABEL": "Account name", @@ -68,24 +92,6 @@ "PLACEHOLDER": "Your company's support email", "ERROR": "" }, - "AUTO_RESOLVE_IGNORE_WAITING": { - "LABEL": "Exclude unattended conversations", - "HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent’s reply." - }, - "AUTO_RESOLVE_DURATION": { - "LABEL": "Inactivity duration for resolution", - "HELP": "Duration after a conversation should auto resolve if there is no activity", - "PLACEHOLDER": "30", - "ERROR": "Auto resolve duration should be between 10 minutes and 999 days", - "API": { - "SUCCESS": "Auto resolve settings updated successfully", - "ERROR": "Failed to update auto resolve settings" - }, - "UPDATE_BUTTON": "Update", - "MESSAGE_LABEL": "Custom resolution message", - "MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity", - "MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity." - }, "FEATURES": { "INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.", "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now." diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 81b9c8a78..219041d75 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -286,6 +286,7 @@ "REPORTS": "Reports", "SETTINGS": "Settings", "CONTACTS": "Contacts", + "ACTIVE": "Active", "CAPTAIN": "Captain", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue index 76ae7eb4e..33a3d4fd5 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue @@ -67,9 +67,11 @@ const hasContacts = computed(() => contacts.value.length > 0); const isContactIndexView = computed( () => route.name === 'contacts_dashboard_index' && pageNumber.value === 1 ); +const isActiveView = computed(() => route.name === 'contacts_dashboard_active'); const hasAppliedFilters = computed(() => { return appliedFilters.value.length > 0; }); + const showEmptyStateLayout = computed(() => { return ( !searchQuery.value && @@ -89,11 +91,20 @@ const showEmptyText = computed(() => { const headerTitle = computed(() => { if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE'); + if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE'); if (activeSegmentId.value) return activeSegment.value?.name; if (activeLabel.value) return `#${activeLabel.value}`; return t('CONTACTS_LAYOUT.HEADER.TITLE'); }); +const emptyStateMessage = computed(() => { + if (isActiveView.value) + return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE'); + if (!searchQuery.value || hasAppliedFilters.value) + return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE'); + return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE'); +}); + const updatePageParam = (page, search = '') => { const query = { ...route.query, @@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => { updatePageParam(page); }; +const fetchActiveContacts = async (page = 1) => { + await store.dispatch('contacts/clearContactFilters'); + await store.dispatch('contacts/active', { + page, + sortAttr: buildSortAttr(), + }); + updatePageParam(page); +}; + const searchContacts = debounce(async (value, page = 1) => { await store.dispatch('contacts/clearContactFilters'); searchValue.value = value; @@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => { } // Reset the search value when we change the view searchValue.value = ''; + // If we're on the active route, fetch active contacts + if (isActiveView.value) { + await fetchActiveContacts(page); + return; + } // If there are applied filters or active segment with query if ( (hasAppliedFilters.value || activeSegment.value?.query) && @@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => { return; } + if (isActiveView.value) { + await fetchActiveContacts(); + return; + } + await (activeSegmentId.value || hasAppliedFilters.value ? fetchSavedOrAppliedFilteredContact( activeSegmentId.value @@ -210,7 +240,7 @@ watch( ); watch( - [activeLabel, activeSegment], + [activeLabel, activeSegment, isActiveView], () => { fetchContactsBasedOnContext(pageNumber.value); }, @@ -222,6 +252,13 @@ watch(searchQuery, value => { searchValue.value = value || ''; // Reset the view if there is search query when we click on the sidebar group if (value === undefined) { + if ( + isActiveView.value || + activeLabel.value || + activeSegment.value || + hasAppliedFilters.value + ) + return; fetchContacts(); } }); @@ -232,6 +269,10 @@ onMounted(async () => { await searchContacts(searchQuery.value, pageNumber.value); return; } + if (isActiveView.value) { + await fetchActiveContacts(pageNumber.value); + return; + } await fetchContacts(pageNumber.value); } else if (activeSegment.value && activeSegmentId.value) { await fetchSavedOrAppliedFilteredContact( @@ -286,11 +327,7 @@ onMounted(async () => { class="flex items-center justify-center py-10" > - {{ - searchQuery || !hasAppliedFilters - ? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE') - : t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE') - }} + {{ emptyStateMessage }} diff --git a/app/javascript/dashboard/routes/dashboard/contacts/routes.js b/app/javascript/dashboard/routes/dashboard/contacts/routes.js index 3610117a5..27de6ed15 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/routes.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/routes.js @@ -32,6 +32,12 @@ export const routes = [ component: ContactsIndex, meta: commonMeta, }, + { + path: 'active', + name: 'contacts_dashboard_active', + component: ContactsIndex, + meta: commonMeta, + }, ], }, { diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue index 9816fc7e0..881ccb0e8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue @@ -1,35 +1,78 @@