feat(companies): add notes and history to company details (#14401)
This commit is contained in:
@@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
}
|
||||
|
||||
listNotes(id) {
|
||||
return axios.get(`${this.url}/${id}/notes`);
|
||||
}
|
||||
|
||||
listConversations(id) {
|
||||
return axios.get(`${this.url}/${id}/conversations`);
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||
return axios.get(requestURL);
|
||||
|
||||
@@ -56,9 +56,14 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<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"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -105,9 +110,14 @@ const closeMobileSidebar = () => {
|
||||
<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"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<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">
|
||||
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
|
||||
|
||||
<div
|
||||
v-else-if="!hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||
</div>
|
||||
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
|
||||
:current-page="currentPage"
|
||||
:total-items="totalContacts"
|
||||
:items-per-page="15"
|
||||
class="!px-0 before:hidden"
|
||||
class="!px-0 before:hidden bg-transparent"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
const conversationContact = conversation => {
|
||||
const sender = conversation.meta?.sender || {};
|
||||
const contact = contactsById.value(sender.id);
|
||||
return contact.id ? contact : sender;
|
||||
};
|
||||
const conversationInbox = conversation =>
|
||||
stateInbox.value(conversation.inboxId) || {
|
||||
name: '',
|
||||
channelType: conversation.meta?.channel,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="conversations.length > 0"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="conversationContact(conversation)"
|
||||
:state-inbox="conversationInbox(conversation)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const hasNotes = computed(() => props.notes.length > 0);
|
||||
|
||||
const contactName = contact =>
|
||||
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
|
||||
const getWrittenBy = note => {
|
||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||
return isCurrentUser
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: note?.user?.name || 'Bot';
|
||||
};
|
||||
|
||||
const openContact = contactId => {
|
||||
router.push({
|
||||
name: 'contacts_edit',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasNotes" class="flex flex-col px-6">
|
||||
<div class="flex flex-col divide-y divide-n-strong">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="flex flex-col gap-2 py-4 group/note"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
:name="contactName(note.contact)"
|
||||
:src="note.contact?.thumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
|
||||
@click="openContact(note.contact.id)"
|
||||
>
|
||||
{{ contactName(note.contact) }}
|
||||
</button>
|
||||
<div class="min-w-0 truncate">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
|
||||
>
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ getWrittenBy(note) }}
|
||||
</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium text-n-slate-11">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -44,9 +44,17 @@
|
||||
"SIDEBAR": {
|
||||
"TABS": {
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"CONTACTS": "Contacts"
|
||||
"CONTACTS": "Contacts",
|
||||
"HISTORY": "History",
|
||||
"NOTES": "Notes"
|
||||
}
|
||||
},
|
||||
"HISTORY": {
|
||||
"EMPTY": "No conversations found for this company's contacts yet."
|
||||
},
|
||||
"NOTES": {
|
||||
"EMPTY": "No notes found for this company's contacts yet."
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"EMPTY_STATE": "There are no company custom attributes configured yet.",
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
|
||||
import CompanyHistorySidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyHistorySidebar.vue';
|
||||
import CompanyNotesSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.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';
|
||||
@@ -20,11 +23,16 @@ const { t } = useI18n();
|
||||
|
||||
const confirmDeleteDialogRef = ref(null);
|
||||
const selectedCandidate = ref(null);
|
||||
const activeSidebarTab = ref('history');
|
||||
|
||||
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 companyConversations = computed(
|
||||
() => companiesStore.companyConversations || []
|
||||
);
|
||||
const companyNotes = computed(() => companiesStore.companyNotes || []);
|
||||
const contactSearchResults = computed(
|
||||
() => companiesStore.contactSearchResults
|
||||
);
|
||||
@@ -32,6 +40,10 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
|
||||
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
|
||||
const isFetchingConversations = computed(
|
||||
() => uiFlags.value.fetchingConversations
|
||||
);
|
||||
const isFetchingNotes = computed(() => uiFlags.value.fetchingNotes);
|
||||
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
|
||||
const isManagingContacts = computed(
|
||||
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
|
||||
@@ -50,6 +62,27 @@ const breadcrumbItems = computed(() => [
|
||||
: []),
|
||||
]);
|
||||
|
||||
const SIDEBAR_TABS_OPTIONS = [
|
||||
{ key: 'HISTORY', value: 'history' },
|
||||
{ key: 'NOTES', value: 'notes' },
|
||||
{ key: 'CONTACTS', value: 'contacts' },
|
||||
];
|
||||
|
||||
const sidebarTabs = computed(() =>
|
||||
SIDEBAR_TABS_OPTIONS.map(tab => ({
|
||||
label: {
|
||||
notes: t('COMPANIES.DETAIL.SIDEBAR.TABS.NOTES'),
|
||||
history: t('COMPANIES.DETAIL.SIDEBAR.TABS.HISTORY'),
|
||||
contacts: `${t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS')} (${Number(companyContactsMeta.value.totalCount || 0)})`,
|
||||
}[tab.value],
|
||||
value: tab.value,
|
||||
}))
|
||||
);
|
||||
|
||||
const activeSidebarTabIndex = computed(() =>
|
||||
SIDEBAR_TABS_OPTIONS.findIndex(tab => tab.value === activeSidebarTab.value)
|
||||
);
|
||||
|
||||
const goToCompaniesIndex = () => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_index',
|
||||
@@ -79,6 +112,19 @@ const clearSelectedCandidate = () => {
|
||||
selectedCandidate.value = null;
|
||||
};
|
||||
|
||||
const loadSidebarTab = tab => {
|
||||
if (!companyId.value) return;
|
||||
if (tab === 'notes') companiesStore.getCompanyNotes(companyId.value);
|
||||
if (tab === 'history') {
|
||||
companiesStore.getCompanyConversations(companyId.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSidebarTabChange = tab => {
|
||||
activeSidebarTab.value = tab.value;
|
||||
loadSidebarTab(tab.value);
|
||||
};
|
||||
|
||||
const handleContactSearch = async query => {
|
||||
await companiesStore.searchCompanyContactCandidates({
|
||||
companyId: companyId.value,
|
||||
@@ -143,10 +189,12 @@ watch(
|
||||
async id => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
clearSelectedCandidate();
|
||||
activeSidebarTab.value = 'history';
|
||||
if (!id) return;
|
||||
await Promise.allSettled([
|
||||
companiesStore.show(id),
|
||||
companiesStore.getCompanyContacts(id),
|
||||
companiesStore.getCompanyConversations(id),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -207,8 +255,29 @@ onBeforeUnmount(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<template #sidebarHeader>
|
||||
<div class="px-6 pt-6 pb-3">
|
||||
<TabBar
|
||||
:tabs="sidebarTabs"
|
||||
:initial-active-tab="activeSidebarTabIndex"
|
||||
class="w-full [&>button]:w-full bg-n-alpha-black2"
|
||||
@tab-changed="handleSidebarTabChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="hasCompany" #sidebar>
|
||||
<CompanyNotesSidebar
|
||||
v-if="activeSidebarTab === 'notes'"
|
||||
:notes="companyNotes"
|
||||
:is-loading="isFetchingNotes"
|
||||
/>
|
||||
<CompanyHistorySidebar
|
||||
v-if="activeSidebarTab === 'history'"
|
||||
:conversations="companyConversations"
|
||||
:is-loading="isFetchingConversations"
|
||||
/>
|
||||
<CompanyContactsSidebar
|
||||
v-if="activeSidebarTab === 'contacts'"
|
||||
:company="company"
|
||||
:contacts="companyContacts"
|
||||
:meta="companyContactsMeta"
|
||||
|
||||
@@ -134,10 +134,13 @@ export const createVuexStore = options => {
|
||||
* @returns {Function} Pinia store composable
|
||||
*/
|
||||
export const createPiniaStore = options => {
|
||||
const { name, API, actions, getters } = options;
|
||||
const { name, API, actions, getters, state } = options;
|
||||
|
||||
return defineStore(name.toLowerCase(), {
|
||||
state: createInitialState,
|
||||
state: () => ({
|
||||
...createInitialState(),
|
||||
...(state ? state() : {}),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
...createGetters(),
|
||||
|
||||
@@ -13,6 +13,8 @@ const createInitialUIFlags = () => ({
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
fetchingConversations: false,
|
||||
fetchingNotes: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
@@ -67,6 +69,16 @@ export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
state: () => ({
|
||||
activeCompanyId: null,
|
||||
companyContacts: [],
|
||||
companyContactsMeta: {},
|
||||
companyConversations: [],
|
||||
companyNotes: [],
|
||||
contactSearchResults: [],
|
||||
contactSearchMeta: {},
|
||||
activeContactSearchQuery: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getCompaniesList: state => state.records,
|
||||
@@ -270,6 +282,74 @@ export const useCompaniesStore = createStore({
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyNotes(companyId) {
|
||||
this.setUIFlag({ fetchingNotes: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyNotesRequestToken || 0) + 1;
|
||||
this.companyNotesRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.listNotes(companyId);
|
||||
const notes = camelcaseKeys(payload || [], { deep: true });
|
||||
|
||||
if (
|
||||
this.companyNotesRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return notes;
|
||||
}
|
||||
|
||||
this.companyNotes = notes;
|
||||
return notes;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (
|
||||
this.companyNotesRequestToken === requestToken &&
|
||||
this.activeCompanyId === activeCompanyId
|
||||
) {
|
||||
this.setUIFlag({ fetchingNotes: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyConversations(companyId) {
|
||||
this.setUIFlag({ fetchingConversations: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyConversationsRequestToken || 0) + 1;
|
||||
this.companyConversationsRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.listConversations(companyId);
|
||||
const conversations = camelcaseKeys(payload || [], { deep: true });
|
||||
|
||||
if (
|
||||
this.companyConversationsRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
this.companyConversations = conversations;
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (
|
||||
this.companyConversationsRequestToken === requestToken &&
|
||||
this.activeCompanyId === activeCompanyId
|
||||
) {
|
||||
this.setUIFlag({ fetchingConversations: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
|
||||
const query = search?.trim() || '';
|
||||
if (!query) {
|
||||
@@ -379,10 +459,15 @@ export const useCompaniesStore = createStore({
|
||||
(this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken =
|
||||
(this.companyContactsRequestToken || 0) + 1;
|
||||
this.companyConversationsRequestToken =
|
||||
(this.companyConversationsRequestToken || 0) + 1;
|
||||
this.companyNotesRequestToken = (this.companyNotesRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
this.companyContacts = [];
|
||||
this.companyContactsMeta = {};
|
||||
this.companyConversations = [];
|
||||
this.companyNotes = [];
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
|
||||
Reference in New Issue
Block a user