feat(companies): add notes and history to company details (#14401)

This commit is contained in:
Sojan Jose
2026-05-11 23:15:25 +05:30
committed by GitHub
parent f6be0d80ef
commit 086aa36ffe
16 changed files with 440 additions and 31 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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 = '';