355 lines
10 KiB
Vue
355 lines
10 KiB
Vue
<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">
|
|
<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 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong 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 bg-transparent"
|
|
@update:current-page="emit('update:currentPage', $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|