Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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', { n: 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 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
<span class="text-heading-1 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,120 @@
|
||||
<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',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
},
|
||||
];
|
||||
|
||||
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,52 @@
|
||||
<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: '' },
|
||||
showPaginationFooter: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
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-surface-1"
|
||||
>
|
||||
<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 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||
<PaginationFooter
|
||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="25"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user