feat: Companies page (#12842)
# Pull Request Template ## Description This PR introduces a new Companies section in the Chatwoot dashboard. It lists all companies associated with the account and includes features such as **search**, **sorting**, and **pagination** to enable easier navigation and efficient management. Fixes https://linear.app/chatwoot/issue/CW-5928/add-companies-tab-to-dashboard ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screenshot <img width="1619" height="1200" alt="image" src="https://github.com/user-attachments/assets/21f0a666-c3d6-4dec-bd02-1e38e0cd9542" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
37
app/javascript/dashboard/api/companies.js
Normal file
37
app/javascript/dashboard/api/companies.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/* 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;
|
||||
};
|
||||
|
||||
class CompanyAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('companies', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
const { page = 1, sort = 'name' } = params;
|
||||
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
search(query = '', page = 1, sort = 'name') {
|
||||
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CompanyAPI();
|
||||
142
app/javascript/dashboard/api/specs/companies.spec.js
Normal file
142
app/javascript/dashboard/api/specs/companies.spec.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import companyAPI, {
|
||||
buildCompanyParams,
|
||||
buildSearchParams,
|
||||
} from '../companies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#CompanyAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
patch: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get with default 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', () => {
|
||||
companyAPI.search('acme & co', 2, 'domain');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=acme%20%26%20co&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', () => {
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
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 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showCompany']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const onClickViewDetails = () => emit('showCompany', props.id);
|
||||
|
||||
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 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout layout="row" @click="onClickViewDetails">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar
|
||||
:username="displayName"
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="domain && description"
|
||||
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>
|
||||
</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">
|
||||
<span
|
||||
v-if="domain && !description"
|
||||
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>
|
||||
</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', { count: contactsCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="updatedAt"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ formattedUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
searchValue: { type: String, default: '' },
|
||||
headerTitle: { type: String, required: true },
|
||||
activeSort: { type: String, default: 'last_activity_at' },
|
||||
activeOrdering: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'update:sort']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<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 v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
:placeholder="$t('CONTACTS_LAYOUT.HEADER.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',
|
||||
]"
|
||||
class="w-full"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed, toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:sort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const sortMenus = [
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
|
||||
value: 'name',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
|
||||
value: 'domain',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
{
|
||||
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
|
||||
// Converted the props to refs for better reactivity
|
||||
const activeSort = toRef(props, 'activeSort');
|
||||
|
||||
const activeOrdering = toRef(props, 'activeOrdering');
|
||||
|
||||
const activeSortLabel = computed(() => {
|
||||
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
||||
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
|
||||
});
|
||||
|
||||
const activeOrderingLabel = computed(() => {
|
||||
const selectedMenu = orderingMenus.find(
|
||||
menu => menu.value === activeOrdering.value
|
||||
);
|
||||
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
|
||||
});
|
||||
|
||||
const handleSortChange = value => {
|
||||
emit('update:sort', { sort: value, order: props.activeOrdering });
|
||||
};
|
||||
|
||||
const handleOrderChange = value => {
|
||||
emit('update:sort', { sort: props.activeSort, order: value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-lucide-arrow-down-up"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
/>
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('COMPANIES.SORT_BY.LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="activeSort"
|
||||
:options="sortMenus"
|
||||
:label="activeSortLabel"
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('COMPANIES.ORDER.LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="activeOrdering"
|
||||
:options="orderingMenus"
|
||||
:label="activeOrderingLabel"
|
||||
@update:model-value="handleOrderChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
defineProps({
|
||||
searchValue: { type: String, default: '' },
|
||||
headerTitle: { type: String, default: '' },
|
||||
currentPage: { type: Number, default: 1 },
|
||||
totalItems: { type: Number, default: 100 },
|
||||
activeSort: { type: String, default: 'name' },
|
||||
activeOrdering: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<CompanyHeader
|
||||
:search-value="searchValue"
|
||||
:header-title="headerTitle"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="25"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -351,6 +351,23 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Companies',
|
||||
label: t('SIDEBAR.COMPANIES'),
|
||||
icon: 'i-lucide-building-2',
|
||||
children: [
|
||||
{
|
||||
name: 'All Companies',
|
||||
label: t('SIDEBAR.ALL_COMPANIES'),
|
||||
to: accountScopedRoute(
|
||||
'companies_dashboard_index',
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['companies_dashboard_index'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
label: t('SIDEBAR.REPORTS'),
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
32
app/javascript/dashboard/i18n/locale/en/companies.json
Normal file
32
app/javascript/dashboard/i18n/locale/en/companies.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"COMPANIES": {
|
||||
"HEADER": "Companies",
|
||||
"SORT_BY": {
|
||||
"LABEL": "Sort by",
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
"LABEL": "Order",
|
||||
"OPTIONS": {
|
||||
"ASCENDING": "Ascending",
|
||||
"DESCENDING": "Descending"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{count} contacts",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
}
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json';
|
||||
import campaign from './campaign.json';
|
||||
import cannedMgmt from './cannedMgmt.json';
|
||||
import chatlist from './chatlist.json';
|
||||
import companies from './companies.json';
|
||||
import components from './components.json';
|
||||
import contact from './contact.json';
|
||||
import contactFilters from './contactFilters.json';
|
||||
@@ -49,6 +50,7 @@ export default {
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
...companies,
|
||||
...components,
|
||||
...contact,
|
||||
...contactFilters,
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"SETTINGS": "Settings",
|
||||
"CONTACTS": "Contacts",
|
||||
"ACTIVE": "Active",
|
||||
"COMPANIES": "Companies",
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"CAPTAIN": "Captain",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = computed(() => route.query?.search || '');
|
||||
const searchValue = ref(searchQuery.value);
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
|
||||
const activeSort = computed(() => {
|
||||
const sortParam = route.query?.sort || 'name';
|
||||
return sortParam.startsWith('-') ? sortParam.slice(1) : sortParam;
|
||||
});
|
||||
|
||||
const activeOrdering = computed(() => {
|
||||
const sortParam = route.query?.sort || 'name';
|
||||
return sortParam.startsWith('-') ? '-' : '';
|
||||
});
|
||||
|
||||
const companies = useMapGetter('companies/getCompaniesList');
|
||||
const meta = useMapGetter('companies/getMeta');
|
||||
const uiFlags = useMapGetter('companies/getUIFlags');
|
||||
|
||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const sortParam = computed(() => {
|
||||
return activeOrdering.value === '-'
|
||||
? `-${activeSort.value}`
|
||||
: activeSort.value;
|
||||
});
|
||||
|
||||
const updateURLParams = (page, search = '', sort = '') => {
|
||||
const query = {
|
||||
...route.query,
|
||||
page: page.toString(),
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
} else {
|
||||
delete query.search;
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
query.sort = sort;
|
||||
} else {
|
||||
delete query.sort;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchCompanies = async (page, search, sort) => {
|
||||
const currentPage = page ?? pageNumber.value;
|
||||
const currentSearch = search ?? searchQuery.value;
|
||||
const currentSort = sort ?? sortParam.value;
|
||||
|
||||
// Only update URL if arguments were explicitly provided
|
||||
if (page !== undefined || search !== undefined || sort !== undefined) {
|
||||
updateURLParams(currentPage, currentSearch, currentSort);
|
||||
}
|
||||
|
||||
if (currentSearch) {
|
||||
await store.dispatch('companies/search', {
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
} else {
|
||||
await store.dispatch('companies/get', {
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = debounce(query => {
|
||||
searchValue.value = query;
|
||||
fetchCompanies(1, query, sortParam.value);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const onPageChange = page => {
|
||||
fetchCompanies(page, searchValue.value, sortParam.value);
|
||||
};
|
||||
|
||||
const handleSort = ({ sort, order }) => {
|
||||
const newSortParam = order === '-' ? `-${sort}` : sort;
|
||||
fetchCompanies(1, searchValue.value, newSortParam);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
searchValue.value = searchQuery.value;
|
||||
fetchCompanies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompaniesListLayout
|
||||
:search-value="searchValue"
|
||||
:header-title="t('COMPANIES.HEADER')"
|
||||
:current-page="pageNumber"
|
||||
:total-items="Number(meta.totalCount || 0)"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
:is-fetching-list="isFetchingList"
|
||||
@update:current-page="onPageChange"
|
||||
@update:sort="handleSort"
|
||||
@search="onSearch"
|
||||
>
|
||||
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
|
||||
<span class="text-n-slate-11 text-base">{{
|
||||
t('COMPANIES.LOADING')
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="companies.length === 0"
|
||||
class="flex items-center justify-center p-8"
|
||||
>
|
||||
<span class="text-n-slate-11 text-base">{{
|
||||
t('COMPANIES.EMPTY_STATE.TITLE')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 p-4">
|
||||
<CompaniesCard
|
||||
v-for="company in companies"
|
||||
:id="company.id"
|
||||
:key="company.id"
|
||||
:name="company.name"
|
||||
:domain="company.domain"
|
||||
:contacts-count="company.contactsCount || 0"
|
||||
:description="company.description"
|
||||
:avatar-url="company.avatarUrl"
|
||||
:updated-at="company.updatedAt"
|
||||
/>
|
||||
</div>
|
||||
</CompaniesListLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CompaniesIndex from './pages/CompaniesIndex.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
|
||||
const commonMeta = {
|
||||
featureFlag: FEATURE_FLAGS.COMPANIES,
|
||||
permissions: ['administrator', 'agent'],
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/companies'),
|
||||
component: CompaniesIndex,
|
||||
meta: commonMeta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'companies_dashboard_index',
|
||||
component: CompaniesIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -2,6 +2,7 @@ import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as companyRoutes } from './companies/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { routes as inboxRoutes } from './inbox/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
@@ -23,6 +24,7 @@ export default {
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
...contactRoutes,
|
||||
...companyRoutes,
|
||||
...searchRoutes,
|
||||
...notificationRoutes,
|
||||
...helpcenterRoutes.routes,
|
||||
|
||||
@@ -14,6 +14,7 @@ import bulkActions from './modules/bulkActions';
|
||||
import campaigns from './modules/campaigns';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import categories from './modules/helpCenterCategories';
|
||||
import companies from './modules/companies';
|
||||
import contactConversations from './modules/contactConversations';
|
||||
import contactLabels from './modules/contactLabels';
|
||||
import contactNotes from './modules/contactNotes';
|
||||
@@ -77,6 +78,7 @@ export default createStore({
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
categories,
|
||||
companies,
|
||||
contactConversations,
|
||||
contactLabels,
|
||||
contactNotes,
|
||||
|
||||
29
app/javascript/dashboard/store/modules/companies.js
Normal file
29
app/javascript/dashboard/store/modules/companies.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/captain/storeFactory';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default createStore({
|
||||
name: 'Company',
|
||||
API: CompanyAPI,
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
},
|
||||
actions: mutationTypes => ({
|
||||
search: async ({ commit }, { search, page, sort }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
commit(mutationTypes.SET, payload);
|
||||
commit(mutationTypes.SET_META, meta);
|
||||
} catch (error) {
|
||||
// Error
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user