feat(companies): add company detail page (#14054)

This commit is contained in:
salmonumbrella
2026-05-06 20:50:27 +05:30
committed by GitHub
parent 42bba748cf
commit fe44b07147
49 changed files with 2461 additions and 193 deletions
@@ -111,6 +111,16 @@ 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 handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -123,6 +133,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>
@@ -162,9 +177,9 @@ 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>
</CompaniesListLayout>
@@ -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,
},
],
},
];
@@ -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(() =>