Merge branch 'develop' into fix/CW-6944
This commit is contained in:
+2
-2
@@ -684,7 +684,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.5)
|
||||
rack (3.2.6)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -699,7 +699,7 @@ GEM
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_custom_attributes_definitions, except: [:create]
|
||||
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
label_title = @label.title
|
||||
account_id = Current.account.id
|
||||
label_deleted_at = Time.current
|
||||
|
||||
@label.destroy!
|
||||
Labels::RemoveAssociationsJob.perform_later(
|
||||
label_title: label_title,
|
||||
account_id: account_id,
|
||||
label_deleted_at: label_deleted_at
|
||||
)
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def render_create_error_not_confirmed
|
||||
render_error(
|
||||
:unauthorized,
|
||||
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
|
||||
error_code: 'user_not_confirmed'
|
||||
)
|
||||
end
|
||||
|
||||
def find_user_for_authentication
|
||||
return nil unless params[:email].present? && params[:password].present?
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
export const buildCompanyParams = (page, sort) => {
|
||||
let params = `page=${page}`;
|
||||
if (sort) {
|
||||
params = `${params}&sort=${sort}`;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const buildSearchParams = (query, page, sort) => {
|
||||
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
||||
if (sort) {
|
||||
params = `${params}&sort=${sort}`;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
const buildParams = params =>
|
||||
new URLSearchParams(
|
||||
Object.entries(params).filter(
|
||||
([key, value]) => value !== undefined && (value !== '' || key === 'q')
|
||||
)
|
||||
).toString();
|
||||
|
||||
class CompanyAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -24,14 +15,41 @@ class CompanyAPI extends ApiClient {
|
||||
|
||||
get(params = {}) {
|
||||
const { page = 1, sort = 'name' } = params;
|
||||
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
||||
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
search(query = '', page = 1, sort = 'name') {
|
||||
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
||||
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
listContacts(id, page = 1) {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
createContact(id, payload) {
|
||||
return axios.post(`${this.url}/${id}/contacts`, payload);
|
||||
}
|
||||
|
||||
removeContact(id, contactId) {
|
||||
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
|
||||
}
|
||||
|
||||
destroyCustomAttributes(id, customAttributes) {
|
||||
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
destroyAvatar(id) {
|
||||
return axios.delete(`${this.url}/${id}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CompanyAPI();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import companyAPI, {
|
||||
buildCompanyParams,
|
||||
buildSearchParams,
|
||||
} from '../companies';
|
||||
import companyAPI from '../companies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#CompanyAPI', () => {
|
||||
@@ -9,7 +6,6 @@ describe('#CompanyAPI', () => {
|
||||
expect(companyAPI).toBeInstanceOf(ApiClient);
|
||||
expect(companyAPI).toHaveProperty('get');
|
||||
expect(companyAPI).toHaveProperty('show');
|
||||
expect(companyAPI).toHaveProperty('create');
|
||||
expect(companyAPI).toHaveProperty('update');
|
||||
expect(companyAPI).toHaveProperty('delete');
|
||||
expect(companyAPI).toHaveProperty('search');
|
||||
@@ -32,111 +28,69 @@ describe('#CompanyAPI', () => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get with default params', () => {
|
||||
it('#get includes pagination and sorting params', () => {
|
||||
companyAPI.get({});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('#get with page and sort params', () => {
|
||||
companyAPI.get({ page: 2, sort: 'domain' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('#get with descending sort', () => {
|
||||
companyAPI.get({ page: 1, sort: '-created_at' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with query', () => {
|
||||
companyAPI.search('acme', 1, 'name');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with special characters in query', () => {
|
||||
it('#search encodes query params', () => {
|
||||
companyAPI.search('acme & co', 2, 'domain');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
||||
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with descending sort', () => {
|
||||
companyAPI.search('test', 1, '-created_at');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with empty query', () => {
|
||||
it('#search keeps empty query param for backend validation', () => {
|
||||
companyAPI.search('', 1, 'name');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildCompanyParams', () => {
|
||||
it('returns correct string with page only', () => {
|
||||
expect(buildCompanyParams(1)).toBe('page=1');
|
||||
});
|
||||
|
||||
it('returns correct string with page and sort', () => {
|
||||
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
||||
});
|
||||
|
||||
it('returns correct string with different page', () => {
|
||||
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
||||
});
|
||||
|
||||
it('returns correct string with descending sort', () => {
|
||||
expect(buildCompanyParams(1, '-created_at')).toBe(
|
||||
'page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string without sort parameter', () => {
|
||||
expect(buildCompanyParams(2, '')).toBe('page=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildSearchParams', () => {
|
||||
it('returns correct string with all parameters', () => {
|
||||
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
||||
'q=acme&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string with special characters', () => {
|
||||
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
||||
'q=acme%20%26%20co&page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string with empty query', () => {
|
||||
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
||||
});
|
||||
|
||||
it('returns correct string without sort parameter', () => {
|
||||
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
||||
});
|
||||
|
||||
it('returns correct string with descending sort', () => {
|
||||
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
||||
'q=company&page=3&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes special characters correctly', () => {
|
||||
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
||||
'q=test%40example.com&page=1&sort=name'
|
||||
);
|
||||
it('#destroyAvatar deletes the company avatar endpoint', () => {
|
||||
companyAPI.destroyAvatar(1);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/avatar'
|
||||
);
|
||||
});
|
||||
|
||||
it('#listContacts fetches company contacts', () => {
|
||||
companyAPI.listContacts(1, 2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts?page=2'
|
||||
);
|
||||
});
|
||||
|
||||
it('#searchContacts encodes contact search params', () => {
|
||||
companyAPI.searchContacts(1, 'jane & co', 3);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
|
||||
);
|
||||
});
|
||||
|
||||
it('#createContact links a contact to the company', () => {
|
||||
companyAPI.createContact(1, { contact_id: 2 });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts',
|
||||
{ contact_id: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeContact unlinks a contact from the company', () => {
|
||||
companyAPI.removeContact(1, 2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts/2'
|
||||
);
|
||||
});
|
||||
|
||||
it('#destroyCustomAttributes removes company custom attributes', () => {
|
||||
companyAPI.destroyCustomAttributes(1, ['plan']);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/destroy_custom_attributes',
|
||||
{ custom_attributes: ['plan'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+22
-30
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
@@ -12,9 +12,8 @@ const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
domain: { type: String, default: '' },
|
||||
contactsCount: { type: Number, default: 0 },
|
||||
description: { type: String, default: '' },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
updatedAt: { type: [String, Number], default: null },
|
||||
lastActivityAt: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showCompany']);
|
||||
@@ -27,15 +26,21 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
||||
|
||||
const avatarSource = computed(() => props.avatarUrl || null);
|
||||
|
||||
const formattedUpdatedAt = computed(() => {
|
||||
if (!props.updatedAt) return '';
|
||||
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
|
||||
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
|
||||
|
||||
const contactsCountLabel = computed(() =>
|
||||
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
|
||||
);
|
||||
|
||||
const formattedLastActivityAt = computed(() => {
|
||||
if (!props.lastActivityAt) return '';
|
||||
return dynamicTime(props.lastActivityAt);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout layout="row" @click="onClickViewDetails">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
|
||||
<Avatar
|
||||
:username="displayName"
|
||||
:src="avatarSource"
|
||||
@@ -51,42 +56,29 @@ const formattedUpdatedAt = computed(() => {
|
||||
{{ displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="domain && description"
|
||||
v-if="hasContacts"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
>
|
||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||
<span class="truncate">{{ domain }}</span>
|
||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||
{{ contactsCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center min-w-0">
|
||||
<span
|
||||
v-if="domain && !description"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
v-if="domain"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
|
||||
@click.stop
|
||||
>
|
||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||
<span class="truncate">{{ domain }}</span>
|
||||
</span>
|
||||
<span v-if="description" class="text-sm text-n-slate-11 truncate">
|
||||
{{ description }}
|
||||
</span>
|
||||
<div
|
||||
v-if="(description || domain) && contactsCount"
|
||||
class="w-px h-3 bg-n-slate-6"
|
||||
/>
|
||||
<span
|
||||
v-if="contactsCount"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
>
|
||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="updatedAt"
|
||||
v-if="lastActivityAt"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ formattedUpdatedAt }}
|
||||
{{ formattedLastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { ref, useSlots } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
breadcrumbItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const slots = useSlots();
|
||||
const isSidebarOpen = ref(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isSidebarOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#details-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
|
||||
:icon="
|
||||
isSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-details-sidebar-toggle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+13
-11
@@ -2,6 +2,7 @@
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||
import CompanyMoreActions from './components/CompanyMoreActions.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
@@ -11,7 +12,7 @@ defineProps({
|
||||
activeOrdering: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'update:sort']);
|
||||
const emit = defineEmits(['search', 'update:sort', 'create']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,22 +20,15 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||
<div class="flex items-center">
|
||||
<CompanySortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
@@ -49,6 +43,14 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-2">
|
||||
<CompanySortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<CompanyMoreActions @create="emit('create')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('COMPANIES.ACTIONS.CREATE'),
|
||||
action: 'create',
|
||||
value: 'create',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAction = ({ action }) => {
|
||||
if (action === 'create') emit('create');
|
||||
showActionsDropdown.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
|
||||
@click="showActionsDropdown = !showActionsDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+6
@@ -35,6 +35,10 @@ const sortMenus = [
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
|
||||
value: 'last_activity_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
@@ -101,6 +105,7 @@ const handleOrderChange = value => {
|
||||
:model-value="activeSort"
|
||||
:options="sortMenus"
|
||||
:label="activeSortLabel"
|
||||
sub-menu-position="left"
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +117,7 @@ const handleOrderChange = value => {
|
||||
:model-value="activeOrdering"
|
||||
:options="orderingMenus"
|
||||
:label="activeOrderingLabel"
|
||||
sub-menu-position="left"
|
||||
@update:model-value="handleOrderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,12 @@ defineProps({
|
||||
showPaginationFooter: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||
const emit = defineEmits([
|
||||
'update:currentPage',
|
||||
'update:sort',
|
||||
'search',
|
||||
'create',
|
||||
]);
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
@@ -31,6 +36,7 @@ const updateCurrentPage = page => {
|
||||
:active-ordering="activeOrdering"
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
@create="emit('create')"
|
||||
/>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
defineProps({
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const form = reactive({ name: '', domain: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.name.trim());
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = '';
|
||||
form.domain = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
emit('create', {
|
||||
name: form.name.trim(),
|
||||
domain: form.domain.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
resetForm();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef, onSuccess });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
overflow-y-auto
|
||||
@confirm="handleConfirm"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.CREATE.TITLE') }}
|
||||
</span>
|
||||
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
|
||||
:disabled="isLoading"
|
||||
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
|
||||
autofocus
|
||||
/>
|
||||
<Input
|
||||
v-model="form.domain"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
|
||||
:disabled="isLoading"
|
||||
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
|
||||
:disabled="isLoading"
|
||||
:max-length="280"
|
||||
class="w-full"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="link"
|
||||
type="reset"
|
||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<Button
|
||||
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
|
||||
color="blue"
|
||||
type="submit"
|
||||
:disabled="isFormInvalid || isLoading"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contacts: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
meta: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isBusy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'cancelContactSelection',
|
||||
'confirmContactSelection',
|
||||
'removeContact',
|
||||
'search',
|
||||
'selectContact',
|
||||
'update:currentPage',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContactId = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const hasContacts = computed(() => props.contacts.length > 0);
|
||||
const currentPage = computed(() => Number(props.meta?.page || 1));
|
||||
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
|
||||
const linkedContactIds = computed(
|
||||
() => new Set(props.contacts.map(contact => Number(contact.id)))
|
||||
);
|
||||
const showPaginationFooter = computed(
|
||||
() => hasContacts.value && totalContacts.value > props.contacts.length
|
||||
);
|
||||
|
||||
const openContact = contactId => {
|
||||
router.push({
|
||||
name: 'contacts_edit',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const contactMeta = contact =>
|
||||
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
|
||||
|
||||
const contactName = contact =>
|
||||
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.searchResults
|
||||
.filter(
|
||||
contact =>
|
||||
!contact.linkedToCurrentCompany &&
|
||||
!linkedContactIds.value.has(Number(contact.id))
|
||||
)
|
||||
.map(contact => ({
|
||||
value: contact.id,
|
||||
label: [contactName(contact), contact.email, contact.phoneNumber]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}))
|
||||
);
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (props.isSearching) {
|
||||
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
|
||||
}
|
||||
|
||||
return searchQuery.value.trim()
|
||||
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
|
||||
});
|
||||
|
||||
const selectedContactName = computed(() =>
|
||||
props.selectedContact ? contactName(props.selectedContact) : ''
|
||||
);
|
||||
|
||||
const selectedContactMeta = computed(() =>
|
||||
props.selectedContact ? contactMeta(props.selectedContact) : ''
|
||||
);
|
||||
|
||||
const selectedContactCompanyName = computed(
|
||||
() => props.selectedContact?.company?.name || ''
|
||||
);
|
||||
|
||||
const summaryRows = computed(() => [
|
||||
{
|
||||
key: 'company',
|
||||
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
|
||||
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
|
||||
avatarSrc: props.company.avatarUrl,
|
||||
primary: props.company.name || t('COMPANIES.UNNAMED'),
|
||||
secondary: props.company.domain,
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
|
||||
badge: selectedContactCompanyName.value
|
||||
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
|
||||
companyName: selectedContactCompanyName.value,
|
||||
})
|
||||
: '',
|
||||
avatarName: selectedContactName.value,
|
||||
avatarSrc: props.selectedContact?.thumbnail,
|
||||
primary: selectedContactName.value,
|
||||
secondary: selectedContactMeta.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const debouncedSearch = debounce(query => {
|
||||
emit('search', query);
|
||||
}, 300);
|
||||
|
||||
const handleSearch = query => {
|
||||
searchQuery.value = query;
|
||||
debouncedSearch(query.trim());
|
||||
};
|
||||
|
||||
const handleContactSelect = contactId => {
|
||||
const selectedContact = props.searchResults.find(
|
||||
contact => contact.id === Number(contactId)
|
||||
);
|
||||
|
||||
selectedContactId.value = null;
|
||||
if (selectedContact) {
|
||||
emit('selectContact', selectedContact);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
|
||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<ComboBox
|
||||
use-api-results
|
||||
:model-value="selectedContactId"
|
||||
:options="contactOptions"
|
||||
:disabled="isBusy"
|
||||
:empty-state="emptyState"
|
||||
:search-placeholder="
|
||||
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleContactSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="row in summaryRows"
|
||||
:key="row.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ row.label }}
|
||||
</label>
|
||||
<span
|
||||
v-if="row.badge"
|
||||
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ row.badge }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
|
||||
>
|
||||
<Avatar
|
||||
:name="row.avatarName"
|
||||
:src="row.avatarSrc"
|
||||
:size="32"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div class="flex flex-col w-full min-w-0 gap-1">
|
||||
<span
|
||||
class="text-sm leading-4 font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ row.primary }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.secondary"
|
||||
class="text-sm leading-4 truncate text-n-slate-11"
|
||||
>
|
||||
{{ row.secondary }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 mt-2">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
:disabled="isBusy"
|
||||
@click="emit('cancelContactSelection')"
|
||||
/>
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
|
||||
class="w-full"
|
||||
:is-loading="isBusy"
|
||||
:disabled="isBusy"
|
||||
@click="emit('confirmContactSelection')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
|
||||
</h4>
|
||||
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
|
||||
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading && !hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col divide-y divide-n-weak">
|
||||
<div
|
||||
v-for="contact in contacts"
|
||||
:key="contact.id"
|
||||
class="flex items-center gap-2 py-3 group/contact"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
|
||||
@click="openContact(contact.id)"
|
||||
>
|
||||
<Avatar
|
||||
:name="contactName(contact)"
|
||||
:src="contact.thumbnail"
|
||||
:size="32"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div class="min-w-0 space-y-0.5">
|
||||
<span
|
||||
class="text-sm font-medium leading-5 truncate text-n-slate-12"
|
||||
>
|
||||
{{ contactName(contact) }}
|
||||
</span>
|
||||
<p
|
||||
v-if="contactMeta(contact)"
|
||||
class="text-sm leading-5 truncate text-n-slate-11"
|
||||
>
|
||||
{{ contactMeta(contact) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-unlink"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
|
||||
:disabled="isBusy"
|
||||
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
|
||||
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
|
||||
@click.stop="emit('removeContact', contact.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
v-if="showPaginationFooter"
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalContacts"
|
||||
:items-per-page="15"
|
||||
class="!px-0 before:hidden"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
|
||||
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
|
||||
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
|
||||
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
|
||||
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await companiesStore.deleteCustomAttributes({
|
||||
id: props.companyId,
|
||||
customAttributes: [props.attribute.attributeKey],
|
||||
});
|
||||
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async value => {
|
||||
try {
|
||||
await companiesStore.update({
|
||||
id: props.companyId,
|
||||
customAttributes: {
|
||||
[props.attribute.attributeKey]: value,
|
||||
},
|
||||
});
|
||||
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const componentMap = {
|
||||
list: ListAttribute,
|
||||
checkbox: CheckboxAttribute,
|
||||
date: DateAttribute,
|
||||
default: OtherAttribute,
|
||||
};
|
||||
|
||||
const CurrentAttributeComponent = computed(
|
||||
() =>
|
||||
componentMap[props.attribute.attributeDisplayType] || componentMap.default
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
|
||||
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="CurrentAttributeComponent"
|
||||
:attribute="attribute"
|
||||
:is-editing-view="isEditingView"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
|
||||
|
||||
const customAttributes = computed(() => props.company?.customAttributes || {});
|
||||
const hasCompanyAttributes = computed(
|
||||
() => companyAttributes.value?.length > 0
|
||||
);
|
||||
|
||||
const processCompanyAttributes = (
|
||||
attributes,
|
||||
attributeValues,
|
||||
filterCondition
|
||||
) => {
|
||||
if (!attributes.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return attributes.reduce((result, attribute) => {
|
||||
const { attributeKey } = attribute;
|
||||
|
||||
if (filterCondition(attributeKey, attributeValues)) {
|
||||
result.push({
|
||||
...attribute,
|
||||
value: attributeValues[attributeKey] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const usedAttributes = computed(() =>
|
||||
processCompanyAttributes(
|
||||
companyAttributes.value,
|
||||
customAttributes.value,
|
||||
(key, values) => key in values
|
||||
)
|
||||
);
|
||||
|
||||
const unusedAttributes = computed(() =>
|
||||
processCompanyAttributes(
|
||||
companyAttributes.value,
|
||||
customAttributes.value,
|
||||
(key, values) => !(key in values)
|
||||
)
|
||||
);
|
||||
|
||||
const filteredUnusedAttributes = computed(() =>
|
||||
unusedAttributes.value.filter(attribute =>
|
||||
attribute.attributeDisplayName
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
|
||||
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
|
||||
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<CompanyCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
:key="`${company.id}-${attribute.id}`"
|
||||
is-editing-view
|
||||
:company-id="company.id"
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-n-slate-5" />
|
||||
<span class="text-sm font-medium text-n-slate-10">
|
||||
{{
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-n-slate-5" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
||||
<span
|
||||
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
|
||||
class="flex items-center justify-start h-11"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
|
||||
<CompanyCustomAttributeItem
|
||||
v-for="attribute in filteredUnusedAttributes"
|
||||
:key="`${company.id}-${attribute.id}`"
|
||||
:company-id="company.id"
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
const props = defineProps({
|
||||
company: { type: Object, default: () => ({}) },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const form = reactive({ name: '', domain: '', description: '' });
|
||||
const avatarPreviewUrl = ref('');
|
||||
const isUploadingAvatar = ref(false);
|
||||
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
const isUpdating = computed(() => uiFlags.value.updatingItem);
|
||||
const isAvatarBusy = computed(
|
||||
() =>
|
||||
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
|
||||
);
|
||||
|
||||
const displayName = computed(
|
||||
() => props.company?.name || t('COMPANIES.UNNAMED')
|
||||
);
|
||||
const avatarSource = computed(
|
||||
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
|
||||
);
|
||||
const isFormInvalid = computed(() => !form.name.trim());
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
form.name.trim() !== (props.company?.name || '').trim() ||
|
||||
form.domain.trim() !== (props.company?.domain || '').trim() ||
|
||||
form.description.trim() !== (props.company?.description || '').trim()
|
||||
);
|
||||
|
||||
const summary = computed(() => {
|
||||
const { createdAt, lastActivityAt } = props.company || {};
|
||||
return [
|
||||
createdAt &&
|
||||
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
|
||||
date: dynamicTime(createdAt),
|
||||
}),
|
||||
lastActivityAt &&
|
||||
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
|
||||
date: dynamicTime(lastActivityAt),
|
||||
}),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
});
|
||||
|
||||
const syncForm = company => {
|
||||
form.name = company?.name || '';
|
||||
form.domain = company?.domain || '';
|
||||
form.description = company?.description || '';
|
||||
};
|
||||
|
||||
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.company?.id,
|
||||
props.company?.name,
|
||||
props.company?.domain,
|
||||
props.company?.description,
|
||||
props.company?.avatarUrl,
|
||||
],
|
||||
() => {
|
||||
avatarPreviewUrl.value = '';
|
||||
syncForm(props.company);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleAvatarUpload = async ({ file, url }) => {
|
||||
avatarPreviewUrl.value = url;
|
||||
isUploadingAvatar.value = true;
|
||||
try {
|
||||
await companiesStore.update({ id: props.company.id, avatar: file });
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
|
||||
} catch {
|
||||
avatarPreviewUrl.value = '';
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
|
||||
} finally {
|
||||
isUploadingAvatar.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
try {
|
||||
await companiesStore.deleteCompanyAvatar(props.company.id);
|
||||
avatarPreviewUrl.value = '';
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCompany = async () => {
|
||||
const companyId = Number(props.company.id);
|
||||
|
||||
try {
|
||||
const updated = await companiesStore.update({
|
||||
id: companyId,
|
||||
name: form.name.trim(),
|
||||
domain: form.domain.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
if (!isCurrentCompany(companyId)) return;
|
||||
|
||||
syncForm(updated);
|
||||
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
if (!isCurrentCompany(companyId)) return;
|
||||
|
||||
syncForm(props.company);
|
||||
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.LOADING') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Avatar
|
||||
:name="displayName"
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
|
||||
<p
|
||||
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
|
||||
class="text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start w-full gap-6">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
|
||||
</span>
|
||||
|
||||
<div class="grid w-full gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
|
||||
:disabled="isUpdating"
|
||||
custom-input-class="h-8 !pt-1 !pb-1"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.domain"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
|
||||
:disabled="isUpdating"
|
||||
custom-input-class="h-8 !pt-1 !pb-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
|
||||
:disabled="isUpdating"
|
||||
:max-length="280"
|
||||
class="w-full"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid || !hasChanges"
|
||||
@click="handleUpdateCompany"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const description = computed(() =>
|
||||
props.company?.name
|
||||
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
|
||||
companyName: props.company.name,
|
||||
})
|
||||
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
|
||||
);
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
|
||||
:description="description"
|
||||
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
|
||||
:is-loading="isLoading"
|
||||
@confirm="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
autofocus
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
@enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
|
||||
@@ -191,7 +191,7 @@ const handleInputUpdate = async () => {
|
||||
:message="attributeErrorMessage"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
@enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
|
||||
+2
-2
@@ -201,7 +201,7 @@ onMounted(() => {
|
||||
v-if="openAgentsList && hasAgentList"
|
||||
:menu-items="agentList"
|
||||
show-search
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
|
||||
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-60"
|
||||
@action="handleArticleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
@@ -233,7 +233,7 @@ onMounted(() => {
|
||||
v-if="openCategoryList && hasCategoryMenuItems"
|
||||
:menu-items="categoryList"
|
||||
show-search
|
||||
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
|
||||
class="w-48 mt-2 z-[100] left-0 top-full max-h-60"
|
||||
@action="handleArticleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
|
||||
+2
-2
@@ -159,7 +159,7 @@ const handleTabChange = value => {
|
||||
v-if="isLocaleMenuOpen"
|
||||
:menu-items="localeMenuItems"
|
||||
show-search
|
||||
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
class="left-0 w-40 max-w-[300px] mt-2 xl:right-0 top-full max-h-60"
|
||||
@action="handleLocaleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
@@ -180,7 +180,7 @@ const handleTabChange = value => {
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
show-search
|
||||
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
class="left-0 w-48 mt-2 xl:right-0 top-full max-h-60"
|
||||
@action="handleCategoryAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
|
||||
+1
-1
@@ -296,7 +296,7 @@ watch(
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<template #secondaryActions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ const handleBreadcrumbClick = () => {
|
||||
v-if="isLocaleMenuOpen"
|
||||
:menu-items="localeMenuItems"
|
||||
show-search
|
||||
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
class="left-0 w-40 mt-2 xl:right-0 top-full max-h-60"
|
||||
@action="handleLocaleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ const targetInboxLabel = computed(() => {
|
||||
<DropdownMenu
|
||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
:menu-items="contactableInboxesList"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="emit('handleInboxAction', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -47,9 +47,6 @@ const allSelected = computed(
|
||||
selectedVisibleCount.value === visibleItemCount.value
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
@@ -95,10 +92,12 @@ const bulkCheckboxState = computed({
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div v-if="$slots.primaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<slot v-if="$slots.primaryActions" name="primaryActions" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot v-if="hasSecondaryActions" name="secondary-actions" />
|
||||
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<slot v-if="$slots.secondaryActions" name="secondaryActions" />
|
||||
<div v-if="$slots.secondaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
|
||||
@@ -10,9 +10,6 @@ const props = defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
type: Array,
|
||||
@@ -22,6 +19,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
roundedThumbnail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -42,9 +43,17 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: 'DROPDOWN_MENU.EMPTY_STATE',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
const emit = defineEmits(['action', 'search', 'empty']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -96,9 +105,13 @@ const filteredMenuSections = computed(() => {
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
emit('search', event.target.value);
|
||||
|
||||
const isEmpty = hasSections.value
|
||||
? filteredMenuSections.value.length === 0
|
||||
: filteredMenuItems.value.length === 0;
|
||||
|
||||
if (isEmpty) emit('empty');
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
@@ -123,57 +136,104 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||
:class="{
|
||||
'pt-2': !showSearch,
|
||||
}"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[136px] shadow-lg pt-2 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showSearch" class="relative shrink-0 px-2 mb-2">
|
||||
<span
|
||||
class="absolute i-lucide-search size-3.5 top-2 ltr:left-5 rtl:right-5"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pl-2 rtl:pr-10 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="hasSections">
|
||||
<div
|
||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||
:key="section.title || sectionIndex"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
:class="showSearch ? 'top-10' : 'top-0'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2 overflow-y-auto min-h-0 px-2 pb-2">
|
||||
<template v-if="hasSections">
|
||||
<div
|
||||
v-if="section.isLoading"
|
||||
class="flex items-center justify-center py-2"
|
||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||
:key="section.title || sectionIndex"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="section.isLoading"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!section.items.length && section.emptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ section.emptyState }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{
|
||||
item.emoji
|
||||
}}</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-2">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!section.items.length && section.emptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ section.emptyState }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
@@ -190,77 +250,44 @@ onMounted(() => {
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowEmptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowEmptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
}}
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: searchQuery
|
||||
? t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: t(emptyStateMessage)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="shrink-0">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ const showDropdown = ref(false);
|
||||
v-on-clickaway="() => (showDropdown = false)"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
@action="emit('updateLabel', $event)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
|
||||
@@ -209,7 +209,7 @@ watch(
|
||||
v-if="showDropdown"
|
||||
:menu-items="filteredCountries"
|
||||
show-search
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
@action="onSelectCountry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['companies_dashboard_index'],
|
||||
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -247,7 +247,7 @@ const handleBlur = e => emit('blur', e);
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="filteredMenuItems"
|
||||
:is-searching="isLoading"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
@action="handleDropdownAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -122,8 +122,6 @@ const {
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const {
|
||||
@@ -866,7 +864,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||
@@ -924,18 +922,14 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
<ConversationBulkActions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
|
||||
@select-all-conversations="toggleSelectAll"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<ConversationList
|
||||
:conversation-list="conversationList"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -91,6 +93,15 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(String(props.chat.id));
|
||||
useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,9 +144,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
class="flex items-center gap-1 overflow-hidden text-xs conversation--header--actions text-n-slate-11 text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="truncate text-label-small text-n-slate-11 hover:text-n-slate-12 !p-0 cucursor-pointer"
|
||||
@click="copyConversationId"
|
||||
>
|
||||
{{ `#${chat.id}` }}
|
||||
</button>
|
||||
<span v-if="hasMultipleInboxes">•</span>
|
||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
|
||||
<span v-if="isSnoozed">•</span>
|
||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||
{{ snoozedDisplayText }}
|
||||
</span>
|
||||
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
Spinner,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ['select', 'close'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAgent: null,
|
||||
goBackToAgentList: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'bulkActions/getUIFlags',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgents() {
|
||||
if (this.query) {
|
||||
return this.assignableAgents.filter(agent =>
|
||||
agent.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: null,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.assignableAgents,
|
||||
];
|
||||
},
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.selectedInboxes.join(',')
|
||||
);
|
||||
},
|
||||
conversationLabel() {
|
||||
return this.conversationCount > 1 ? 'conversations' : 'conversation';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$emit('select', this.selectedAgent);
|
||||
},
|
||||
goBack() {
|
||||
this.goBackToAgentList = true;
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
assignAgent(agent) {
|
||||
this.selectedAgent = agent;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onCloseAgentList() {
|
||||
if (this.selectedAgent === null && !this.goBackToAgentList) {
|
||||
this.onClose();
|
||||
}
|
||||
this.goBackToAgentList = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div
|
||||
v-if="assignableAgentsUiFlags.isFetching"
|
||||
class="agent__list-loading"
|
||||
>
|
||||
<Spinner />
|
||||
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
|
||||
</div>
|
||||
<div v-else class="agent__list-container">
|
||||
<ul v-if="!selectedAgent">
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-list-item" @click="assignAgent(agent)">
|
||||
<Avatar
|
||||
:name="agent.name"
|
||||
:src="agent.thumbnail"
|
||||
:status="agent.availability_status"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ agent.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="agent-confirmation-container">
|
||||
<p v-if="selectedAgent.id">
|
||||
{{
|
||||
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
<strong>
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
<span>?</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
{{
|
||||
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="agent-confirmation-actions">
|
||||
<NextButton
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.YES')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__agents {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.agent__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-list-item {
|
||||
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-confirmation-container {
|
||||
@apply flex flex-col h-full p-2.5;
|
||||
p {
|
||||
@apply flex-grow;
|
||||
}
|
||||
.agent-confirmation-actions {
|
||||
@apply w-full grid grid-cols-2 gap-2.5;
|
||||
}
|
||||
}
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
|
||||
.agent__list-loading {
|
||||
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
|
||||
}
|
||||
</style>
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedAgent = ref(null);
|
||||
|
||||
const assignableAgentsUiFlags = useMapGetter(
|
||||
'inboxAssignableAgents/getUIFlags'
|
||||
);
|
||||
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
|
||||
|
||||
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
|
||||
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
|
||||
|
||||
const assignableAgentsList = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
);
|
||||
const assignableAgents = computed(() =>
|
||||
assignableAgentsList.value(props.selectedInboxes.join(','))
|
||||
);
|
||||
|
||||
const agentMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: t('BULK_ACTION.NONE'),
|
||||
thumbnail: {
|
||||
name: t('BULK_ACTION.NONE'),
|
||||
src: '',
|
||||
},
|
||||
isSelected: selectedAgent.value?.id === null,
|
||||
},
|
||||
];
|
||||
|
||||
assignableAgents.value.forEach(agent => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: agent.id,
|
||||
label: agent.name,
|
||||
thumbnail: {
|
||||
name: agent.name,
|
||||
src: agent.thumbnail,
|
||||
},
|
||||
isSelected: selectedAgent.value?.id === agent.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectAgent = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedAgent.value = { id: null, name: t('BULK_ACTION.NONE') };
|
||||
} else {
|
||||
const agent = assignableAgents.value.find(a => a.id === item.value);
|
||||
selectedAgent.value = agent || { id: null, name: t('BULK_ACTION.NONE') };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (isUpdating.value) return;
|
||||
emit('select', selectedAgent.value);
|
||||
selectedAgent.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedAgent.value = null;
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
selectedAgent.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleToggleDropdown = () => {
|
||||
const willOpen = !showDropdown.value;
|
||||
toggleDropdown();
|
||||
|
||||
// Fetch agents only when opening the dropdown
|
||||
if (willOpen && props.selectedInboxes.length > 0) {
|
||||
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-check"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="handleToggleDropdown"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
|
||||
:menu-items="agentMenuItems"
|
||||
:is-loading="isLoading"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80"
|
||||
@action="handleSelectAgent"
|
||||
>
|
||||
<template v-if="selectedAgent" #footer>
|
||||
<div
|
||||
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedAgent.id"
|
||||
keypath="BULK_ACTION.ASSIGN_AGENT_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #agentName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.UNASSIGN_AGENT_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
:disabled="isUpdating"
|
||||
:is-loading="isUpdating"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
|
||||
const buttonLabel = computed(() =>
|
||||
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
|
||||
);
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return labels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
color: label.color,
|
||||
id: label.id,
|
||||
isSelected: isLabelSelected(label.title),
|
||||
}));
|
||||
});
|
||||
|
||||
const toggleLabelSelection = labelTitle => {
|
||||
const index = selectedLabels.value.indexOf(labelTitle);
|
||||
if (index > -1) {
|
||||
selectedLabels.value.splice(index, 1);
|
||||
} else {
|
||||
selectedLabels.value.push(labelTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
selectedLabels.value = [];
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-tag"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
:class="{
|
||||
'bg-n-alpha-2': showDropdown,
|
||||
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit !text-n-blue-11 [&>span]:!text-n-blue-11 !px-2':
|
||||
isTypeContact,
|
||||
}"
|
||||
:disabled="disabled || isLoading"
|
||||
:is-loading="isLoading"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
:enter-active-class="
|
||||
!isTypeContact
|
||||
? 'transition-all duration-150 ease-out origin-bottom'
|
||||
: 'transition-all duration-150 ease-out origin-top'
|
||||
"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
:leave-active-class="
|
||||
!isTypeContact
|
||||
? 'transition-all duration-100 ease-in origin-bottom'
|
||||
: 'transition-all duration-100 ease-in origin-top'
|
||||
"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="w-60 max-h-80"
|
||||
:class="{
|
||||
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8':
|
||||
!isTypeContact,
|
||||
'ltr:right-0 rtl:left-0 mb-1 top-10': isTypeContact,
|
||||
}"
|
||||
@action="item => toggleLabelSelection(item.value)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #trailing-icon="{ item }">
|
||||
<Icon
|
||||
v-if="isLabelSelected(item.value)"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-blue-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="sticky bottom-0 rounded-b-md px-2 py-2 z-20 bg-n-alpha-3 backdrop-blur-[4px]"
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedTeam = ref(null);
|
||||
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
|
||||
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
|
||||
|
||||
const teamMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: t('BULK_ACTION.TEAMS.NONE'),
|
||||
isSelected: selectedTeam.value?.id === 0,
|
||||
},
|
||||
];
|
||||
|
||||
teams.value.forEach(team => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
isSelected: selectedTeam.value?.id === team.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectTeam = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedTeam.value = { id: 0, name: t('BULK_ACTION.TEAMS.NONE') };
|
||||
} else {
|
||||
const foundTeam = teams.value.find(team => team.id === item.value);
|
||||
selectedTeam.value = foundTeam || {
|
||||
id: 0,
|
||||
name: t('BULK_ACTION.TEAMS.NONE'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (isUpdating.value) return;
|
||||
emit('select', selectedTeam.value);
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedTeam.value = null;
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
|
||||
:menu-items="teamMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80"
|
||||
@action="handleSelectTeam"
|
||||
>
|
||||
<template v-if="selectedTeam" #footer>
|
||||
<div
|
||||
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedTeam.id"
|
||||
keypath="BULK_ACTION.TEAMS.ASSIGN_TEAM_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #teamName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedTeam.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.TEAMS.UNASSIGN_TEAM_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
:disabled="isUpdating"
|
||||
:is-loading="isUpdating"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
|
||||
const updateMenuItems = computed(() => {
|
||||
const items = [];
|
||||
|
||||
if (props.showResolve) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'resolved',
|
||||
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
icon: 'i-lucide-check',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showReopen) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'open',
|
||||
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
icon: 'i-lucide-redo',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showSnooze) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'snoozed',
|
||||
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
icon: 'i-lucide-alarm-clock',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleUpdate = item => {
|
||||
if (item.value === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', item.value);
|
||||
}
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-circle-fading-arrow-up"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="updateMenuItems"
|
||||
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
|
||||
@action="handleUpdate"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+169
-301
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
@@ -10,315 +12,181 @@ import {
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
import TeamActions from './TeamActions.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import BulkAgentActions from './BulkAgentActions.vue';
|
||||
import BulkUpdateActions from './BulkUpdateActions.vue';
|
||||
import BulkLabelActions from './BulkLabelActions.vue';
|
||||
import BulkTeamActions from './BulkTeamActions.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
TeamActions,
|
||||
CustomSnoozeModal,
|
||||
NextButton,
|
||||
|
||||
const props = defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: [
|
||||
'selectAllConversations',
|
||||
'assignAgent',
|
||||
'updateConversations',
|
||||
'assignLabels',
|
||||
'assignTeam',
|
||||
'resolveConversations',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showAgentsList: false,
|
||||
showUpdateActions: false,
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.updateConversations('snoozed', snoozeType);
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
},
|
||||
onCmdReopenConversation() {
|
||||
this.updateConversations('open', null);
|
||||
},
|
||||
onCmdResolveConversation() {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('selectAllConversations', e.target.checked);
|
||||
},
|
||||
submit(agent) {
|
||||
this.$emit('assignAgent', agent);
|
||||
},
|
||||
updateConversations(status, snoozedUntil) {
|
||||
this.$emit('updateConversations', status, snoozedUntil);
|
||||
},
|
||||
assignLabels(labels) {
|
||||
this.$emit('assignLabels', labels);
|
||||
},
|
||||
assignTeam(team) {
|
||||
this.$emit('assignTeam', team);
|
||||
},
|
||||
resolveConversations() {
|
||||
this.$emit('resolveConversations');
|
||||
},
|
||||
toggleUpdateActions() {
|
||||
this.showUpdateActions = !this.showUpdateActions;
|
||||
},
|
||||
toggleLabelActions() {
|
||||
this.showLabelActions = !this.showLabelActions;
|
||||
},
|
||||
toggleAgentList() {
|
||||
this.showAgentsList = !this.showAgentsList;
|
||||
},
|
||||
toggleTeamsList() {
|
||||
this.showTeamsList = !this.showTeamsList;
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAllConversations']);
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const {
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
showCustomTimeSnoozeModal.value = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
onUpdateConversations('snoozed', snoozeType);
|
||||
} else {
|
||||
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
}
|
||||
|
||||
function onCmdReopenConversation() {
|
||||
onUpdateConversations('open', null);
|
||||
}
|
||||
|
||||
function onCmdResolveConversation() {
|
||||
onUpdateConversations('resolved', null);
|
||||
}
|
||||
|
||||
function customSnoozeTime(customSnoozedTime) {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
if (customSnoozedTime) {
|
||||
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomSnoozeModal() {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
}
|
||||
|
||||
// Computed property with getter/setter to enable v-model usage
|
||||
const allSelected = computed({
|
||||
get: () => props.allConversationsSelected,
|
||||
set: value => {
|
||||
emit('selectAllConversations', value);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-action__container">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center justify-between bulk-action__panel">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="allConversationsSelected"
|
||||
:indeterminate.prop="!allConversationsSelected"
|
||||
@change="selectAll($event)"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-1 bulk-action__actions">
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
icon="i-lucide-tags"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleLabelActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-repeat"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleUpdateActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-plus"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleAgentList"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleTeamsList"
|
||||
/>
|
||||
</div>
|
||||
<transition name="popover-animation">
|
||||
<LabelActions
|
||||
v-if="showLabelActions"
|
||||
class="label-actions-box"
|
||||
@assign="assignLabels"
|
||||
@close="showLabelActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<UpdateActions
|
||||
v-if="showUpdateActions"
|
||||
class="update-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<AgentSelector
|
||||
v-if="showAgentsList"
|
||||
class="agent-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<TeamActions
|
||||
v-if="showTeamsList"
|
||||
class="team-actions-box"
|
||||
@assign-team="assignTeam"
|
||||
@close="showTeamsList = false"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="conversations.length > 0"
|
||||
v-bind="attrs"
|
||||
class="px-2 absolute bottom-20 sm:bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
|
||||
>
|
||||
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
|
||||
<label class="cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
:indeterminate="!allConversationsSelected"
|
||||
/>
|
||||
<span class="cursor-pointer">
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
|
||||
<NextButton
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="onUpdateConversations"
|
||||
/>
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__container {
|
||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
@apply cursor-pointer;
|
||||
|
||||
span {
|
||||
@apply text-xs my-0 mx-1;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply cursor-pointer m-0;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-action__alert {
|
||||
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
|
||||
}
|
||||
|
||||
.popover-animation-enter-active,
|
||||
.popover-animation-leave-active {
|
||||
transition: transform ease-out 0.1s;
|
||||
}
|
||||
|
||||
.popover-animation-enter {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.popover-animation-enter-to {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave-to {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.label-actions-box {
|
||||
--triangle-position: 5.3125rem;
|
||||
}
|
||||
.update-actions-box {
|
||||
--triangle-position: 3.5rem;
|
||||
}
|
||||
.agent-actions-box {
|
||||
--triangle-position: 1.75rem;
|
||||
}
|
||||
.team-actions-box {
|
||||
--triangle-position: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const query = ref('');
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
if (!query.value) return labels.value;
|
||||
return labels.value.filter(label =>
|
||||
label.title.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const hasLabels = computed(() => labels.value.length > 0);
|
||||
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
|
||||
|
||||
const isLabelSelected = label => {
|
||||
return selectedLabels.value.includes(label);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="onClose"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
|
||||
role="dialog"
|
||||
aria-labelledby="label-dialog-title"
|
||||
>
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2.5">
|
||||
<span class="text-sm font-medium">{{
|
||||
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
}}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="flex flex-col max-h-60 min-h-0">
|
||||
<header class="py-2 px-2.5">
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
icon-left="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</header>
|
||||
<ul
|
||||
v-if="hasLabels"
|
||||
class="flex-1 overflow-y-auto m-0 list-none"
|
||||
role="listbox"
|
||||
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
>
|
||||
<li v-if="!hasFilteredLabels" class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="my-1 mx-0 py-0 px-2.5"
|
||||
role="option"
|
||||
:aria-selected="isLabelSelected(label.title)"
|
||||
>
|
||||
<label
|
||||
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
|
||||
:aria-label="label.title"
|
||||
/>
|
||||
<span
|
||||
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</div>
|
||||
<footer class="p-2">
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['assignTeam', 'close'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedteams: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ teams: 'teams/getTeams' }),
|
||||
filteredTeams() {
|
||||
return [
|
||||
{ name: 'None', id: 0 },
|
||||
...this.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
assignTeam(key) {
|
||||
this.$emit('assignTeam', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="team__list-container">
|
||||
<ul>
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<template v-if="filteredTeams.length">
|
||||
<li v-for="team in filteredTeams" :key="team.id">
|
||||
<div class="team__list-item" @click="assignTeam(team)">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-else>
|
||||
<div class="team__list-item">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__teams {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.team__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team__list-item {
|
||||
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
</style>
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const actions = ref([
|
||||
{ icon: 'i-lucide-check', key: 'resolved' },
|
||||
{ icon: 'i-lucide-redo', key: 'open' },
|
||||
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
|
||||
]);
|
||||
|
||||
const updateConversations = key => {
|
||||
if (key === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', key);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const showAction = key => {
|
||||
const actionsMap = {
|
||||
resolved: props.showResolve,
|
||||
open: props.showReopen,
|
||||
snoozed: props.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
};
|
||||
|
||||
const actionLabel = key => {
|
||||
const labelsMap = {
|
||||
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
|
||||
>
|
||||
<div
|
||||
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
|
||||
>
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill-rule="evenodd"
|
||||
stroke-width="1px"
|
||||
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-2.5 flex gap-1 items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
|
||||
</span>
|
||||
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="px-2.5 pt-0 pb-2.5">
|
||||
<WootDropdownMenu class="m-0 list-none">
|
||||
<template v-for="action in actions">
|
||||
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
|
||||
<Button
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
class="!w-full !justify-start"
|
||||
:icon="action.icon"
|
||||
:label="actionLabel(action.key)"
|
||||
@click="updateConversations(action.key)"
|
||||
/>
|
||||
</WootDropdownItem>
|
||||
</template>
|
||||
</WootDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,9 +23,15 @@ export function useBulkActions() {
|
||||
|
||||
function deSelectConversation(conversationId, inboxId) {
|
||||
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
|
||||
selectedInboxes.value = selectedInboxes.value.filter(
|
||||
item => item !== inboxId
|
||||
);
|
||||
// Only remove one instance of the inboxId, not all
|
||||
// This handles the case where multiple conversations from the same inbox are selected
|
||||
const index = selectedInboxes.value.indexOf(inboxId);
|
||||
if (index > -1) {
|
||||
selectedInboxes.value = [
|
||||
...selectedInboxes.value.slice(0, index),
|
||||
...selectedInboxes.value.slice(index + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function resetBulkActions() {
|
||||
@@ -141,6 +147,8 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
async function onUpdateConversations(status, snoozedUntil) {
|
||||
if (selectedConversations.value.length === 0) return;
|
||||
|
||||
let conversationIds = selectedConversations.value;
|
||||
let skippedCount = 0;
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
"HEADER": "Custom Attributes",
|
||||
"HEADER_BTN_TXT": "Add Custom Attribute",
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts, companies, or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Company"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
@@ -108,7 +109,8 @@
|
||||
"TABS": {
|
||||
"HEADER": "Custom Attributes",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Company"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
|
||||
"NONE": "None",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
|
||||
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"YES": "Yes",
|
||||
"CANCEL": "Cancel",
|
||||
"SEARCH_INPUT_PLACEHOLDER": "Search",
|
||||
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
|
||||
"ASSIGN_TEAM_TOOLTIP": "Assign team",
|
||||
@@ -15,7 +15,6 @@
|
||||
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
|
||||
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
|
||||
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
|
||||
"AGENT_LIST_LOADING": "Loading agents",
|
||||
"UPDATE": {
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
@@ -28,16 +27,14 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"TEAM_SELECT_LABEL": "Select team",
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign team. Please try again."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY_AT": "Last activity",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
@@ -21,6 +22,113 @@
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"ACTIONS": {
|
||||
"CREATE": "Add company"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Add company details",
|
||||
"ACTIONS": {
|
||||
"SAVE": "Add company"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"SUCCESS": "Company created.",
|
||||
"ERROR": "Could not create the company."
|
||||
}
|
||||
},
|
||||
"DETAIL": {
|
||||
"LOADING": "Loading company details...",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Company not found",
|
||||
"SUBTITLE": "This company may have been removed or is no longer available in this account."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TABS": {
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"CONTACTS": "Contacts"
|
||||
}
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"EMPTY_STATE": "There are no company custom attributes configured yet.",
|
||||
"NO_ATTRIBUTES": "No matching attributes found.",
|
||||
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
|
||||
"MESSAGES": {
|
||||
"UPDATE_SUCCESS": "Company attribute updated.",
|
||||
"UPDATE_ERROR": "Could not update company attribute.",
|
||||
"DELETE_SUCCESS": "Company attribute removed.",
|
||||
"DELETE_ERROR": "Could not remove company attribute."
|
||||
}
|
||||
},
|
||||
"CONTACTS": {
|
||||
"LOADING": "Loading contacts...",
|
||||
"EMPTY": "No contacts are linked to this company yet.",
|
||||
"UNNAMED_CONTACT": "Unnamed contact",
|
||||
"ACTIONS": {
|
||||
"ADD": "Add contact",
|
||||
"REMOVE": "Remove contact"
|
||||
},
|
||||
"DIALOGS": {
|
||||
"ADD": {
|
||||
"DESCRIPTION": "Search for an existing contact and link it to this company.",
|
||||
"SEARCH_PLACEHOLDER": "Search contacts...",
|
||||
"INITIAL": "Start typing to search contacts.",
|
||||
"EMPTY": "No contacts found.",
|
||||
"CONFIRM_TITLE": "Link contact",
|
||||
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
|
||||
"COMPANY_LABEL": "Company",
|
||||
"CONTACT_LABEL": "Contact",
|
||||
"CURRENT_COMPANY": "Currently linked to {companyName}",
|
||||
"ADD": "Link contact",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"MESSAGES": {
|
||||
"ADD_SUCCESS": "Contact linked to company.",
|
||||
"ADD_ERROR": "Could not link contact to company.",
|
||||
"REASSIGN_SUCCESS": "Contact reassigned to company.",
|
||||
"REASSIGN_ERROR": "Could not reassign contact to company.",
|
||||
"REMOVE_SUCCESS": "Contact removed from company.",
|
||||
"REMOVE_ERROR": "Could not remove contact from company."
|
||||
}
|
||||
},
|
||||
"AVATAR": {
|
||||
"UPDATING": "Updating company avatar...",
|
||||
"UPLOAD_SUCCESS": "Company avatar updated.",
|
||||
"UPLOAD_ERROR": "Could not update the company avatar.",
|
||||
"DELETE_SUCCESS": "Company avatar removed.",
|
||||
"DELETE_ERROR": "Could not remove the company avatar."
|
||||
},
|
||||
"PROFILE": {
|
||||
"TITLE": "Edit company details",
|
||||
"CREATED_AT": "Created {date}",
|
||||
"LAST_ACTIVE": "Last active {date}",
|
||||
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
|
||||
"ACTIONS": {
|
||||
"SAVE": "Update company"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"UPDATE_SUCCESS": "Company updated.",
|
||||
"UPDATE_ERROR": "Could not update the company."
|
||||
},
|
||||
"FIELDS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"SECTION_TITLE": "Danger zone",
|
||||
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
|
||||
"BUTTON": "Delete company",
|
||||
"TITLE": "Delete company?",
|
||||
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
|
||||
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
|
||||
"CONFIRM": "Delete company",
|
||||
"MESSAGES": {
|
||||
"SUCCESS": "Company deleted.",
|
||||
"ERROR": "Could not delete the company."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details",
|
||||
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
|
||||
@@ -232,7 +232,7 @@ watch(() => modelValue.value, resolveContactName, { immediate: true });
|
||||
show-search
|
||||
disable-local-filtering
|
||||
:is-searching="isSearching"
|
||||
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
|
||||
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80"
|
||||
@search="performSearch"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
@@ -210,7 +210,7 @@ const onToggleDropdown = () => {
|
||||
>
|
||||
<template #footer>
|
||||
<div class="h-px bg-n-strong" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 px-2 py-2">
|
||||
<div class="flex items-center justify-between gap-2 px-1 h-9">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('SEARCH.DATE_RANGE.CUSTOM_RANGE') }}
|
||||
|
||||
@@ -117,7 +117,7 @@ const onToggleDropdown = () => {
|
||||
:menu-sections="menuSections"
|
||||
show-search
|
||||
disable-local-filtering
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80 overflow-y-auto"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80"
|
||||
@search="searchQuery = $event"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
@@ -297,7 +297,7 @@ onMounted(() => {
|
||||
}"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<template #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
|
||||
|
||||
const DEFAULT_SORT_FIELD = 'name';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
@@ -26,6 +28,7 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const searchQuery = computed(() => route.query?.search || '');
|
||||
const searchValue = ref(searchQuery.value);
|
||||
const createCompanyDialogRef = ref(null);
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
|
||||
const parseSortSettings = (sortString = '') => {
|
||||
@@ -51,6 +54,7 @@ const activeSort = computed(() => sortState.activeSort);
|
||||
const activeOrdering = computed(() => sortState.activeOrdering);
|
||||
|
||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||
const isCreatingCompany = computed(() => uiFlags.value.creatingItem);
|
||||
|
||||
const buildSortAttr = () =>
|
||||
`${sortState.activeOrdering}${sortState.activeSort}`;
|
||||
@@ -111,6 +115,31 @@ const onPageChange = page => {
|
||||
fetchCompanies(page, searchValue.value, sortParam.value);
|
||||
};
|
||||
|
||||
const showCompany = companyId => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_show',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
companyId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateCompanyDialog = () => {
|
||||
createCompanyDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
|
||||
const createCompany = async company => {
|
||||
try {
|
||||
const newCompany = await companiesStore.create(company);
|
||||
createCompanyDialogRef.value?.onSuccess();
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
|
||||
showCompany(newCompany.id);
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
@@ -123,6 +152,11 @@ const handleSort = async ({ sort, order }) => {
|
||||
|
||||
onMounted(() => {
|
||||
searchValue.value = searchQuery.value;
|
||||
|
||||
if (!route.query.sort && sortParam.value !== DEFAULT_SORT_FIELD) {
|
||||
updateURLParams(pageNumber.value, searchQuery.value, sortParam.value);
|
||||
}
|
||||
|
||||
fetchCompanies();
|
||||
});
|
||||
</script>
|
||||
@@ -140,6 +174,7 @@ onMounted(() => {
|
||||
@update:current-page="onPageChange"
|
||||
@update:sort="handleSort"
|
||||
@search="onSearch"
|
||||
@create="openCreateCompanyDialog"
|
||||
>
|
||||
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
|
||||
<span class="text-n-slate-11 text-base">{{
|
||||
@@ -162,10 +197,15 @@ onMounted(() => {
|
||||
:name="company.name"
|
||||
:domain="company.domain"
|
||||
:contacts-count="company.contactsCount || 0"
|
||||
:description="company.description"
|
||||
:avatar-url="company.avatarUrl"
|
||||
:updated-at="company.updatedAt"
|
||||
:last-activity-at="company.lastActivityAt"
|
||||
@show-company="showCompany"
|
||||
/>
|
||||
</div>
|
||||
<CompanyCreateDialog
|
||||
ref="createCompanyDialogRef"
|
||||
:is-loading="isCreatingCompany"
|
||||
@create="createCompany"
|
||||
/>
|
||||
</CompaniesListLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CompaniesDetailsLayout from 'dashboard/components-next/Companies/CompaniesDetailsLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
|
||||
import CompanyProfileCard from 'dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue';
|
||||
import ConfirmCompanyDeleteDialog from 'dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const companiesStore = useCompaniesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const confirmDeleteDialogRef = ref(null);
|
||||
const selectedCandidate = ref(null);
|
||||
|
||||
const companyId = computed(() => Number(route.params.companyId));
|
||||
const company = computed(() => companiesStore.getRecord(companyId.value));
|
||||
const companyContacts = computed(() => companiesStore.companyContacts);
|
||||
const companyContactsMeta = computed(() => companiesStore.companyContactsMeta);
|
||||
const contactSearchResults = computed(
|
||||
() => companiesStore.contactSearchResults
|
||||
);
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
|
||||
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
|
||||
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
|
||||
const isManagingContacts = computed(
|
||||
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
|
||||
);
|
||||
const isDeletingCompany = computed(() => uiFlags.value.deletingItem);
|
||||
const hasCompany = computed(() => Boolean(company.value?.id));
|
||||
const showInitialLoadingState = computed(
|
||||
() =>
|
||||
!hasCompany.value && (isFetchingCompany.value || isFetchingContacts.value)
|
||||
);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: t('COMPANIES.HEADER') },
|
||||
...(hasCompany.value
|
||||
? [{ label: company.value?.name || t('COMPANIES.UNNAMED') }]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const goToCompaniesIndex = () => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
query: { page: '1' },
|
||||
});
|
||||
};
|
||||
|
||||
const goToCompaniesList = () => {
|
||||
if (window.history.state?.back) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
goToCompaniesIndex();
|
||||
};
|
||||
|
||||
const loadCompanyContactsPage = async page => {
|
||||
if (!companyId.value) return;
|
||||
await companiesStore.getCompanyContacts(companyId.value, page);
|
||||
};
|
||||
|
||||
const openDeleteCompanyDialog = () => {
|
||||
confirmDeleteDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
|
||||
const clearSelectedCandidate = () => {
|
||||
selectedCandidate.value = null;
|
||||
};
|
||||
|
||||
const handleContactSearch = async query => {
|
||||
await companiesStore.searchCompanyContactCandidates({
|
||||
companyId: companyId.value,
|
||||
search: query,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmContactSelection = async () => {
|
||||
const candidate = selectedCandidate.value;
|
||||
if (!candidate) return;
|
||||
|
||||
const isReassigning =
|
||||
candidate.company?.id && candidate.company.id !== companyId.value;
|
||||
const message = isReassigning
|
||||
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_SUCCESS')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_SUCCESS');
|
||||
|
||||
try {
|
||||
await companiesStore.attachContactToCompany(companyId.value, candidate.id);
|
||||
useAlert(message);
|
||||
clearSelectedCandidate();
|
||||
} catch {
|
||||
const errorMessage = isReassigning
|
||||
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_ERROR')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_ERROR');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveContact = async contactId => {
|
||||
const currentPage = Number(companyContactsMeta.value.page || 1);
|
||||
const nextPage =
|
||||
currentPage > 1 && companyContacts.value.length === 1
|
||||
? currentPage - 1
|
||||
: currentPage;
|
||||
|
||||
try {
|
||||
await companiesStore.removeContactFromCompany(
|
||||
companyId.value,
|
||||
contactId,
|
||||
nextPage
|
||||
);
|
||||
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async () => {
|
||||
try {
|
||||
await companiesStore.delete(companyId.value);
|
||||
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.SUCCESS'));
|
||||
confirmDeleteDialogRef.value?.dialogRef.close();
|
||||
goToCompaniesIndex();
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
companyId,
|
||||
async id => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
clearSelectedCandidate();
|
||||
if (!id) return;
|
||||
await Promise.allSettled([
|
||||
companiesStore.show(id),
|
||||
companiesStore.getCompanyContacts(id),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompaniesDetailsLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
@back="goToCompaniesList"
|
||||
>
|
||||
<div
|
||||
v-if="showInitialLoadingState"
|
||||
class="flex flex-col items-center justify-center gap-3 py-24 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
<span class="text-sm">{{ t('COMPANIES.DETAIL.LOADING') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasCompany"
|
||||
class="flex flex-col items-center justify-center gap-3 px-6 py-24 text-center rounded-2xl border border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<span class="text-lg font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.EMPTY_STATE.TITLE') }}
|
||||
</span>
|
||||
<p class="max-w-md text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.EMPTY_STATE.SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<CompanyProfileCard :company="company" :is-loading="isFetchingCompany" />
|
||||
|
||||
<Policy :permissions="['administrator']">
|
||||
<section
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.DELETE.SECTION_TITLE') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.DELETE.SECTION_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.DELETE.BUTTON')"
|
||||
color="ruby"
|
||||
:disabled="isDeletingCompany"
|
||||
@click="openDeleteCompanyDialog"
|
||||
/>
|
||||
</section>
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<template v-if="hasCompany" #sidebar>
|
||||
<CompanyContactsSidebar
|
||||
:company="company"
|
||||
:contacts="companyContacts"
|
||||
:meta="companyContactsMeta"
|
||||
:is-loading="isFetchingContacts"
|
||||
:is-busy="isManagingContacts"
|
||||
:search-results="contactSearchResults"
|
||||
:is-searching="isSearchingContacts"
|
||||
:selected-contact="selectedCandidate"
|
||||
@cancel-contact-selection="clearSelectedCandidate"
|
||||
@confirm-contact-selection="handleConfirmContactSelection"
|
||||
@search="handleContactSearch"
|
||||
@select-contact="contact => (selectedCandidate = contact)"
|
||||
@remove-contact="handleRemoveContact"
|
||||
@update:current-page="loadCompanyContactsPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ConfirmCompanyDeleteDialog
|
||||
ref="confirmDeleteDialogRef"
|
||||
:company="company"
|
||||
:is-loading="isDeletingCompany"
|
||||
@confirm="handleDeleteCompany"
|
||||
/>
|
||||
</CompaniesDetailsLayout>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CompaniesIndex from './pages/CompaniesIndex.vue';
|
||||
import CompanyDetailView from './pages/CompanyDetailView.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
|
||||
@@ -23,4 +24,17 @@ export const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/companies/:companyId'),
|
||||
component: CompanyDetailView,
|
||||
meta: commonMeta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'companies_dashboard_show',
|
||||
component: CompanyDetailView,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
+14
-54
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||
import BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,7 +33,6 @@ const { t } = useI18n();
|
||||
|
||||
const selectedCount = computed(() => props.selectedContactIds.length);
|
||||
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
|
||||
const showLabelSelector = ref(false);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!totalVisibleContacts.value) {
|
||||
@@ -71,23 +69,8 @@ const selectionModel = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const emitClearSelection = () => {
|
||||
showLabelSelector.value = false;
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const toggleLabelSelector = () => {
|
||||
if (!selectedCount.value || props.isLoading) return;
|
||||
showLabelSelector.value = !showLabelSelector.value;
|
||||
};
|
||||
|
||||
const closeLabelSelector = () => {
|
||||
showLabelSelector.value = false;
|
||||
};
|
||||
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
closeLabelSelector();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -102,60 +85,37 @@ const handleAssignLabels = labels => {
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<template #primaryActions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1.5"
|
||||
@click="emitClearSelection"
|
||||
class="!px-1"
|
||||
@click="emit('clearSelection')"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div
|
||||
v-on-click-outside="closeLabelSelector"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-tags"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="toggleLabelSelector"
|
||||
/>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<LabelActions
|
||||
v-if="showLabelSelector"
|
||||
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
faded
|
||||
ghost
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
@@ -32,6 +32,7 @@ const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
const [showEditPopup, toggleEditPopup] = useToggle(false);
|
||||
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
|
||||
const selectedAttribute = ref({});
|
||||
const attributeModels = ['conversation_attribute', 'contact_attribute'];
|
||||
|
||||
const openAddPopup = () => {
|
||||
toggleAddPopup(true);
|
||||
@@ -69,8 +70,8 @@ onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
|
||||
const attributeModel = computed(() =>
|
||||
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
|
||||
const attributeModel = computed(
|
||||
() => attributeModels[selectedTabIndex.value] || 'conversation_attribute'
|
||||
);
|
||||
|
||||
const attributes = computed(() =>
|
||||
|
||||
+1
-1
@@ -527,7 +527,7 @@ onMounted(() => {
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showPolicyDropdown"
|
||||
class="top-full ltr:left-0 rtl:right-0 mt-2 max-w-64 max-h-72 overflow-y-auto"
|
||||
class="top-full ltr:left-0 rtl:right-0 mt-2 max-w-64 max-h-72"
|
||||
:menu-items="policyMenuItems"
|
||||
:is-searching="isLoadingPolicies"
|
||||
@action="handlePolicyMenuAction"
|
||||
|
||||
+1
-1
@@ -288,7 +288,7 @@ onMounted(() => {
|
||||
:menu-items="inboxMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96"
|
||||
@action="handleInboxAction($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,11 @@ export const getters = {
|
||||
.filter(record => record.attribute_model === 'contact_attribute')
|
||||
.map(camelcaseKeys);
|
||||
},
|
||||
getCompanyAttributes: _state => {
|
||||
return _state.records
|
||||
.filter(record => record.attribute_model === 'company_attribute')
|
||||
.map(camelcaseKeys);
|
||||
},
|
||||
getAttributesByModel: _state => attributeModel => {
|
||||
return _state.records.filter(
|
||||
record => record.attribute_model === attributeModel
|
||||
|
||||
@@ -35,6 +35,36 @@ describe('#getters', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('getCompanyAttributes', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
attribute_display_name: 'Industry',
|
||||
attribute_display_type: 0,
|
||||
attribute_description: 'Company industry',
|
||||
attribute_key: 'industry',
|
||||
attribute_model: 'company_attribute',
|
||||
},
|
||||
{
|
||||
attribute_display_name: 'Language',
|
||||
attribute_display_type: 1,
|
||||
attribute_description: 'Conversation language',
|
||||
attribute_key: 'language',
|
||||
attribute_model: 'conversation_attribute',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getters.getCompanyAttributes(state)).toEqual([
|
||||
{
|
||||
attributeDisplayName: 'Industry',
|
||||
attributeDisplayType: 0,
|
||||
attributeDescription: 'Company industry',
|
||||
attributeKey: 'industry',
|
||||
attributeModel: 'company_attribute',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
|
||||
@@ -1,31 +1,392 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
const createInitialUIFlags = () => ({
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
creatingItem: false,
|
||||
deletingItem: false,
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
});
|
||||
|
||||
const camelizeCompany = data =>
|
||||
camelcaseKeys(data || {}, { deep: true, stopPaths: ['custom_attributes'] });
|
||||
|
||||
const camelizeContact = data =>
|
||||
camelcaseKeys(data || {}, {
|
||||
deep: true,
|
||||
stopPaths: ['custom_attributes', 'additional_attributes'],
|
||||
});
|
||||
|
||||
const normalizeMeta = meta => ({
|
||||
...camelcaseKeys(meta || {}),
|
||||
totalCount: Number(meta?.total_count || meta?.totalCount || meta?.count || 0),
|
||||
page: Number(meta?.page || meta?.current_page || 1),
|
||||
});
|
||||
|
||||
const appendFormData = (formData, key, value) => {
|
||||
if (value === undefined || value == null || value === '') return;
|
||||
if (
|
||||
value instanceof File ||
|
||||
value instanceof Blob ||
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
Object.entries(value).forEach(([k, v]) =>
|
||||
appendFormData(formData, `${key}[${k}]`, v)
|
||||
);
|
||||
};
|
||||
|
||||
const buildCompanyRequestPayload = ({ avatar, customAttributes, ...rest }) => {
|
||||
const payload = {
|
||||
...snakecaseKeys(rest, { deep: true }),
|
||||
...(customAttributes && { custom_attributes: customAttributes }),
|
||||
...(avatar && { avatar }),
|
||||
};
|
||||
if (!avatar) return { company: payload };
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([k, v]) =>
|
||||
appendFormData(formData, `company[${k}]`, v)
|
||||
);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
getCompaniesList: state => state.records,
|
||||
},
|
||||
|
||||
actions: () => ({
|
||||
async search({ search, page, sort }) {
|
||||
setMeta(meta) {
|
||||
this.meta = normalizeMeta(meta);
|
||||
},
|
||||
|
||||
setActiveCompanyId(companyId) {
|
||||
this.activeCompanyId = Number(companyId);
|
||||
},
|
||||
|
||||
ensureActiveCompanyContext(companyId) {
|
||||
if (this.activeCompanyId === null) {
|
||||
this.setActiveCompanyId(companyId);
|
||||
}
|
||||
},
|
||||
|
||||
upsertCompanyRecord(record) {
|
||||
const index = this.records.findIndex(r => r.id === record.id);
|
||||
if (index === -1) this.records.push(record);
|
||||
else this.records[index] = record;
|
||||
},
|
||||
|
||||
updateCompanyContactsCount(companyId, contactsCount) {
|
||||
const company = this.getRecord(companyId);
|
||||
if (!company.id) return;
|
||||
this.upsertCompanyRecord({ ...company, contactsCount });
|
||||
},
|
||||
|
||||
clearContactSearchResults() {
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
},
|
||||
|
||||
async get({ page = 1, sort = 'name' } = {}) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.get({ page, sort });
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async show(id) {
|
||||
this.setUIFlag({ fetchingItem: true });
|
||||
this.setActiveCompanyId(id);
|
||||
const activeCompanyId = Number(id);
|
||||
const requestToken = (this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyDetailRequestToken = requestToken;
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.show(id);
|
||||
const company = camelizeCompany(payload);
|
||||
|
||||
if (
|
||||
this.companyDetailRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return company;
|
||||
}
|
||||
|
||||
this.upsertCompanyRecord(company);
|
||||
this.setActiveCompanyId(company.id);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyDetailRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async update({ id, ...companyAttrs }) {
|
||||
this.setUIFlag({ updatingItem: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.update(
|
||||
id,
|
||||
buildCompanyRequestPayload(companyAttrs)
|
||||
);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ updatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async create(companyAttrs) {
|
||||
this.setUIFlag({ creatingItem: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.create(buildCompanyRequestPayload(companyAttrs));
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ creatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
this.setUIFlag({ deletingItem: true });
|
||||
try {
|
||||
await CompanyAPI.delete(id);
|
||||
this.records = this.records.filter(r => r.id !== Number(id));
|
||||
if (this.activeCompanyId === Number(id)) this.resetCompanyDetailState();
|
||||
return Number(id);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async search({ search, page = 1, sort = 'name' }) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
this.records = payload;
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCompanyAvatar(companyId) {
|
||||
this.setUIFlag({ deletingAvatar: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyAvatar(companyId);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingAvatar: false });
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyContacts(companyId, page = 1) {
|
||||
this.setUIFlag({ fetchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyContactsRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.listContacts(companyId, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.companyContactsRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.companyContacts = contacts;
|
||||
this.companyContactsMeta = normalizedMeta;
|
||||
this.updateCompanyContactsCount(companyId, normalizedMeta.totalCount);
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyContactsRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
|
||||
const query = search?.trim() || '';
|
||||
if (!query) {
|
||||
this.clearContactSearchResults();
|
||||
return [];
|
||||
}
|
||||
|
||||
this.setUIFlag({ searchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
this.activeContactSearchQuery = query;
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.contactSearchRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.searchContacts(companyId, query, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.contactSearchRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId ||
|
||||
this.activeContactSearchQuery !== query
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.contactSearchResults = contacts;
|
||||
this.contactSearchMeta = normalizedMeta;
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.contactSearchRequestToken === requestToken) {
|
||||
this.setUIFlag({ searchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async attachContactToCompany(companyId, contactId) {
|
||||
this.setUIFlag({ creatingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.createContact(companyId, {
|
||||
contact_id: contactId,
|
||||
});
|
||||
const contact = camelizeContact(payload);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(companyId, 1);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
this.clearContactSearchResults();
|
||||
}
|
||||
}
|
||||
return contact;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ creatingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async removeContactFromCompany(companyId, contactId, page = null) {
|
||||
this.setUIFlag({ removingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
await CompanyAPI.removeContact(companyId, contactId);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(
|
||||
companyId,
|
||||
page || this.companyContactsMeta?.page || 1
|
||||
);
|
||||
}
|
||||
return Number(contactId);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ removingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomAttributes({ id, customAttributes }) {
|
||||
this.setUIFlag({ deletingCustomAttributes: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyCustomAttributes(id, customAttributes);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingCustomAttributes: false });
|
||||
}
|
||||
},
|
||||
|
||||
resetCompanyDetailState() {
|
||||
const { fetchingList } = this.uiFlags;
|
||||
this.activeCompanyId = null;
|
||||
this.companyDetailRequestToken =
|
||||
(this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken =
|
||||
(this.companyContactsRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
this.companyContacts = [];
|
||||
this.companyContactsMeta = {};
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.uiFlags = { ...createInitialUIFlags(), fetchingList };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useCompaniesStore } from './companies';
|
||||
|
||||
vi.mock('dashboard/api/companies', () => ({
|
||||
default: {
|
||||
show: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroyAvatar: vi.fn(),
|
||||
listContacts: vi.fn(),
|
||||
searchContacts: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
destroyCustomAttributes: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(error => error),
|
||||
}));
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(res => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('companies store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the latest active company when show requests resolve out of order', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
|
||||
CompanyAPI.show
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const staleRequest = companiesStore.show(1);
|
||||
const currentRequest = companiesStore.show(2);
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Beta Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await currentRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
|
||||
firstRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Alpha Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await staleRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getRecord(1)).toEqual({});
|
||||
expect(companiesStore.getRecord(2)).toEqual(
|
||||
expect.objectContaining({ id: 2, name: 'Beta Company' })
|
||||
);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps avatar files intact when building multipart update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' });
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
avatar,
|
||||
});
|
||||
|
||||
const formData = CompanyAPI.update.mock.calls[0][1];
|
||||
expect(formData.get('company[avatar]')).toBe(avatar);
|
||||
expect(formData.get('company[name]')).toBe('Acme');
|
||||
});
|
||||
|
||||
it('preserves custom attribute keys when building update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
});
|
||||
|
||||
expect(CompanyAPI.update).toHaveBeenCalledWith(1, {
|
||||
company: {
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('links an existing contact and refreshes company contacts', async () => {
|
||||
CompanyAPI.createContact.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
CompanyAPI.listContacts.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
],
|
||||
meta: { total_count: 1, page: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
companiesStore.setActiveCompanyId(1);
|
||||
|
||||
await companiesStore.attachContactToCompany(1, 2);
|
||||
|
||||
expect(CompanyAPI.createContact).toHaveBeenCalledWith(1, {
|
||||
contact_id: 2,
|
||||
});
|
||||
expect(CompanyAPI.listContacts).toHaveBeenCalledWith(1, 1);
|
||||
expect(companiesStore.companyContacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
companyId: 1,
|
||||
linkedToCurrentCompany: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a company custom attribute and updates the company record', async () => {
|
||||
CompanyAPI.destroyCustomAttributes.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
region: 'us',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.deleteCustomAttributes({
|
||||
id: 1,
|
||||
customAttributes: ['plan'],
|
||||
});
|
||||
|
||||
expect(CompanyAPI.destroyCustomAttributes).toHaveBeenCalledWith(1, [
|
||||
'plan',
|
||||
]);
|
||||
expect(companiesStore.getRecord(1)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
region: 'us',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
setAuthCredentials,
|
||||
throwErrorMessage,
|
||||
clearLocalStorageOnLogout,
|
||||
parseAPIErrorResponse,
|
||||
} from 'dashboard/store/utils/api';
|
||||
import wootAPI from './apiClient';
|
||||
import {
|
||||
@@ -42,8 +43,9 @@ export const login = async ({
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
throwErrorMessage(error);
|
||||
return null;
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const ERROR_MESSAGES = {
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
const USER_NOT_CONFIRMED_ERROR_CODE = 'user_not_confirmed';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -185,6 +186,15 @@ export default {
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
if (response?.errorCode === USER_NOT_CONFIRMED_ERROR_CODE) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.$router.push({
|
||||
name: 'auth_verify_email',
|
||||
state: { email: credentials.email },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset URL Params if the authentication is invalid
|
||||
if (this.email) {
|
||||
window.location = '/app/login';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
class Account::ContactsExportJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LABELS_COLUMN = 'labels'.freeze
|
||||
LABELS_DELIMITER = ','.freeze
|
||||
|
||||
def perform(account_id, user_id, column_names, params)
|
||||
@account = Account.find(account_id)
|
||||
@params = params
|
||||
@@ -14,16 +17,45 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
private
|
||||
|
||||
def generate_csv(headers)
|
||||
contacts_to_export = contacts.to_a
|
||||
preload_contact_labels(contacts_to_export) if headers.include?(LABELS_COLUMN)
|
||||
|
||||
csv_data = CSV.generate do |csv|
|
||||
csv << headers
|
||||
contacts.each do |contact|
|
||||
csv << headers.map { |header| contact.send(header) }
|
||||
contacts_to_export.each do |contact|
|
||||
csv << headers.map { |header| value_for_header(contact, header) }
|
||||
end
|
||||
end
|
||||
|
||||
attach_export_file(csv_data)
|
||||
end
|
||||
|
||||
def value_for_header(contact, header)
|
||||
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
|
||||
|
||||
contact.send(header)
|
||||
end
|
||||
|
||||
def approved_labels
|
||||
@approved_labels ||= @account.labels.pluck(:title)
|
||||
end
|
||||
|
||||
def preload_contact_labels(contacts_to_export)
|
||||
contact_ids = contacts_to_export.map(&:id)
|
||||
return if contact_ids.blank?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins(:tag)
|
||||
.where(context: LABELS_COLUMN, taggable_type: 'Contact', taggable_id: contact_ids)
|
||||
.where(tags: { name: approved_labels })
|
||||
.pluck(:taggable_id, 'tags.name')
|
||||
.each { |contact_id, label| contact_labels_by_id[contact_id] << label }
|
||||
end
|
||||
|
||||
def contact_labels_by_id
|
||||
@contact_labels_by_id ||= Hash.new { |hash, contact_id| hash[contact_id] = [] }
|
||||
end
|
||||
|
||||
def contacts
|
||||
if @params.present? && @params[:payload].present? && @params[:payload].any?
|
||||
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
|
||||
@@ -36,7 +68,12 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
end
|
||||
|
||||
def valid_headers(column_names)
|
||||
(column_names.presence || default_columns) & Contact.column_names
|
||||
requested_headers = column_names.presence || default_columns
|
||||
|
||||
# Keep requested header order while allowing the virtual labels column.
|
||||
requested_headers.select do |header|
|
||||
header == LABELS_COLUMN || Contact.column_names.include?(header)
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def attach_export_file(csv_data)
|
||||
@@ -65,6 +102,6 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
end
|
||||
|
||||
def default_columns
|
||||
%w[id name email phone_number]
|
||||
%w[id name email phone_number labels]
|
||||
end
|
||||
end
|
||||
|
||||
+102
-7
@@ -5,6 +5,10 @@ class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
|
||||
|
||||
LABELS_DELIMITER = ','.freeze
|
||||
LABELS_CONTEXT = 'labels'.freeze
|
||||
CONTACT_TAGGABLE_TYPE = 'Contact'.freeze
|
||||
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
@contact_manager = DataImport::ContactManager.new(@data_import.account)
|
||||
@@ -33,26 +37,117 @@ class DataImportJob < ApplicationJob
|
||||
|
||||
with_import_file do |file|
|
||||
csv_reader(file).each do |row|
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
contacts << current_contact
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
build_contact_from_row(row, contacts, rejected_contacts)
|
||||
end
|
||||
end
|
||||
|
||||
[contacts, rejected_contacts]
|
||||
end
|
||||
|
||||
def build_contact_from_row(row, contacts, rejected_contacts)
|
||||
row_hash = row.to_h.with_indifferent_access
|
||||
labels = extract_labels(row_hash)
|
||||
invalid_labels = labels.map(&:downcase) - approved_labels
|
||||
|
||||
if invalid_labels.present?
|
||||
append_label_error(row, invalid_labels, rejected_contacts)
|
||||
return
|
||||
end
|
||||
|
||||
current_contact = @contact_manager.build_contact(row_hash.except(:labels))
|
||||
if current_contact.valid?
|
||||
contacts << { contact: current_contact, labels: labels }
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_labels(row_hash)
|
||||
row_hash[:labels].to_s.split(LABELS_DELIMITER).map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def append_rejected_contact(row, contact, rejected_contacts)
|
||||
row['errors'] = contact.errors.full_messages.join(', ')
|
||||
rejected_contacts << row
|
||||
end
|
||||
|
||||
def import_contacts(contacts)
|
||||
def import_contacts(contacts_with_labels)
|
||||
contacts = contacts_with_labels.pluck(:contact)
|
||||
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
|
||||
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
|
||||
apply_labels_to_contacts(contacts_with_labels)
|
||||
end
|
||||
|
||||
def apply_labels_to_contacts(contacts_with_labels)
|
||||
taggings = taggings_for_contacts(contacts_with_labels)
|
||||
return if taggings.blank?
|
||||
|
||||
ActsAsTaggableOn::Tagging.import(%i[tag_id taggable_type taggable_id context created_at],
|
||||
taggings, on_duplicate_key_ignore: true, validate: false, batch_size: 1000)
|
||||
end
|
||||
|
||||
def taggings_for_contacts(contacts_with_labels)
|
||||
tag_lookup = tags_by_label_name(contacts_with_labels)
|
||||
taggings = contacts_with_labels.flat_map do |item|
|
||||
contact = contact_for_label_import(item[:contact])
|
||||
labels = item[:labels].map(&:downcase).uniq
|
||||
next [] if contact&.id.blank?
|
||||
|
||||
labels.map do |label|
|
||||
[tag_lookup[label].id, CONTACT_TAGGABLE_TYPE, contact.id, LABELS_CONTEXT]
|
||||
end
|
||||
end.uniq
|
||||
|
||||
reject_existing_taggings(taggings).map { |tagging| tagging + [Time.zone.now] }
|
||||
end
|
||||
|
||||
def reject_existing_taggings(taggings)
|
||||
tag_ids = taggings.map { |tag_id, _taggable_type, _taggable_id, _context| tag_id }
|
||||
taggable_ids = taggings.map { |_tag_id, _taggable_type, taggable_id, _context| taggable_id }
|
||||
existing_taggings = ActsAsTaggableOn::Tagging
|
||||
.where(context: LABELS_CONTEXT, taggable_type: CONTACT_TAGGABLE_TYPE,
|
||||
taggable_id: taggable_ids, tag_id: tag_ids)
|
||||
.pluck(:tag_id, :taggable_id)
|
||||
.index_with(true)
|
||||
|
||||
taggings.reject do |tag_id, _taggable_type, taggable_id, _context|
|
||||
existing_taggings[[tag_id, taggable_id]]
|
||||
end
|
||||
end
|
||||
|
||||
def contact_for_label_import(contact)
|
||||
return contact if contact.id.present?
|
||||
|
||||
key = contact_identity_key(contact)
|
||||
return if key.blank?
|
||||
|
||||
imported_contact(contact)
|
||||
end
|
||||
|
||||
def contact_identity_key(contact)
|
||||
contact.identifier.presence || contact.email.presence || contact.phone_number.presence
|
||||
end
|
||||
|
||||
def imported_contact(contact)
|
||||
return @data_import.account.contacts.find_by(identifier: contact.identifier) if contact.identifier.present?
|
||||
return @data_import.account.contacts.from_email(contact.email) if contact.email.present?
|
||||
|
||||
@data_import.account.contacts.find_by(phone_number: contact.phone_number) if contact.phone_number.present?
|
||||
end
|
||||
|
||||
def tags_by_label_name(contacts_with_labels)
|
||||
labels = contacts_with_labels.flat_map { |item| item[:labels] }.map(&:downcase).uniq
|
||||
|
||||
ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(labels).index_by { |tag| tag.name.downcase }
|
||||
end
|
||||
|
||||
def approved_labels
|
||||
@approved_labels ||= @data_import.account.labels.pluck(:title)
|
||||
end
|
||||
|
||||
def append_label_error(row, labels, rejected_contacts)
|
||||
row['errors'] = "Unknown labels: #{labels.join(', ')}"
|
||||
rejected_contacts << row
|
||||
end
|
||||
|
||||
def update_data_import_status(processed_records, rejected_records)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Labels::RemoveAssociationsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(label_title:, account_id:, label_deleted_at:)
|
||||
Labels::DestroyService.new(
|
||||
label_title: label_title,
|
||||
account_id: account_id,
|
||||
label_deleted_at: label_deleted_at
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
module MailboxHelper
|
||||
include MailboxInlineAttachmentHelper
|
||||
|
||||
private
|
||||
|
||||
def create_message
|
||||
@@ -24,6 +26,9 @@ module MailboxHelper
|
||||
def add_attachments_to_message
|
||||
return if @message.blank?
|
||||
|
||||
# Load email content once for all attachment processing
|
||||
load_email_content
|
||||
|
||||
# ensure we don't add more than the permitted number of attachments
|
||||
all_attachments = processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS)
|
||||
grouped_attachments = group_attachments(all_attachments)
|
||||
@@ -38,7 +43,7 @@ module MailboxHelper
|
||||
# If the email lacks a text body or if inline attachments aren't images,
|
||||
# treat them as standard attachments for processing.
|
||||
inline_attachments = attachments.select do |attachment|
|
||||
mail_content.present? && attachment[:original].inline? && attachment[:original].content_type.to_s.start_with?('image/')
|
||||
inline_attachment?(attachment)
|
||||
end
|
||||
|
||||
regular_attachments = attachments - inline_attachments
|
||||
@@ -59,11 +64,6 @@ module MailboxHelper
|
||||
def process_inline_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
|
||||
|
||||
# create an instance variable here, the `embed_inline_image_source`
|
||||
# updates them directly. And then the value is eventaully used to update the message content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@text_content = processed_mail.serialized_data[:text_content][:reply]
|
||||
|
||||
attachments.each do |mail_attachment|
|
||||
embed_inline_image_source(mail_attachment)
|
||||
end
|
||||
@@ -81,12 +81,6 @@ module MailboxHelper
|
||||
end
|
||||
end
|
||||
|
||||
def upload_inline_image(mail_attachment)
|
||||
content_id = mail_attachment[:original].cid
|
||||
|
||||
@html_content = @html_content.gsub("cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s)
|
||||
end
|
||||
|
||||
def embed_plain_text_email_with_inline_image(mail_attachment)
|
||||
attachment_name = mail_attachment[:original].filename
|
||||
img_tag = "<img src=\"#{inline_image_url(mail_attachment[:blob])}\" alt=\"#{attachment_name}\">"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
module MailboxInlineAttachmentHelper
|
||||
private
|
||||
|
||||
def load_email_content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@text_content = processed_mail.serialized_data[:text_content][:reply]
|
||||
end
|
||||
|
||||
def inline_attachment?(attachment)
|
||||
# Only process images as potential inline attachments
|
||||
return false unless mail_content.present? && attachment[:original].content_type.to_s.start_with?('image/')
|
||||
|
||||
# Check if attachment is explicitly marked as inline
|
||||
return true if attachment[:original].inline?
|
||||
|
||||
# For Outlook compatibility: if not marked as inline but has CID and is referenced in body
|
||||
cid = attachment[:original].cid
|
||||
cid.present? && body_references_cid?(cid)
|
||||
end
|
||||
|
||||
def body_references_cid?(cid)
|
||||
# Check if CID is referenced in HTML content
|
||||
return false if @html_content.blank?
|
||||
|
||||
cid_urls_for(cid).any? { |cid_url| @html_content.include?(cid_url) }
|
||||
end
|
||||
|
||||
def upload_inline_image(mail_attachment)
|
||||
content_id = mail_attachment[:original].cid
|
||||
image_url = inline_image_url(mail_attachment[:blob]).to_s
|
||||
|
||||
cid_urls_for(content_id).each do |cid_url|
|
||||
@html_content = @html_content.gsub(cid_url, image_url)
|
||||
end
|
||||
end
|
||||
|
||||
def cid_urls_for(cid)
|
||||
# RFC 2392 cid URLs can contain URL-encoded Content-ID values.
|
||||
# Check both raw and encoded variants so clients using either form render inline images.
|
||||
encoded_cid = ERB::Util.url_encode(cid)
|
||||
lowercase_encoded_cid = encoded_cid.gsub(/%[0-9A-F]{2}/, &:downcase)
|
||||
|
||||
["cid:#{cid}", "cid:#{encoded_cid}", "cid:#{lowercase_encoded_cid}"].uniq
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,8 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked],
|
||||
:company => %w[name domain description contacts_count created_at updated_at last_activity_at]
|
||||
}.freeze
|
||||
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
@@ -41,12 +42,12 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
validates :attribute_model, presence: true
|
||||
validate :attribute_must_not_conflict, on: :create
|
||||
|
||||
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
|
||||
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1, company_attribute: 2 }
|
||||
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
|
||||
|
||||
belongs_to :account
|
||||
after_update :update_widget_pre_chat_custom_fields
|
||||
after_destroy :sync_widget_pre_chat_custom_fields
|
||||
after_update :update_widget_pre_chat_custom_fields, unless: :company_attribute?
|
||||
after_destroy :sync_widget_pre_chat_custom_fields, unless: :company_attribute?
|
||||
|
||||
private
|
||||
|
||||
@@ -64,8 +65,10 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
end
|
||||
|
||||
def attribute_must_not_conflict
|
||||
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
|
||||
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
|
||||
model_keys = attribute_model.to_s.delete_suffix('_attribute').to_sym
|
||||
standard_attributes = STANDARD_ATTRIBUTES[model_keys]
|
||||
return if standard_attributes.blank?
|
||||
return unless attribute_key.in?(standard_attributes)
|
||||
|
||||
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class CustomAttributeDefinitionPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
class Labels::DestroyService
|
||||
pattr_initialize [:label_title!, :account_id!, :label_deleted_at!]
|
||||
|
||||
def perform
|
||||
remove_conversation_labels
|
||||
remove_contact_labels
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_conversation_labels
|
||||
tagged_conversations.find_in_batches do |conversation_batch|
|
||||
conversation_batch.each do |conversation|
|
||||
update_conversation_cached_labels(conversation)
|
||||
end
|
||||
delete_label_taggings('Conversation', conversation_batch.map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
def remove_contact_labels
|
||||
contact_label_taggings.in_batches do |tagging_batch|
|
||||
ActsAsTaggableOn::Tagging.where(id: tagging_batch.select(:id)).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def update_conversation_cached_labels(conversation)
|
||||
label_list = conversation.label_list.dup
|
||||
label_list.remove(label_title)
|
||||
|
||||
# We only want the acts-as-taggable-on cache effect here, not Conversation callbacks/events.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_column(:cached_label_list, label_list.join("#{ActsAsTaggableOn.delimiter} "))
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def tagged_conversations
|
||||
account.conversations.where(id: label_taggings_for('Conversation').select(:taggable_id))
|
||||
end
|
||||
|
||||
def contact_label_taggings
|
||||
label_taggings_for('Contact').where(taggable_id: account.contacts.select(:id))
|
||||
end
|
||||
|
||||
def delete_label_taggings(taggable_type, taggable_ids)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.where(id: label_taggings_for(taggable_type).where(taggable_id: taggable_ids).select(:id))
|
||||
.delete_all
|
||||
end
|
||||
|
||||
def label_taggings_for(taggable_type)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins(:tag)
|
||||
.where(context: 'labels', taggable_type: taggable_type, tags: { name: label_title })
|
||||
.where('taggings.created_at <= ?', label_deleted_at)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
end
|
||||
@@ -177,6 +177,17 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :search
|
||||
end
|
||||
member do
|
||||
post :destroy_custom_attributes
|
||||
delete :avatar
|
||||
end
|
||||
scope module: :companies do
|
||||
resources :contacts, only: [:index, :create, :destroy] do
|
||||
collection do
|
||||
get :search
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||
collection do
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddAdditionalAttributesToCompanies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
change_table :companies, bulk: true do |t|
|
||||
t.jsonb :additional_attributes, default: {}
|
||||
t.jsonb :custom_attributes, default: {}
|
||||
t.datetime :last_activity_at, precision: nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -612,6 +612,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contacts_count"
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
RESULTS_PER_PAGE = 15
|
||||
CONTACT_SEARCH_QUERY = [
|
||||
'contacts.name ILIKE :search',
|
||||
'contacts.email ILIKE :search',
|
||||
'contacts.phone_number ILIKE :search',
|
||||
'contacts.identifier ILIKE :search'
|
||||
].join(' OR ')
|
||||
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :fetch_company
|
||||
before_action :authorize_company_read!, only: [:index, :search]
|
||||
before_action :authorize_company_update!, only: [:create, :destroy]
|
||||
before_action :set_current_page, only: [:index, :search]
|
||||
before_action :fetch_contact, only: [:destroy]
|
||||
|
||||
def index
|
||||
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def search
|
||||
if params[:q].blank?
|
||||
return render json: { error: 'Specify search string with parameter q' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@contacts = fetch_contacts(contact_search_scope)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def create
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
membership_service.assign(contact: @contact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
membership_service.remove(contact: @contact)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@company = Current.account.companies.find(params[:company_id])
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
@contact = @company.contacts.find(params[:id])
|
||||
end
|
||||
|
||||
def fetch_contacts(contacts)
|
||||
contacts
|
||||
.includes({ avatar_attachment: [:blob] }, :company)
|
||||
.page(@current_page)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def contact_search_scope
|
||||
Current.account.contacts
|
||||
.where('contacts.company_id IS NULL OR contacts.company_id != ?', @company.id)
|
||||
.where(CONTACT_SEARCH_QUERY, search: "%#{params[:q].strip}%")
|
||||
.order(:name, :id)
|
||||
end
|
||||
|
||||
def membership_service
|
||||
@membership_service ||= Companies::ContactMembershipService.new(company: @company)
|
||||
end
|
||||
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def authorize_company_read!
|
||||
authorize(@company, :show?)
|
||||
end
|
||||
|
||||
def authorize_company_update!
|
||||
authorize(@company, :update?)
|
||||
end
|
||||
end
|
||||
@@ -3,13 +3,15 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
sort_on :name, type: :string
|
||||
sort_on :domain, type: :string
|
||||
sort_on :created_at, type: :datetime
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :search]
|
||||
before_action :fetch_company, only: [:show, :update, :destroy]
|
||||
before_action :fetch_company, only: [:show, :update, :destroy, :avatar, :destroy_custom_attributes]
|
||||
|
||||
def index
|
||||
@companies = fetch_companies(resolved_companies)
|
||||
@@ -35,7 +37,15 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def update
|
||||
@company.update!(company_params)
|
||||
@company.update!(company_update_params)
|
||||
end
|
||||
|
||||
def destroy_custom_attributes
|
||||
custom_attributes = custom_attributes_to_destroy
|
||||
return if performed?
|
||||
|
||||
@company.custom_attributes = @company.custom_attributes.excluding(*custom_attributes)
|
||||
@company.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -43,6 +53,10 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
head :ok
|
||||
end
|
||||
|
||||
def avatar
|
||||
@company.avatar.purge if @company.avatar.attached?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolved_companies
|
||||
@@ -59,10 +73,10 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
authorize(Company)
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@@ -70,6 +84,31 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def company_params
|
||||
params.require(:company).permit(:name, :domain, :description, :avatar)
|
||||
params.require(:company).permit(
|
||||
:name,
|
||||
:domain,
|
||||
:description,
|
||||
:avatar,
|
||||
additional_attributes: {},
|
||||
custom_attributes: {}
|
||||
)
|
||||
end
|
||||
|
||||
def company_custom_attributes
|
||||
custom_attributes = company_params[:custom_attributes]
|
||||
return @company.custom_attributes.merge(custom_attributes.to_h) if custom_attributes.present?
|
||||
|
||||
@company.custom_attributes
|
||||
end
|
||||
|
||||
def company_update_params
|
||||
company_params.except(:custom_attributes).merge(custom_attributes: company_custom_attributes)
|
||||
end
|
||||
|
||||
def custom_attributes_to_destroy
|
||||
custom_attributes = params.permit(custom_attributes: [])[:custom_attributes]
|
||||
return custom_attributes if custom_attributes.present? || params[:custom_attributes].is_a?(Array)
|
||||
|
||||
render json: { error: 'custom_attributes must be an array' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,12 +3,13 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
|
||||
PER_ACCOUNT_HOURLY_CAP = 50
|
||||
GLOBAL_HOURLY_CAP = 1000
|
||||
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
|
||||
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
|
||||
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
sync_intervals = Enterprise::Account.captain_document_sync_intervals
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
break if @remaining_global_capacity <= 0
|
||||
@@ -21,7 +22,9 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
next unless interval
|
||||
|
||||
stats[:accounts_scheduled] += 1
|
||||
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
|
||||
result = enqueue_due_documents(account, interval)
|
||||
stats[:documents_enqueued] += result[:enqueued]
|
||||
stats[:documents_skipped] += result[:skipped]
|
||||
end
|
||||
|
||||
log_scheduler_summary(stats)
|
||||
@@ -30,28 +33,74 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
private
|
||||
|
||||
def enqueue_due_documents(account, interval)
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
result = { enqueued: 0, skipped: 0 }
|
||||
skipped_document_ids = []
|
||||
|
||||
while result[:enqueued] < per_account_limit
|
||||
|
||||
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
|
||||
break if documents.empty?
|
||||
|
||||
documents.each do |document|
|
||||
break if result[:enqueued] >= per_account_limit
|
||||
|
||||
process_due_document(document, result, skipped_document_ids)
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_due_document(document, result, skipped_document_ids)
|
||||
return unless document.syncable?
|
||||
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
unless reserve_sync_slot(document)
|
||||
result[:skipped] += 1
|
||||
skipped_document_ids << document.id
|
||||
return
|
||||
end
|
||||
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
result[:enqueued] += 1
|
||||
end
|
||||
|
||||
def due_documents(account, interval, skipped_document_ids)
|
||||
syncing = Captain::Document.sync_statuses[:syncing]
|
||||
synced = Captain::Document.sync_statuses[:synced]
|
||||
failed = Captain::Document.sync_statuses[:failed]
|
||||
stale_cutoff = SYNC_STALE_TIMEOUT.ago
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
enqueued_count = 0
|
||||
|
||||
account.captain_documents.syncable.where(status: :available).where(
|
||||
documents = account.captain_documents.syncable.where(status: :available).where(
|
||||
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
|
||||
'(sync_status = ? AND last_sync_attempted_at < ?)',
|
||||
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
|
||||
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
|
||||
next unless document.syncable?
|
||||
synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
|
||||
)
|
||||
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
|
||||
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
|
||||
end
|
||||
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
enqueued_count += 1
|
||||
end
|
||||
def reserve_sync_slot(document)
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
log_document_skip(document, e)
|
||||
false
|
||||
end
|
||||
|
||||
enqueued_count
|
||||
def log_document_skip(document, error)
|
||||
payload = {
|
||||
event: 'document_skipped',
|
||||
document_id: document.id,
|
||||
account_id: document.account_id,
|
||||
assistant_id: document.assistant_id,
|
||||
error_class: error.class.name,
|
||||
error_message: error.message,
|
||||
validation_errors: document.errors.full_messages
|
||||
}
|
||||
|
||||
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def log_scheduler_summary(stats)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# additional_attributes :jsonb
|
||||
# custom_attributes :jsonb
|
||||
# last_activity_at :datetime
|
||||
# id :bigint not null, primary key
|
||||
# contacts_count :integer
|
||||
# description :text
|
||||
@@ -19,6 +22,7 @@
|
||||
#
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
@@ -27,9 +31,11 @@ class Company < ApplicationRecord
|
||||
}
|
||||
validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
|
||||
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :custom_attributes, jsonb_attributes_length: true
|
||||
|
||||
belongs_to :account
|
||||
has_many :contacts, dependent: :nullify
|
||||
before_validation :prepare_jsonb_attributes
|
||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
@@ -44,9 +50,21 @@ class Company < ApplicationRecord
|
||||
)
|
||||
)
|
||||
}
|
||||
scope :order_on_last_activity_at, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
sanitize_sql_for_order("\"companies\".\"last_activity_at\" #{direction} NULLS LAST")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private
|
||||
|
||||
def prepare_jsonb_attributes
|
||||
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
|
||||
self.custom_attributes = {} unless custom_attributes.is_a?(Hash)
|
||||
end
|
||||
|
||||
def fetch_favicon
|
||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,14 @@ class CompanyPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def avatar?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy_custom_attributes?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Companies::ContactMembershipService
|
||||
attr_reader :company
|
||||
|
||||
def initialize(company:)
|
||||
@company = company
|
||||
end
|
||||
|
||||
def assign(contact:)
|
||||
contact.update!(company: company)
|
||||
end
|
||||
|
||||
def remove(contact:)
|
||||
contact.update!(company: nil)
|
||||
end
|
||||
end
|
||||
@@ -4,15 +4,17 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
DEFAULT_QUANTITY = 2
|
||||
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
active_sub = active_subscription
|
||||
return false if active_sub && !default_plan_subscription?(active_sub)
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
|
||||
subscription = active_sub || Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
|
||||
custom_attributes = build_custom_attributes(customer_id, subscription)
|
||||
custom_attributes.except!('is_creating_customer')
|
||||
|
||||
account.update!(custom_attributes: custom_attributes)
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
@@ -44,18 +46,21 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
price_ids.first
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
def active_subscription
|
||||
stripe_customer_id = account.custom_attributes['stripe_customer_id']
|
||||
return false if stripe_customer_id.blank?
|
||||
return nil if stripe_customer_id.blank?
|
||||
|
||||
subscriptions = Stripe::Subscription.list(
|
||||
Stripe::Subscription.list(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
)
|
||||
subscriptions.data.present?
|
||||
).data.first
|
||||
end
|
||||
|
||||
def default_plan_subscription?(subscription)
|
||||
default_plan['price_ids'].include?(subscription['plan']['id'])
|
||||
end
|
||||
|
||||
def build_custom_attributes(customer_id, subscription)
|
||||
|
||||
@@ -47,9 +47,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
|
||||
def current_plan_credits
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return { responses: 0, documents: 0 } if plan_name.blank?
|
||||
|
||||
get_plan_credits(plan_name)
|
||||
plan_credits = get_plan_credits(plan_name) if plan_name.present?
|
||||
plan_credits || { responses: 0, documents: 0 }
|
||||
end
|
||||
|
||||
def update_account_attributes(subscription, plan)
|
||||
@@ -71,19 +70,28 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
# skipping self hosted plan events
|
||||
return if account.blank?
|
||||
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
previous_monthly_credits = current_plan_credits[:responses]
|
||||
return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
|
||||
account.with_lock do
|
||||
previous_usage = { responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: previous_monthly_credits }
|
||||
adjust_captain_credits(previous_usage, new_plan_credits: 0)
|
||||
account.reset_response_usage
|
||||
end
|
||||
end
|
||||
|
||||
def handle_subscription_credits(plan, previous_usage)
|
||||
current_limits = account.limits || {}
|
||||
adjust_captain_credits(previous_usage, new_plan_credits: get_plan_credits(plan['name'])[:responses])
|
||||
end
|
||||
|
||||
def adjust_captain_credits(previous_usage, new_plan_credits:)
|
||||
current_limits = account.limits || {}
|
||||
current_credits = current_limits['captain_responses'].to_i
|
||||
new_plan_credits = get_plan_credits(plan['name'])[:responses]
|
||||
|
||||
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
|
||||
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
|
||||
updated_credits = [current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits, 0].max
|
||||
|
||||
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||
Rails.logger.info("Updating captain credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
json.id company.id
|
||||
json.name company.name
|
||||
json.contacts_count company.contacts_count
|
||||
json.domain company.domain
|
||||
json.description company.description
|
||||
json.avatar_url company.avatar_url
|
||||
json.created_at company.created_at
|
||||
json.updated_at company.updated_at
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.partial! 'api/v1/models/contact', formats: [:json], resource: contact, with_contact_inboxes: false
|
||||
json.company_id contact.company_id
|
||||
json.linked_to_current_company contact.company_id == @company.id
|
||||
if contact.company.present?
|
||||
json.company do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: contact.company
|
||||
end
|
||||
else
|
||||
json.company nil
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: @contact
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.meta do
|
||||
json.total_count @contacts_count
|
||||
json.page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: contact
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.meta do
|
||||
json.total_count @contacts_count
|
||||
json.page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: contact
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
@@ -5,6 +5,6 @@ end
|
||||
|
||||
json.payload do
|
||||
json.array! @companies do |company|
|
||||
json.partial! 'company', company: company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: company
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,6 @@ end
|
||||
|
||||
json.payload do
|
||||
json.array! @companies do |company|
|
||||
json.partial! 'company', company: company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: company
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.contacts_count resource.contacts_count
|
||||
json.domain resource.domain
|
||||
json.description resource.description
|
||||
json.custom_attributes resource.custom_attributes
|
||||
json.avatar_url resource.avatar_url
|
||||
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
|
||||
json.created_at resource.created_at.to_i if resource[:created_at].present?
|
||||
json.updated_at resource.updated_at.to_i if resource[:updated_at].present?
|
||||
+1
-1
@@ -94,7 +94,7 @@
|
||||
"tinykeys": "^3.0.0",
|
||||
"turbolinks": "^5.2.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"video.js": "7.18.1",
|
||||
"video.js": "7.21.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"virtua": "^0.48.6",
|
||||
|
||||
Generated
+34
-51
@@ -206,8 +206,8 @@ importers:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
video.js:
|
||||
specifier: 7.18.1
|
||||
version: 7.18.1
|
||||
specifier: 7.21.1
|
||||
version: 7.21.1
|
||||
videojs-record:
|
||||
specifier: 4.5.0
|
||||
version: 4.5.0
|
||||
@@ -441,10 +441,6 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.25.6':
|
||||
resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.26.7':
|
||||
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1386,17 +1382,14 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@videojs/http-streaming@2.13.1':
|
||||
resolution: {integrity: sha512-1x3fkGSPyL0+iaS3/lTvfnPTtfqzfgG+ELQtPPtTvDwqGol9Mx3TNyZwtSTdIufBrqYRn7XybB/3QNMsyjq13A==}
|
||||
'@videojs/http-streaming@2.15.1':
|
||||
resolution: {integrity: sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
peerDependencies:
|
||||
video.js: ^6 || ^7
|
||||
|
||||
'@videojs/vhs-utils@3.0.4':
|
||||
resolution: {integrity: sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
|
||||
'@videojs/vhs-utils@3.0.5':
|
||||
resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
@@ -1570,8 +1563,8 @@ packages:
|
||||
'@vueuse/shared@12.0.0':
|
||||
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
|
||||
|
||||
'@xmldom/xmldom@0.7.13':
|
||||
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
@@ -1618,8 +1611,8 @@ packages:
|
||||
activestorage@5.2.8:
|
||||
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
|
||||
|
||||
aes-decrypter@3.1.2:
|
||||
resolution: {integrity: sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==}
|
||||
aes-decrypter@3.1.3:
|
||||
resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
@@ -3197,8 +3190,8 @@ packages:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
m3u8-parser@4.7.0:
|
||||
resolution: {integrity: sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ==}
|
||||
m3u8-parser@4.8.0:
|
||||
resolution: {integrity: sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==}
|
||||
|
||||
magic-string@0.30.11:
|
||||
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
|
||||
@@ -3309,8 +3302,8 @@ packages:
|
||||
mlly@1.8.1:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||
mpd-parser@0.22.1:
|
||||
resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==}
|
||||
hasBin: true
|
||||
|
||||
mri@1.2.0:
|
||||
@@ -4497,8 +4490,8 @@ packages:
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
video.js@7.18.1:
|
||||
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
|
||||
video.js@7.21.1:
|
||||
resolution: {integrity: sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==}
|
||||
|
||||
videojs-font@3.2.0:
|
||||
resolution: {integrity: sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==}
|
||||
@@ -4981,10 +4974,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.26.0
|
||||
|
||||
'@babel/runtime@7.25.6':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.26.7':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
@@ -5919,22 +5908,16 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@videojs/http-streaming@2.13.1(video.js@7.18.1)':
|
||||
'@videojs/http-streaming@2.15.1(video.js@7.21.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.4
|
||||
aes-decrypter: 3.1.2
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
aes-decrypter: 3.1.3
|
||||
global: 4.4.0
|
||||
m3u8-parser: 4.7.0
|
||||
mpd-parser: 0.21.0
|
||||
m3u8-parser: 4.8.0
|
||||
mpd-parser: 0.22.1
|
||||
mux.js: 6.0.1
|
||||
video.js: 7.18.1
|
||||
|
||||
'@videojs/vhs-utils@3.0.4':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
global: 4.4.0
|
||||
url-toolkit: 2.2.5
|
||||
video.js: 7.21.1
|
||||
|
||||
'@videojs/vhs-utils@3.0.5':
|
||||
dependencies:
|
||||
@@ -6213,7 +6196,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@xmldom/xmldom@0.7.13': {}
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
abab@2.0.6: {}
|
||||
|
||||
@@ -6248,7 +6231,7 @@ snapshots:
|
||||
dependencies:
|
||||
spark-md5: 3.0.2
|
||||
|
||||
aes-decrypter@3.1.2:
|
||||
aes-decrypter@3.1.3:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
@@ -8106,7 +8089,7 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
m3u8-parser@4.7.0:
|
||||
m3u8-parser@4.8.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
@@ -8220,11 +8203,11 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
mpd-parser@0.22.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
'@xmldom/xmldom': 0.7.13
|
||||
'@xmldom/xmldom': 0.8.13
|
||||
global: 4.4.0
|
||||
|
||||
mri@1.2.0: {}
|
||||
@@ -9526,17 +9509,17 @@ snapshots:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
video.js@7.18.1:
|
||||
video.js@7.21.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.6
|
||||
'@videojs/http-streaming': 2.13.1(video.js@7.18.1)
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/http-streaming': 2.15.1(video.js@7.21.1)
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
'@videojs/xhr': 2.6.0
|
||||
aes-decrypter: 3.1.2
|
||||
aes-decrypter: 3.1.3
|
||||
global: 4.4.0
|
||||
keycode: 2.2.1
|
||||
m3u8-parser: 4.7.0
|
||||
mpd-parser: 0.21.0
|
||||
m3u8-parser: 4.8.0
|
||||
mpd-parser: 0.22.1
|
||||
mux.js: 6.0.1
|
||||
safe-json-parse: 4.0.0
|
||||
videojs-font: 3.2.0
|
||||
@@ -9547,7 +9530,7 @@ snapshots:
|
||||
videojs-record@4.5.0:
|
||||
dependencies:
|
||||
recordrtc: 5.6.2
|
||||
video.js: 7.18.1
|
||||
video.js: 7.21.1
|
||||
videojs-wavesurfer: 3.8.0
|
||||
webrtc-adapter: 9.0.1
|
||||
|
||||
@@ -9557,7 +9540,7 @@ snapshots:
|
||||
|
||||
videojs-wavesurfer@3.8.0:
|
||||
dependencies:
|
||||
video.js: 7.18.1
|
||||
video.js: 7.21.1
|
||||
wavesurfer.js: 7.8.6
|
||||
|
||||
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
id,name,email,identifier,phone_number,ip_address,company_name,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Acme Inc,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Acme Inc,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Acme Inc,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Acme Inc,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Acme Inc,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Acme Inc,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Acme Inc,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Acme Inc,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Acme Inc,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Acme Inc,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Acme Inc,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Acme Inc,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Acme Inc,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Acme Inc,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Acme Inc,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Acme Inc,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Acme Inc,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Acme Inc,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Acme Inc,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Acme Inc,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Acme Inc,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Acme Inc,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Acme Inc,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Acme Inc,Random-value23,Random-value23
|
||||
id,name,email,identifier,phone_number,labels,ip_address,company_name,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,,168.186.4.241,Acme Inc,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,,73.44.41.59,Acme Inc,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,,115.249.27.155,Acme Inc,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,,219.181.212.8,Acme Inc,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,,231.210.115.166,Acme Inc,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,,160.189.41.11,Acme Inc,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,,53.94.18.201,Acme Inc,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,,18.87.247.249,Acme Inc,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,,25.191.96.124,Acme Inc,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,,11.211.174.93,Acme Inc,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,,47.26.205.153,Acme Inc,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,,157.196.34.166,Acme Inc,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,,109.231.152.148,Acme Inc,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,,20.43.146.179,Acme Inc,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,,179.76.226.171,Acme Inc,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,,92.243.194.160,Acme Inc,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,,25.22.86.101,Acme Inc,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,,150.249.116.118,Acme Inc,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,,237.167.197.197,Acme Inc,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,,88.102.64.113,Acme Inc,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,,141.248.89.29,Acme Inc,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,,118.123.138.115,Acme Inc,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,,157.45.102.235,Acme Inc,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,,170.73.198.47,Acme Inc,Random-value23,Random-value23
|
||||
|
||||
|
@@ -2,7 +2,8 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/custom_attribute_definitions' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -19,7 +20,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
create(:custom_attribute_definition, attribute_model: 'contact_attribute', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -45,7 +46,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'shows the custom attribute definition' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -81,7 +82,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates the filter' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: user.create_new_auth_token,
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: admin.create_new_auth_token,
|
||||
params: payload
|
||||
end.to change(CustomAttributeDefinition, :count).by(1)
|
||||
|
||||
@@ -90,6 +91,18 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
expect(json_response['attribute_key']).to eq 'developer_id'
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not create the custom attribute' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload
|
||||
end.not_to change(CustomAttributeDefinition, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when creating with a conflicting attribute_key' do
|
||||
let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
|
||||
let(:conflicting_payload) do
|
||||
@@ -105,7 +118,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
|
||||
it 'returns error for conflicting key' do
|
||||
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
params: conflicting_payload
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
@@ -132,7 +145,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'updates the custom attribute definition' do
|
||||
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -141,6 +154,19 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
expect(custom_attribute_definition.reload.attribute_model).to eq('conversation_attribute')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not update the custom attribute' do
|
||||
original_name = custom_attribute_definition.attribute_display_name
|
||||
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(custom_attribute_definition.reload.attribute_display_name).to eq(original_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do
|
||||
@@ -156,11 +182,22 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
||||
context 'when it is an authenticated admin user' do
|
||||
it 'deletes custom attribute' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: user.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(account.custom_attribute_definitions.count).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns forbidden and does not delete the custom attribute' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.custom_attribute_definitions.count).to be 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user