Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/message-bubble
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
.mx-input {
|
||||
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
|
||||
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
|
||||
}
|
||||
|
||||
.mx-input:disabled,
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
|
||||
.mx-datepicker-main {
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
@apply border-0 bg-n-solid-2 rounded-xl;
|
||||
|
||||
.cell {
|
||||
&.disabled {
|
||||
@@ -53,6 +53,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mx-calendar+.mx-calendar {
|
||||
@apply border-l border-n-weak;
|
||||
}
|
||||
|
||||
.mx-datepicker-footer {
|
||||
@apply border border-n-weak;
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-4">
|
||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
primaryContactId: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
primaryContactList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:primaryContactId', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div 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">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
:model-value="primaryContactId"
|
||||
:options="primaryContactList"
|
||||
:empty-state="
|
||||
isSearching
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
|
||||
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
|
||||
"
|
||||
:search-placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
|
||||
:has-error="hasError"
|
||||
:message="errorMessage"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
@update:model-value="value => emit('update:primaryContactId', value)"
|
||||
@search="query => emit('search', query)"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex justify-center gap-2 top-4">
|
||||
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
|
||||
<div
|
||||
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
|
||||
/>
|
||||
<div
|
||||
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div 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">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
|
||||
>
|
||||
<Avatar
|
||||
:name="selectedContact.name || ''"
|
||||
:src="selectedContact.thumbnail || ''"
|
||||
:size="32"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm leading-4 truncate text-n-slate-11">
|
||||
{{ selectedContact.name }}
|
||||
</span>
|
||||
<span class="text-sm leading-4 truncate text-n-slate-11">
|
||||
{{ selectedContact.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import ContactMergeForm from '../ContactMergeForm.vue';
|
||||
import { contactData, primaryContactList } from './fixtures';
|
||||
|
||||
const handleSearch = query => {
|
||||
console.log('Searching for:', query);
|
||||
};
|
||||
|
||||
const handleUpdate = value => {
|
||||
console.log('Primary contact updated:', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactMergeForm"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Selected Primary Contact">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="1"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Error State">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
has-error
|
||||
error-message="Please select a primary contact"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty Primary Contact List">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="[]"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import ContactsForm from '../ContactsForm.vue';
|
||||
import contactData from './fixtures';
|
||||
import { contactData } from './fixtures';
|
||||
|
||||
const handleUpdate = updatedData => {
|
||||
console.log('Form updated:', updatedData);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default {
|
||||
export const contactData = {
|
||||
id: 370,
|
||||
name: 'John Doe',
|
||||
email: 'johndoe@chatwoot.com',
|
||||
@@ -18,3 +18,30 @@ export default {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const primaryContactList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 1) Jane Smith',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Mike Johnson',
|
||||
email: 'mike@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 2) Mike Johnson',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Sarah Wilson',
|
||||
email: 'sarah@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 3) Sarah Wilson',
|
||||
value: 3,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'search',
|
||||
'filter',
|
||||
'update:sort',
|
||||
'message',
|
||||
'add',
|
||||
'import',
|
||||
'export',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<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',
|
||||
]"
|
||||
@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 class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="i-lucide-list-filter"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
if (action === 'add') {
|
||||
emit('add');
|
||||
} else if (action === 'import') {
|
||||
emit('import');
|
||||
} else if (action === 'export') {
|
||||
emit('export');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
|
||||
@click="showActionsDropdown = !showActionsDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="contactMenuItems"
|
||||
class="right-0 mt-1 w-52 top-full"
|
||||
@action="handleContactAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<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: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:sort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const sortMenus = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
|
||||
value: 'name',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
||||
value: 'phone_number',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||
value: 'company_name',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
|
||||
value: 'country',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
|
||||
value: 'city',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
|
||||
value: 'last_activity_at',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.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('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
|
||||
);
|
||||
});
|
||||
|
||||
const activeOrderingLabel = computed(() => {
|
||||
const selectedMenu = orderingMenus.find(
|
||||
menu => menu.value === activeOrdering.value
|
||||
);
|
||||
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.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 right-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('CONTACTS_LAYOUT.HEADER.ACTIONS.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('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="activeOrdering"
|
||||
:options="orderingMenus"
|
||||
:label="activeOrderingLabel"
|
||||
@update:model-value="handleOrderChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ContactHeader from '../ContactHeader.vue';
|
||||
|
||||
// Base state controls
|
||||
const searchValue = ref('');
|
||||
const activeSort = ref('last_activity_at');
|
||||
const activeOrdering = ref('');
|
||||
|
||||
const onSearch = value => {
|
||||
searchValue.value = value;
|
||||
console.log('🔍 Search:', value);
|
||||
};
|
||||
|
||||
const onSort = ({ sort, order }) => {
|
||||
activeSort.value = sort;
|
||||
activeOrdering.value = order;
|
||||
console.log('🔄 Sort changed:', { sort, order });
|
||||
};
|
||||
|
||||
const onFilter = () => {
|
||||
console.log('🏷️ Filter clicked');
|
||||
};
|
||||
|
||||
const onMessage = () => {
|
||||
console.log('💬 Message clicked');
|
||||
};
|
||||
|
||||
const onAdd = () => {
|
||||
console.log('➕ Add contact clicked');
|
||||
};
|
||||
|
||||
const onImport = () => {
|
||||
console.log('📥 Import contacts clicked');
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
console.log('📤 Export contacts clicked');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactHeader"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="w-full h-[400px]">
|
||||
<ContactHeader
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Segment View">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Segment: VIP Customers"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
writtenBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.note.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
|
||||
<Avatar
|
||||
:name="note.user.name"
|
||||
:src="note.user.thumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 truncate">
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium">{{ writtenBy }}</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
class="opacity-0 group-hover/note:opacity-100"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import ContactNoteItem from '../ContactNoteItem.vue';
|
||||
import notes from './fixtures';
|
||||
|
||||
const controls = {
|
||||
writtenBy: {
|
||||
type: 'text',
|
||||
default: 'You',
|
||||
},
|
||||
};
|
||||
|
||||
// Example delete handler
|
||||
const onDelete = noteId => {
|
||||
console.log('Note deleted:', noteId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactNoteItem"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Multiple Notes">
|
||||
<div class="flex flex-col border rounded-lg border-n-strong">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:written-by="
|
||||
note.id === notes[1].id
|
||||
? controls.writtenBy.default
|
||||
: note.user.name
|
||||
"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
export default [
|
||||
{
|
||||
id: 12,
|
||||
content:
|
||||
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 30,
|
||||
account_id: 2,
|
||||
availability_status: 'offline',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'bruce@paperlayer.test',
|
||||
available_name: 'Bruce',
|
||||
name: 'Bruce',
|
||||
role: 'administrator',
|
||||
thumbnail:
|
||||
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730786556,
|
||||
updatedAt: 1730786556,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
content:
|
||||
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 1,
|
||||
account_id: 2,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
confirmed: true,
|
||||
email: 'hillary@chatwoot.com',
|
||||
available_name: 'Hillary',
|
||||
name: 'Hillary',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730782566,
|
||||
updatedAt: 1730782566,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
content:
|
||||
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 1,
|
||||
account_id: 2,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
available_name: 'John',
|
||||
name: 'John',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730782564,
|
||||
updatedAt: 1730782564,
|
||||
},
|
||||
];
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import ContactEmptyState from './ContactEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/EmptyState"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<!-- Default Story -->
|
||||
<Variant title="Default">
|
||||
<ContactEmptyState
|
||||
title="No contacts found"
|
||||
subtitle="Create your first contact to get started"
|
||||
button-label="Add Contact"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Without Button -->
|
||||
<Variant title="Without Button">
|
||||
<ContactEmptyState
|
||||
title="No contacts"
|
||||
subtitle="These are your current contacts"
|
||||
:show-button="false"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ContactsCard
|
||||
v-for="contact in contactContent.slice(0, 5)"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:is-expanded="0 === contact.id"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="showButton">
|
||||
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
export default [
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
twitter: 'candice_w_dev',
|
||||
facebook: 'candice.dev',
|
||||
linkedin: 'candice-matherson',
|
||||
instagram: 'candice.codes',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
dateContact: '2024-11-11T11:53:09.299Z',
|
||||
linkContact: 'https://example.com',
|
||||
listContact: 'Follow-Up',
|
||||
textContact: 'Hi there!',
|
||||
numberContact: '42',
|
||||
checkboxContact: false,
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'San Francisco',
|
||||
country: 'United States',
|
||||
description: 'Passionate about design and user experience.',
|
||||
companyName: 'Designify',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'ophelia-folkard',
|
||||
twitter: 'oph_designs',
|
||||
facebook: 'ophelia.folkard',
|
||||
linkedin: 'ophelia-folkard',
|
||||
instagram: 'ophelia.design',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'ophelia.folkard@designify.com',
|
||||
id: 21,
|
||||
name: 'Ophelia Folkard',
|
||||
phoneNumber: '+14155552672',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
dateContact: '2024-10-05T10:12:34.567Z',
|
||||
linkContact: 'https://designify.com',
|
||||
listContact: 'Prospects',
|
||||
textContact: 'Looking forward to connecting!',
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Austin',
|
||||
country: 'United States',
|
||||
description: 'Avid coder and tech enthusiast.',
|
||||
companyName: 'CodeHub',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'willy_castelot',
|
||||
twitter: 'willy_code',
|
||||
facebook: 'willy.castelot',
|
||||
linkedin: 'willy-castelot',
|
||||
instagram: 'willy.coder',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy.castelot@codehub.io',
|
||||
id: 20,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+14155552673',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
textContact: 'Let’s collaborate!',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Seattle',
|
||||
country: 'United States',
|
||||
description: 'Product manager with a love for innovation.',
|
||||
companyName: 'InnovaTech',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'elisabeth-d',
|
||||
twitter: 'elisabeth_innova',
|
||||
facebook: 'elisabeth.derington',
|
||||
linkedin: 'elisabeth-derington',
|
||||
instagram: 'elisabeth.innovates',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'elisabeth.derington@innova.com',
|
||||
id: 19,
|
||||
name: 'Elisabeth Derington',
|
||||
phoneNumber: '+14155552674',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
textContact: 'Let’s schedule a call.',
|
||||
},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Chicago',
|
||||
country: 'United States',
|
||||
description: 'Marketing specialist and content creator.',
|
||||
companyName: 'Contently',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'olia-olenchenko',
|
||||
twitter: 'olia_content',
|
||||
facebook: 'olia.olenchenko',
|
||||
linkedin: 'olia-olenchenko',
|
||||
instagram: 'olia.creates',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'olia.olenchenko@contently.com',
|
||||
id: 18,
|
||||
name: 'Olia Olenchenko',
|
||||
phoneNumber: '+14155552675',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Boston',
|
||||
country: 'United States',
|
||||
description: 'SEO expert and analytics enthusiast.',
|
||||
companyName: 'OptiSearch',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'nate-vannuchi',
|
||||
twitter: 'nate_seo',
|
||||
facebook: 'nathaniel.vannuchi',
|
||||
linkedin: 'nathaniel-vannuchi',
|
||||
instagram: 'nate.optimizes',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'nathaniel.vannuchi@optisearch.com',
|
||||
id: 17,
|
||||
name: 'Nathaniel Vannuchi',
|
||||
phoneNumber: '+14155552676',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Denver',
|
||||
country: 'United States',
|
||||
description: 'UI/UX designer with a flair for minimalist designs.',
|
||||
companyName: 'Minimal Designs',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'merrile-petruk',
|
||||
twitter: 'merrile_ux',
|
||||
facebook: 'merrile.petruk',
|
||||
linkedin: 'merrile-petruk',
|
||||
instagram: 'merrile.designs',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'merrile.petruk@minimal.com',
|
||||
id: 16,
|
||||
name: 'Merrile Petruk',
|
||||
phoneNumber: '+14155552677',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Miami',
|
||||
country: 'United States',
|
||||
description: 'Entrepreneur with a background in e-commerce.',
|
||||
companyName: 'Ecom Solutions',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'cordell-d',
|
||||
twitter: 'cordell_entrepreneur',
|
||||
facebook: 'cordell.dalinder',
|
||||
linkedin: 'cordell-dalinder',
|
||||
instagram: 'cordell.ecom',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'cordell.dalinder@ecomsolutions.com',
|
||||
id: 15,
|
||||
name: 'Cordell Dalinder',
|
||||
phoneNumber: '+14155552678',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const attributeValue = ref(Boolean(props.attribute.value));
|
||||
|
||||
const handleChange = value => {
|
||||
emit('update', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<Switch v-model="attributeValue" @change="handleChange" />
|
||||
<Button
|
||||
v-if="isEditingView"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isEditingValue = ref(false);
|
||||
const editedValue = ref(props.attribute.value || '');
|
||||
|
||||
const rules = {
|
||||
editedValue: {
|
||||
required,
|
||||
isDate: value => new Date(value).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { editedValue });
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return props.attribute.value
|
||||
? new Date(props.attribute.value).toLocaleDateString()
|
||||
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
|
||||
});
|
||||
|
||||
const hasError = computed(() => v$.value.$errors.length > 0);
|
||||
|
||||
const defaultDateValue = computed({
|
||||
get() {
|
||||
const existingDate = editedValue.value ?? props.attribute.value;
|
||||
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
|
||||
return isEditingValue.value && !hasError.value
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: '';
|
||||
},
|
||||
set(value) {
|
||||
editedValue.value = value ? new Date(value).toISOString() : value;
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEditValue = value => {
|
||||
isEditingValue.value =
|
||||
typeof value === 'boolean' ? value : !isEditingValue.value;
|
||||
|
||||
if (isEditingValue.value && !editedValue.value) {
|
||||
v$.value.$reset();
|
||||
editedValue.value = new Date().toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputUpdate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
emit('update', parseISO(editedValue.value));
|
||||
isEditingValue.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="!isEditingValue"
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate flex-1': isEditingView,
|
||||
}"
|
||||
@click="toggleEditValue(!isEditingView)"
|
||||
>
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="isEditingView && !isEditingValue"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleEditValue(true)"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditingValue"
|
||||
v-on-clickaway="() => toggleEditValue(false)"
|
||||
class="flex items-center w-full"
|
||||
>
|
||||
<Input
|
||||
v-model="defaultDateValue"
|
||||
type="date"
|
||||
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
|
||||
:message="
|
||||
hasError
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
|
||||
: ''
|
||||
"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
autofocus
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
:color="hasError ? 'ruby' : 'blue'"
|
||||
size="sm"
|
||||
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click="handleInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
|
||||
|
||||
const attributeListMenuItems = computed(() => {
|
||||
return (
|
||||
props.attribute.attributeValues?.map(value => ({
|
||||
label: value,
|
||||
value,
|
||||
action: 'select',
|
||||
isSelected: value === props.attribute.value,
|
||||
})) || []
|
||||
);
|
||||
});
|
||||
|
||||
const handleAttributeAction = async action => {
|
||||
emit('update', action.value);
|
||||
toggleAttributeListDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="() => toggleAttributeListDropdown(false)"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate flex-1': isEditingView,
|
||||
}"
|
||||
@click="toggleAttributeListDropdown(!props.isEditingView)"
|
||||
>
|
||||
{{
|
||||
attribute.value ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
|
||||
}}
|
||||
</span>
|
||||
<DropdownMenu
|
||||
v-if="showAttributeListDropdown"
|
||||
:menu-items="attributeListMenuItems"
|
||||
show-search
|
||||
class="w-48 mt-2 top-full"
|
||||
:class="{
|
||||
'ltr:right-0 rtl:left-0': !isEditingView,
|
||||
'ltr:left-0 rtl:right-0': isEditingView,
|
||||
}"
|
||||
@action="handleAttributeAction($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditingView" class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleAttributeListDropdown()"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!-- Attribute type "Text, URL, Number" -->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { isValidURL } from 'dashboard/helper/URLHelper.js';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const isEditingValue = ref(false);
|
||||
const editedValue = ref(props.attribute.value || '');
|
||||
|
||||
const isAttributeTypeLink = computed(
|
||||
() => props.attribute.attributeDisplayType === 'link'
|
||||
);
|
||||
|
||||
const isAttributeTypeText = computed(
|
||||
() => props.attribute.attributeDisplayType === 'text'
|
||||
);
|
||||
|
||||
const isAttributeTypeNumber = computed(
|
||||
() => props.attribute.attributeDisplayType === 'number'
|
||||
);
|
||||
|
||||
const rules = computed(() => ({
|
||||
editedValue: {
|
||||
required,
|
||||
...(isAttributeTypeLink.value && {
|
||||
url: value => !value || isValidURL(value),
|
||||
}),
|
||||
...(isAttributeTypeText.value &&
|
||||
props.attribute.regexPattern && {
|
||||
regexValidation: value => {
|
||||
if (!value) return true;
|
||||
return getRegexp(props.attribute.regexPattern).test(value);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const v$ = useVuelidate(rules, { editedValue });
|
||||
|
||||
const hasError = computed(() => v$.value.$error);
|
||||
|
||||
const attributeErrorMessage = computed(() => {
|
||||
if (!hasError.value) return '';
|
||||
|
||||
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
|
||||
if (
|
||||
isAttributeTypeText.value &&
|
||||
props.attribute.regexPattern &&
|
||||
v$.value.editedValue.regexValidation?.$invalid
|
||||
) {
|
||||
return (
|
||||
props.attribute.regexCue ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
|
||||
);
|
||||
}
|
||||
|
||||
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
|
||||
}
|
||||
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
});
|
||||
|
||||
const getInputType = computed(() => {
|
||||
switch (props.attribute.attributeDisplayType) {
|
||||
case 'link':
|
||||
return 'url';
|
||||
case 'number':
|
||||
return 'number';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
});
|
||||
|
||||
const toggleEditValue = value => {
|
||||
isEditingValue.value =
|
||||
typeof value === 'boolean' ? value : !isEditingValue.value;
|
||||
if (isEditingValue.value) {
|
||||
v$.value.$reset();
|
||||
editedValue.value = props.attribute.value || '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputUpdate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
emit('update', editedValue.value);
|
||||
toggleEditValue(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="!isEditingValue"
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate flex-1':
|
||||
isEditingView && !isAttributeTypeLink,
|
||||
'truncate flex-1 hover:text-n-brand text-n-blue-text':
|
||||
isEditingView && isAttributeTypeLink,
|
||||
}"
|
||||
@click="toggleEditValue(!isEditingView)"
|
||||
>
|
||||
<a
|
||||
v-if="isAttributeTypeLink && attribute.value && isEditingView"
|
||||
:href="attribute.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
{{ attribute.value }}
|
||||
</a>
|
||||
<template v-else>
|
||||
{{
|
||||
attribute.value ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="isEditingView && !isEditingValue"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleEditValue(true)"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditingValue"
|
||||
v-on-clickaway="() => toggleEditValue(false)"
|
||||
class="flex items-center w-full"
|
||||
>
|
||||
<Input
|
||||
v-model="editedValue"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
|
||||
:type="getInputType"
|
||||
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
|
||||
autofocus
|
||||
:message="attributeErrorMessage"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
:color="hasError ? 'ruby' : 'blue'"
|
||||
size="sm"
|
||||
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click="handleInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import Attributes from './fixtures';
|
||||
|
||||
import OtherAttribute from '../OtherAttribute.vue';
|
||||
import ListAttribute from '../ListAttribute.vue';
|
||||
import DateAttribute from '../DateAttribute.vue';
|
||||
import CheckboxAttribute from '../CheckboxAttribute.vue';
|
||||
|
||||
const componentMap = {
|
||||
list: ListAttribute,
|
||||
checkbox: CheckboxAttribute,
|
||||
date: DateAttribute,
|
||||
default: OtherAttribute,
|
||||
};
|
||||
|
||||
const getCurrentComponent = type => {
|
||||
return componentMap[type] || componentMap.default;
|
||||
};
|
||||
|
||||
const handleUpdate = (type, value) => {
|
||||
console.log(`${type} updated:`, value);
|
||||
};
|
||||
|
||||
const handleDelete = type => {
|
||||
console.log(`${type} deleted`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/CustomAttributes"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Create View">
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
|
||||
<div
|
||||
v-for="attribute in Attributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="getCurrentComponent(attribute.attributeDisplayType)"
|
||||
:attribute="attribute"
|
||||
@update="
|
||||
value => handleUpdate(attribute.attributeDisplayType, value)
|
||||
"
|
||||
@delete="() => handleDelete(attribute.attributeDisplayType)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Saved View">
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
|
||||
<div
|
||||
v-for="attribute in Attributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="getCurrentComponent(attribute.attributeDisplayType)"
|
||||
:attribute="attribute"
|
||||
is-editing-view
|
||||
@update="
|
||||
value => handleUpdate(attribute.attributeDisplayType, value)
|
||||
"
|
||||
@delete="() => handleDelete(attribute.attributeDisplayType)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
export default [
|
||||
{
|
||||
attributeKey: 'textContact',
|
||||
attributeDisplayName: 'Text Input',
|
||||
attributeDisplayType: 'text',
|
||||
value: 'Sample text value',
|
||||
},
|
||||
{
|
||||
attributeKey: 'linkContact',
|
||||
attributeDisplayName: 'URL Input',
|
||||
attributeDisplayType: 'link',
|
||||
value: 'https://www.chatwoot.com',
|
||||
},
|
||||
{
|
||||
attributeKey: 'numberContact',
|
||||
attributeDisplayName: 'Number Input',
|
||||
attributeDisplayType: 'number',
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
attributeKey: 'listContact',
|
||||
attributeDisplayName: 'List Input',
|
||||
attributeDisplayType: 'list',
|
||||
value: 'Option 2',
|
||||
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'dateContact',
|
||||
attributeDisplayName: 'Date Input',
|
||||
attributeDisplayType: 'date',
|
||||
value: '2024-03-25T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
attributeKey: 'checkboxContact',
|
||||
attributeDisplayName: 'Checkbox Input',
|
||||
attributeDisplayType: 'checkbox',
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
@@ -95,7 +95,7 @@ const togglePortalSwitcher = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-3">
|
||||
<div class="w-full max-w-[960px] mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+23
-4
@@ -27,6 +27,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'saveArticleAsync',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
@@ -35,19 +36,37 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
|
||||
const saveAndSync = value => {
|
||||
emit('saveArticle', value);
|
||||
};
|
||||
|
||||
// this will only send the data to the backend
|
||||
// but will not update the local state preventing unnecessary re-renders
|
||||
// since the data is already saved and we keep the editor text as the source of truth
|
||||
const quickSave = debounce(
|
||||
value => emit('saveArticleAsync', value),
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
||||
// so we can save the data to the backend and retrieve the updated data
|
||||
// this will update the local state with response data
|
||||
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
set: value => {
|
||||
saveArticle({ title: value });
|
||||
quickSave({ title: value });
|
||||
saveAndSyncDebounced({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => props.article.content,
|
||||
set: content => {
|
||||
saveArticle({ content });
|
||||
quickSave({ content });
|
||||
saveAndSyncDebounced({ content });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +112,7 @@ const previewArticle = () => {
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
:article="article"
|
||||
@save-article="saveArticle"
|
||||
@save-article="saveAndSync"
|
||||
@set-author="setAuthorId"
|
||||
@set-category="setCategoryId"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
defineProps({
|
||||
labelMenuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateLabel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<span class="i-lucide-plus" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('LABEL.TAG_BUTTON') }}
|
||||
</span>
|
||||
</button>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="() => (showDropdown = false)"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
@action="emit('updateLabel', $event)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<div
|
||||
class="rounded-sm size-2"
|
||||
:style="{ backgroundColor: item.thumbnail.color }"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 m-1 rounded-sm"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import AddLabel from '../AddLabel.vue';
|
||||
import { labelMenuItems } from './fixtures';
|
||||
|
||||
function onUpdateLabel(label) {
|
||||
console.log('Label updated:', label);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Label/Add Label">
|
||||
<Variant title="Default (button with label menu items with active state)">
|
||||
<div class="h-[300px] p-4">
|
||||
<AddLabel
|
||||
:label-menu-items="labelMenuItems"
|
||||
@update-label="onUpdateLabel"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty List (button with empty label menu)">
|
||||
<div class="h-[300px] p-4">
|
||||
<AddLabel :label-menu-items="[]" @update-label="onUpdateLabel" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import Label from '../LabelItem.vue';
|
||||
import { label } from './fixtures';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Label/Label item">
|
||||
<Variant title="Default">
|
||||
<Label :label="label" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Label">
|
||||
<Label
|
||||
:label="{
|
||||
title: 'Custom Label',
|
||||
color: '#FF5733',
|
||||
}"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
export const label = {
|
||||
id: 1,
|
||||
title: 'delivery',
|
||||
color: '#A2FDD5',
|
||||
};
|
||||
|
||||
export const labelMenuItems = [
|
||||
{
|
||||
label: 'delivery',
|
||||
value: 3,
|
||||
thumbnail: {
|
||||
color: '#A2FDD5',
|
||||
},
|
||||
isSelected: true,
|
||||
action: 'addLabel',
|
||||
},
|
||||
{
|
||||
label: 'lead',
|
||||
value: 6,
|
||||
thumbnail: {
|
||||
color: '#F161C8',
|
||||
},
|
||||
isSelected: false,
|
||||
action: 'addLabel',
|
||||
},
|
||||
{
|
||||
label: 'ops-handover',
|
||||
value: 4,
|
||||
thumbnail: {
|
||||
color: '#A53326',
|
||||
},
|
||||
isSelected: false,
|
||||
action: 'addLabel',
|
||||
},
|
||||
{
|
||||
label: 'billing',
|
||||
value: 1,
|
||||
thumbnail: {
|
||||
color: '#28AD21',
|
||||
},
|
||||
isSelected: false,
|
||||
action: 'addLabel',
|
||||
},
|
||||
{
|
||||
label: 'premium-customer',
|
||||
value: 5,
|
||||
thumbnail: {
|
||||
color: '#6FD4EF',
|
||||
},
|
||||
isSelected: false,
|
||||
action: 'addLabel',
|
||||
},
|
||||
{
|
||||
label: 'software',
|
||||
value: 2,
|
||||
thumbnail: {
|
||||
color: '#8F6EF2',
|
||||
},
|
||||
isSelected: false,
|
||||
action: 'addLabel',
|
||||
},
|
||||
];
|
||||
@@ -1,46 +1,85 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed, useSlots, useAttrs } from 'vue';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import {
|
||||
VARIANT_OPTIONS,
|
||||
COLOR_OPTIONS,
|
||||
SIZE_OPTIONS,
|
||||
EXCLUDED_ATTRS,
|
||||
} from './constants.js';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
label: { type: [String, Number], default: '' },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'solid',
|
||||
validator: value =>
|
||||
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
|
||||
default: null,
|
||||
validator: value => VARIANT_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: value =>
|
||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
||||
default: null,
|
||||
validator: value => COLOR_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
trailingIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: null,
|
||||
validator: value => SIZE_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const attrs = useAttrs();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const filteredAttrs = computed(() => {
|
||||
const standardAttrs = {};
|
||||
|
||||
Object.entries(attrs)
|
||||
.filter(([key]) => !EXCLUDED_ATTRS.includes(key))
|
||||
.forEach(([key, value]) => {
|
||||
standardAttrs[key] = value;
|
||||
});
|
||||
|
||||
return standardAttrs;
|
||||
});
|
||||
|
||||
const computedVariant = computed(() => {
|
||||
if (props.variant) return props.variant;
|
||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
||||
if (attrs.link || attrs.link === '') return 'link';
|
||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
||||
return 'solid'; // Default variant
|
||||
});
|
||||
|
||||
const computedColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
||||
return 'blue'; // Default color
|
||||
});
|
||||
|
||||
const computedSize = computed(() => {
|
||||
if (props.size) return props.size;
|
||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
||||
if (attrs.md || attrs.md === '') return 'md';
|
||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
||||
return 'md';
|
||||
});
|
||||
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
@@ -113,23 +152,24 @@ const STYLE_CONFIG = {
|
||||
const variantClasses = computed(() => {
|
||||
const variantMap = {
|
||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
||||
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
|
||||
outline: STYLE_CONFIG.colors[props.color].outline,
|
||||
faded: STYLE_CONFIG.colors[props.color].faded,
|
||||
solid: STYLE_CONFIG.colors[props.color].solid,
|
||||
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
|
||||
outline: STYLE_CONFIG.colors[computedColor.value].outline,
|
||||
faded: STYLE_CONFIG.colors[computedColor.value].faded,
|
||||
solid: STYLE_CONFIG.colors[computedColor.value].solid,
|
||||
};
|
||||
|
||||
return variantMap[props.variant];
|
||||
return variantMap[computedVariant.value];
|
||||
});
|
||||
|
||||
const isIconOnly = computed(() => !props.label && !slots.default);
|
||||
const isLink = computed(() => props.variant === 'link');
|
||||
const isLink = computed(() => computedVariant.value === 'link');
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
|
||||
computedVariant.value !== 'link' &&
|
||||
STYLE_CONFIG.sizes[sizeConfig][computedSize.value],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
@@ -138,7 +178,7 @@ const buttonClasses = computed(() => {
|
||||
const linkButtonClasses = computed(() => {
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
STYLE_CONFIG.sizes.link[props.size],
|
||||
STYLE_CONFIG.sizes.link[computedSize.value],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
@@ -147,10 +187,11 @@ const linkButtonClasses = computed(() => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-bind="filteredAttrs"
|
||||
:class="{
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[size]]: true,
|
||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
|
||||
export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
|
||||
export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
|
||||
|
||||
export const EXCLUDED_ATTRS = [
|
||||
'variant',
|
||||
'color',
|
||||
'size',
|
||||
'icon',
|
||||
'trailingIcon',
|
||||
'isLoading',
|
||||
...VARIANT_OPTIONS,
|
||||
...COLOR_OPTIONS,
|
||||
...SIZE_OPTIONS,
|
||||
];
|
||||
@@ -14,22 +14,22 @@ const menuItems = ref([
|
||||
{
|
||||
label: 'Contact Support',
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => window.alert('Contact Support'),
|
||||
click: () => console.log('Contact Support'),
|
||||
},
|
||||
{
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => window.alert('Keyboard Shortcuts'),
|
||||
click: () => console.log('Keyboard Shortcuts'),
|
||||
},
|
||||
{
|
||||
label: 'Profile Settings',
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => window.alert('Profile Settings'),
|
||||
click: () => console.log('Profile Settings'),
|
||||
},
|
||||
{
|
||||
label: 'Change Appearance',
|
||||
icon: 'i-lucide-swatch-book',
|
||||
click: () => window.alert('Change Appearance'),
|
||||
click: () => console.log('Change Appearance'),
|
||||
},
|
||||
{
|
||||
label: 'Open SuperAdmin',
|
||||
@@ -40,7 +40,7 @@ const menuItems = ref([
|
||||
{
|
||||
label: 'Log Out',
|
||||
icon: 'i-lucide-log-out',
|
||||
click: () => window.alert('Log Out'),
|
||||
click: () => console.log('Log Out'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { provideDropdownContext } from './provider.js';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@@ -20,9 +21,9 @@ provideDropdownContext({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20 space-y-2">
|
||||
<div class="relative space-y-2">
|
||||
<slot name="trigger" :is-open :toggle="() => toggle()" />
|
||||
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
|
||||
<div v-if="isOpen" v-on-click-outside="closeMenu" class="absolute">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ConditionRow from './ConditionRow.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import { filterTypes } from './fixtures/filterTypes.js';
|
||||
|
||||
const DEFAULT_FILTER = {
|
||||
attributeKey: 'status',
|
||||
filterOperator: 'equal_to',
|
||||
values: [],
|
||||
queryOperator: 'and',
|
||||
};
|
||||
|
||||
const filters = ref([{ ...DEFAULT_FILTER }]);
|
||||
|
||||
const removeFilter = index => {
|
||||
filters.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const showQueryOperator = true;
|
||||
|
||||
const addFilter = () => {
|
||||
filters.value.push({ ...DEFAULT_FILTER });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/ConditionRow"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<div class="min-h-[400px] p-2 space-y-2">
|
||||
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:show-query-operator="false"
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
|
||||
<ConditionRow
|
||||
v-else
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
:show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
<Button sm label="Add Filter" @click="addFilter" />
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
import MultiSelect from './inputs/MultiSelect.vue';
|
||||
import SingleSelect from './inputs/SingleSelect.vue';
|
||||
|
||||
import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
|
||||
// filterTypes: import('vue').ComputedRef<FilterType[]>
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
const { t } = useI18n();
|
||||
const showErrors = ref(false);
|
||||
|
||||
const attributeKey = defineModel('attributeKey', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const values = defineModel('values', {
|
||||
type: [String, Number, Array, Object],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const filterOperator = defineModel('filterOperator', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const queryOperator = defineModel('queryOperator', {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
validator: value => ['and', 'or'].includes(value),
|
||||
});
|
||||
|
||||
const getFilterFromFilterTypes = key =>
|
||||
filterTypes.find(filterObj => filterObj.attributeKey === key);
|
||||
|
||||
const currentFilter = computed(() =>
|
||||
getFilterFromFilterTypes(attributeKey.value)
|
||||
);
|
||||
|
||||
const getOperator = (filter, selectedOperator) => {
|
||||
const operatorFromOptions = filter.filterOperators.find(
|
||||
operator => operator.value === selectedOperator
|
||||
);
|
||||
|
||||
if (!operatorFromOptions) {
|
||||
return filter.filterOperators[0];
|
||||
}
|
||||
|
||||
return operatorFromOptions;
|
||||
};
|
||||
|
||||
const currentOperator = computed(() =>
|
||||
getOperator(currentFilter.value, filterOperator.value)
|
||||
);
|
||||
|
||||
const getInputType = (operator, filter) =>
|
||||
operator.inputOverride ?? filter.inputType;
|
||||
|
||||
const inputType = computed(() =>
|
||||
getInputType(currentOperator.value, currentFilter.value)
|
||||
);
|
||||
|
||||
const queryOperatorOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
|
||||
value: 'and',
|
||||
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
|
||||
},
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
|
||||
value: 'or',
|
||||
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const booleanOptions = computed(() => [
|
||||
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
|
||||
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
|
||||
]);
|
||||
|
||||
const validationError = computed(() => {
|
||||
return validateSingleFilter({
|
||||
attributeKey: attributeKey.value,
|
||||
filter_operator: filterOperator.value,
|
||||
values: values.value,
|
||||
});
|
||||
});
|
||||
|
||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
/**
|
||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||
* the values and operator remain compatible with the new attribute type. For example,
|
||||
* switching from a text field to a multi-select should reset the value from '' (empty string)
|
||||
* to an empty array.
|
||||
*/
|
||||
const filter = getFilterFromFilterTypes(newAttributeKey);
|
||||
const newOperator = getOperator(filter, filterOperator.value);
|
||||
const newInputType = getInputType(newOperator, filter);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
watch([attributeKey, values, filterOperator], () => {
|
||||
showErrors.value = false;
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
showErrors.value = true;
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-none">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md"
|
||||
:class="{
|
||||
'animate-wiggle': showErrors && validationError,
|
||||
}"
|
||||
>
|
||||
<FilterSelect
|
||||
v-if="showQueryOperator"
|
||||
v-model="queryOperator"
|
||||
variant="faded"
|
||||
hide-icon
|
||||
class="text-sm"
|
||||
:options="queryOperatorOptions"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="filterOperator"
|
||||
variant="ghost"
|
||||
:options="currentFilter.filterOperators"
|
||||
/>
|
||||
<template v-if="currentOperator.hasInput">
|
||||
<MultiSelect
|
||||
v-if="inputType === 'multiSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'searchSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
disable-search
|
||||
:options="booleanOptions"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="values"
|
||||
:type="inputType === 'date' ? 'date' : 'text'"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
|
||||
{{ t(`FILTER.ERRORS.${validationError}`) }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,558 @@
|
||||
export const filterTypes = [
|
||||
{
|
||||
attributeKey: 'status',
|
||||
value: 'status',
|
||||
attributeName: 'Status',
|
||||
label: 'Status',
|
||||
inputType: 'multiSelect',
|
||||
options: [
|
||||
{ id: 'open', name: 'Open' },
|
||||
{ id: 'resolved', name: 'Resolved' },
|
||||
{ id: 'pending', name: 'Pending' },
|
||||
{ id: 'snoozed', name: 'Snoozed' },
|
||||
{ id: 'all', name: 'All' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'assignee_id',
|
||||
value: 'assignee_id',
|
||||
attributeName: 'Assignee name',
|
||||
label: 'Assignee name',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{ id: 14, name: 'Ben Nugent' },
|
||||
{ id: 30, name: 'Bruce' },
|
||||
{ id: 16, name: 'Cathy Simms' },
|
||||
{ id: 7, name: 'Charles Miner' },
|
||||
{ id: 10, name: 'Craig D' },
|
||||
{ id: 9, name: 'Dan Gore' },
|
||||
{ id: 13, name: 'Danny Cordray' },
|
||||
{ id: 3, name: 'David Wallace' },
|
||||
{ id: 4, name: 'Deangelo Vickers' },
|
||||
{ id: 33, name: 'Devon White' },
|
||||
{ id: 8, name: 'Ed Truck' },
|
||||
{ id: 31, name: 'Frank' },
|
||||
{ id: 29, name: 'Gideon' },
|
||||
{ id: 24, name: 'Glenn Max' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'team_id',
|
||||
value: 'team_id',
|
||||
attributeName: 'Team name',
|
||||
label: 'Team name',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{ id: 223, name: '💰 sales' },
|
||||
{ id: 224, name: '💼 management' },
|
||||
{ id: 225, name: '👩💼 administration' },
|
||||
{ id: 226, name: '🚛 warehouse' },
|
||||
],
|
||||
dataType: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
value: 'display_id',
|
||||
attributeName: 'Conversation identifier',
|
||||
label: 'Conversation identifier',
|
||||
inputType: 'plainText',
|
||||
datatype: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'contains',
|
||||
label: 'Contains',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-superset-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'does_not_contain',
|
||||
label: 'Does not contain',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-superset-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'campaign_id',
|
||||
value: 'campaign_id',
|
||||
attributeName: 'Campaign name',
|
||||
label: 'Campaign name',
|
||||
inputType: 'searchSelect',
|
||||
options: [],
|
||||
datatype: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'labels',
|
||||
value: 'labels',
|
||||
attributeName: 'Labels',
|
||||
label: 'Labels',
|
||||
inputType: 'multiSelect',
|
||||
options: [
|
||||
{ id: 'billing', name: 'billing' },
|
||||
{ id: 'delivery', name: 'delivery' },
|
||||
{ id: 'lead', name: 'lead' },
|
||||
{ id: 'ops-handover', name: 'ops-handover' },
|
||||
{ id: 'premium-customer', name: 'premium-customer' },
|
||||
{ id: 'software', name: 'software' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeName: 'Referer link',
|
||||
label: 'Referer link',
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'contains',
|
||||
label: 'Contains',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-superset-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'does_not_contain',
|
||||
label: 'Does not contain',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-superset-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeName: 'Created at',
|
||||
label: 'Created at',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'days_before',
|
||||
label: 'Is x days before',
|
||||
hasInput: true,
|
||||
inputOverride: 'plainText',
|
||||
icon: 'i-ph-calendar-minus-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeName: 'Last activity',
|
||||
label: 'Last activity',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'days_before',
|
||||
label: 'Is x days before',
|
||||
hasInput: true,
|
||||
inputOverride: 'plainText',
|
||||
icon: 'i-ph-calendar-minus-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'are_you_a_paid_customer',
|
||||
value: 'are_you_a_paid_customer',
|
||||
attributeName: 'Are you a paid customer?',
|
||||
label: 'Are you a paid customer?',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'date_of_purchase',
|
||||
value: 'date_of_purchase',
|
||||
attributeName: 'Date of Purchase',
|
||||
label: 'Date of Purchase',
|
||||
inputType: 'date',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'your_website',
|
||||
value: 'your_website',
|
||||
attributeName: 'Your website',
|
||||
label: 'Your website',
|
||||
inputType: 'plainText',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'are_you_residing_in_india',
|
||||
value: 'are_you_residing_in_india',
|
||||
attributeName: 'Are you residing in India?',
|
||||
label: 'Are you residing in India?',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'cloud',
|
||||
value: 'cloud',
|
||||
attributeName: 'Cloud',
|
||||
label: 'Cloud',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'license_type',
|
||||
value: 'license_type',
|
||||
attributeName: 'License Type',
|
||||
label: 'License Type',
|
||||
inputType: 'searchSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'Personal',
|
||||
name: 'Personal',
|
||||
},
|
||||
{
|
||||
id: 'Enterprise',
|
||||
name: 'Enterprise',
|
||||
},
|
||||
{
|
||||
id: 'Teams',
|
||||
name: 'Teams',
|
||||
},
|
||||
{
|
||||
id: 'Professional',
|
||||
name: 'Professional',
|
||||
},
|
||||
],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import FilterSelect from './FilterSelect.vue';
|
||||
|
||||
const options = [
|
||||
{ value: 'EQUAL_TO', label: 'Equal To', icon: 'i-ph-equals-bold' },
|
||||
{
|
||||
value: 'NOT_EQUAL_TO',
|
||||
label: 'Not Equal To',
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{ value: 'IS_PRESENT', label: 'Is Present', icon: 'i-ph-member-of-bold' },
|
||||
{
|
||||
value: 'IS_NOT_PRESENT',
|
||||
label: 'Is Not Present',
|
||||
icon: 'i-ph-not-member-of-bold',
|
||||
},
|
||||
{ value: 'CONTAINS', label: 'Contains', icon: 'i-ph-superset-of-bold' },
|
||||
{
|
||||
value: 'DOES_NOT_CONTAIN',
|
||||
label: 'Does Not Contain',
|
||||
icon: 'i-ph-not-superset-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'IS_GREATER_THAN',
|
||||
label: 'Is Greater Than',
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{ value: 'IS_LESS_THAN', label: 'Is Less Than', icon: 'i-ph-less-than-bold' },
|
||||
{
|
||||
value: 'DAYS_BEFORE',
|
||||
label: 'Days Before',
|
||||
icon: 'i-ph-calendar-minus-bold',
|
||||
},
|
||||
{
|
||||
value: 'STARTS_WITH',
|
||||
label: 'Starts With',
|
||||
icon: 'i-ph-caret-line-right-bold',
|
||||
},
|
||||
];
|
||||
|
||||
const selected = ref(options[0].value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/Filter Select"
|
||||
:layout="{ type: 'grid', width: '250px' }"
|
||||
>
|
||||
<Variant title="With Icon & Label">
|
||||
<div class="min-h-[400px]">
|
||||
<FilterSelect v-model="selected" :options="options" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Without Icon">
|
||||
<div class="min-h-[400px]">
|
||||
<FilterSelect v-model="selected" hide-icon :options="options" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Without Label">
|
||||
<div class="min-h-[400px]">
|
||||
<FilterSelect v-model="selected" hide-label :options="options" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
// [{label, icon, value}]
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'faded',
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(o => o.value === selected.value) || {};
|
||||
});
|
||||
|
||||
const iconToRender = computed(() => {
|
||||
if (props.hideIcon) return null;
|
||||
return selectedOption.value.icon || 'i-lucide-chevron-down';
|
||||
});
|
||||
|
||||
const updateSelected = newValue => {
|
||||
selected.value = newValue;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<slot name="trigger" :toggle="toggle">
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
:variant
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="hideLabel ? null : selectedOption.label"
|
||||
@click="toggle"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-[999]">
|
||||
<DropdownSection class="max-h-80 overflow-scroll">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import MultiSelect from './MultiSelect.vue';
|
||||
|
||||
const options = [
|
||||
{ name: 'Open', id: 'open' },
|
||||
{ name: 'Closed', id: 'closed' },
|
||||
{ name: 'Pending', id: 'pending' },
|
||||
{ name: 'Resolved', id: 'resolved' },
|
||||
{ name: 'Spam', id: 'spam' },
|
||||
{ name: 'All', id: 'all' },
|
||||
];
|
||||
|
||||
const selected = ref([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/Multiselect Input"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<div class="min-h-[400px]">
|
||||
<MultiSelect v-model="selected" :options="options" />
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { defineModel, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const { options, maxChips } = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
maxChips: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const selected = defineModel({
|
||||
type: [Array, String],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const hasItems = computed(() => {
|
||||
if (!selected.value) return false;
|
||||
if (!Array.isArray(selected.value)) return false;
|
||||
if (selected.value.length === 0) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const selectedIds = computed(() => {
|
||||
if (!hasItems.value) return [];
|
||||
return selected.value.map(value => value.id);
|
||||
});
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
// Options has additional properties, so we need to use them directly
|
||||
if (!hasItems.value) return [];
|
||||
return options.filter(option => selectedIds.value.includes(option.id));
|
||||
});
|
||||
|
||||
const selectedVisibleItems = computed(() => {
|
||||
if (!hasItems.value) return [];
|
||||
// avoid showing "+1 more" coz it takes up space anway, might as well show it
|
||||
if (selectedItems.value.length === maxChips + 1) return selectedItems.value;
|
||||
// if we have more than maxChips then show only maxChips
|
||||
return selectedItems.value.slice(0, maxChips);
|
||||
});
|
||||
|
||||
const remainingItems = computed(() => {
|
||||
if (!hasItems.value) return [];
|
||||
if (selectedItems.value.length === maxChips + 1) return [];
|
||||
return selectedItems.value.slice(maxChips);
|
||||
});
|
||||
|
||||
const remainingTooltip = computed(() => {
|
||||
if (!hasItems.value) return '';
|
||||
return remainingItems.value.map(item => item.name).join(', ');
|
||||
});
|
||||
|
||||
const toggleOption = option => {
|
||||
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
|
||||
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
|
||||
const optionToToggle = {
|
||||
id: option.id,
|
||||
name: option.name,
|
||||
};
|
||||
|
||||
const idToToggle = optionToToggle.id;
|
||||
|
||||
if (!hasItems.value) {
|
||||
selected.value = [optionToToggle];
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.value.includes(idToToggle)) {
|
||||
selected.value = selected.value.filter(value => value.id !== idToToggle);
|
||||
} else {
|
||||
selected.value = [...selected.value, optionToToggle];
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
v-if="hasItems"
|
||||
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
|
||||
@click="toggle"
|
||||
>
|
||||
<div
|
||||
v-for="item in selectedVisibleItems"
|
||||
:key="item.name"
|
||||
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="remainingItems.length > 0"
|
||||
v-tooltip.top="remainingTooltip"
|
||||
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
|
||||
>
|
||||
<span class="truncate">{{
|
||||
t('COMBOBOX.MORE', { count: remainingItems.length })
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center border-none px-3 gap-2">
|
||||
<Icon icon="i-lucide-plus" />
|
||||
</div>
|
||||
</button>
|
||||
<Button v-else sm slate faded @click="toggle">
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
||||
</template>
|
||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-[999]">
|
||||
<DropdownSection class="max-h-80 overflow-scroll">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
preserve-open
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<template #label>
|
||||
{{ option.name }}
|
||||
<Icon
|
||||
v-if="selectedIds.includes(option.id)"
|
||||
icon="i-lucide-check"
|
||||
class="bg-n-blue-text pointer-events-none"
|
||||
/>
|
||||
</template>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import SingleSelect from './SingleSelect.vue';
|
||||
|
||||
const options = [
|
||||
{ name: 'Open', id: 'open' },
|
||||
{ name: 'Closed', id: 'closed' },
|
||||
{ name: 'Pending', id: 'pending' },
|
||||
{ name: 'Resolved', id: 'resolved' },
|
||||
{ name: 'Spam', id: 'spam' },
|
||||
{ name: 'All', id: 'all' },
|
||||
];
|
||||
|
||||
const selected = ref(options[0]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/Single Select Input"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="With Search">
|
||||
<div class="min-h-[400px]">
|
||||
<SingleSelect v-model="selected" :options="options" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Without Search">
|
||||
<div class="min-h-[400px]">
|
||||
<SingleSelect v-model="selected" disable-search :options="options" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { defineModel, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const { options } = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const selected = defineModel({
|
||||
type: Object,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const searchTerm = ref('');
|
||||
const searchResults = computed(() => {
|
||||
if (!options) return [];
|
||||
return picoSearch(options, searchTerm.value, ['name']);
|
||||
});
|
||||
|
||||
const selectedItem = computed(() => {
|
||||
if (!options) return null;
|
||||
if (!selected.value) return null;
|
||||
|
||||
// there are cases where the selected value is an array
|
||||
const optionToSearch = Array.isArray(selected.value)
|
||||
? selected.value[0]
|
||||
: selected.value;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
});
|
||||
|
||||
const toggleSelected = option => {
|
||||
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
|
||||
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
|
||||
const optionToToggle = {
|
||||
id: option.id,
|
||||
name: option.name,
|
||||
};
|
||||
|
||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||
selected.value = null;
|
||||
} else {
|
||||
selected.value = optionToToggle;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
v-if="selectedItem"
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:icon="selectedItem.icon"
|
||||
:label="selectedItem.name"
|
||||
@click="toggle"
|
||||
/>
|
||||
<Button v-else sm slate faded @click="toggle">
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
||||
</template>
|
||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-56 z-[999]">
|
||||
<div v-if="!disableSearch" class="relative">
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
autofocus
|
||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="max-h-80 overflow-scroll">
|
||||
<template v-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
@click="toggleSelected(option)"
|
||||
>
|
||||
<template #label>
|
||||
{{ option.name }}
|
||||
<Icon
|
||||
v-if="selectedItem && selectedItem.id === option.id"
|
||||
icon="i-lucide-check"
|
||||
class="bg-n-blue-text pointer-events-none"
|
||||
/>
|
||||
</template>
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else-if="searchTerm">
|
||||
<DropdownItem disabled>
|
||||
{{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm: searchTerm }) }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DropdownItem disabled>
|
||||
{{ t('COMBOBOX.EMPTY_STATE') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
@@ -249,11 +249,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CSAT'),
|
||||
to: accountScopedRoute('csat_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
@@ -279,6 +274,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.REPORTS_SLA'),
|
||||
to: accountScopedRoute('sla_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ const emitNewAccount = () => {
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-80">
|
||||
<DropdownBody class="min-w-80 z-50">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
|
||||
<DropdownItem
|
||||
v-for="account in currentUser.accounts"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import Switch from './Switch.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Default varian
|
||||
const isEnabled = ref(false);
|
||||
|
||||
// States variant
|
||||
const defaultValue = ref(false);
|
||||
const checkedValue = ref(true);
|
||||
|
||||
// Events variant
|
||||
const eventValue = ref(false);
|
||||
const lastChange = ref('No changes yet');
|
||||
|
||||
const onChange = value => {
|
||||
lastChange.value = `Changed to: ${value} at ${new Date().toLocaleTimeString()}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Switch" :layout="{ type: 'grid', width: '200px' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-2">
|
||||
<Switch v-model="isEnabled" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="States">
|
||||
<div class="p-2 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="w-20">Default:</span>
|
||||
<Switch v-model="defaultValue" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="w-20">Checked:</span>
|
||||
<Switch v-model="checkedValue" />
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Events">
|
||||
<div class="p-2 space-y-4">
|
||||
<Switch v-model="eventValue" @change="onChange" />
|
||||
<div class="text-sm text-gray-600">Last change: {{ lastChange }}</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="p-2">
|
||||
<Switch v-model="isEnabled" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
});
|
||||
|
||||
const updateValue = () => {
|
||||
modelValue.value = !modelValue.value;
|
||||
emit('change', !modelValue.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:ring-offset-n-slate-2 focus:ring-offset-2"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-alpha-1 dark:bg-n-alpha-2'"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="updateValue"
|
||||
>
|
||||
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||
<span
|
||||
class="absolute top-px left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
||||
:class="
|
||||
modelValue
|
||||
? 'translate-x-2.5 bg-white'
|
||||
: 'translate-x-0 bg-white dark:bg-n-black'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -54,7 +54,7 @@ const end = computed(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-n-slate-11 mb-0">
|
||||
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ const end = computed(() => {
|
||||
<woot-button
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
variant="clear"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
@@ -70,7 +70,7 @@ const end = computed(() => {
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
@@ -81,22 +81,22 @@ const end = computed(() => {
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
variant="clear"
|
||||
class="size-8 flex items-center justify-center border text-xs leading-none text-center"
|
||||
:class="page == currentPage ? 'border-woot-500' : 'border-slate-50'"
|
||||
class="h-8 flex items-center justify-center text-xs leading-none text-center"
|
||||
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(page - 1)"
|
||||
>
|
||||
<div
|
||||
<span
|
||||
class="text-center"
|
||||
:class="{ 'text-woot-500': page == currentPage }"
|
||||
:class="{ 'text-n-brand': page == currentPage }"
|
||||
>
|
||||
{{ page }}
|
||||
</div>
|
||||
</span>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
@@ -105,7 +105,7 @@ const end = computed(() => {
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
class="h-8 border-0 flex items-center"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { FlexRender } from '@tanstack/vue-table';
|
||||
import SortButton from './SortButton.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -11,22 +12,36 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'relaxed',
|
||||
},
|
||||
});
|
||||
|
||||
const isRelaxed = computed(() => props.type === 'relaxed');
|
||||
const headerClass = computed(() =>
|
||||
isRelaxed.value
|
||||
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table :class="{ 'table-fixed': fixed }">
|
||||
<thead
|
||||
class="sticky top-0 z-10 border-b border-slate-50 dark:border-slate-800 bg-slate-25 dark:bg-slate-800"
|
||||
>
|
||||
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<thead class="sticky top-0 z-10 bg-n-slate-1">
|
||||
<tr
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
class="rounded-xl"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:style="{
|
||||
width: `${header.getSize()}px`,
|
||||
}"
|
||||
class="text-left py-3 px-5 dark:bg-slate-800 text-slate-800 dark:text-slate-200 font-normal text-xs"
|
||||
class="text-left py-3 px-5 font-normal text-sm"
|
||||
:class="headerClass"
|
||||
@click="header.column.getCanSort() && header.column.toggleSorting()"
|
||||
>
|
||||
<div
|
||||
@@ -43,16 +58,12 @@ defineProps({
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-slate-25 dark:divide-slate-900">
|
||||
<tr
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="hover:bg-slate-25 dark:hover:bg-slate-800"
|
||||
>
|
||||
<tbody class="divide-y divide-n-slate-2">
|
||||
<tr v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
class="py-2 px-5"
|
||||
:class="isRelaxed ? 'py-4 px-5' : 'py-2 px-5'"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
|
||||
@@ -29,7 +29,7 @@ const spanClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-right uppercase text-n-slate-11 rtl:text-left"
|
||||
:class="spanClass"
|
||||
>
|
||||
<slot>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
default: () => ({}),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
@@ -12,7 +12,7 @@ defineProps({
|
||||
},
|
||||
textClass: {
|
||||
type: String,
|
||||
default: 'text-xs text-slate-600',
|
||||
default: 'text-sm text-n-slate-12',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -25,11 +25,11 @@ defineProps({
|
||||
:username="user.name"
|
||||
:status="user.availability_status"
|
||||
/>
|
||||
<h6
|
||||
class="my-0 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
<span
|
||||
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ user.name }}
|
||||
</h6>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ const formatDate = timestamp =>
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
@@ -26,7 +26,7 @@ const formatDate = timestamp =>
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-slate-900 dark:text-slate-25 text-right tabular-nums"
|
||||
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
|
||||
+2
-2
@@ -40,9 +40,9 @@ const toggleShowAllNRT = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLAEventItem
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
|
||||
*
|
||||
* @returns {string|null} An error message if validation fails, or null if validation passes.
|
||||
*/
|
||||
const validateSingleFilter = filter => {
|
||||
export const validateSingleFilter = filter => {
|
||||
if (!filter.attribute_key) {
|
||||
return ATTRIBUTE_KEY_REQUIRED;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
},
|
||||
"COMBOBOX": {
|
||||
"PLACEHOLDER": "Select an option...",
|
||||
"EMPTY_SEARCH_RESULTS": "No items found for the search term `{searchTerm}`",
|
||||
"EMPTY_STATE": "No results found.",
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MORE": "+{count} more"
|
||||
},
|
||||
"DROPDOWN_MENU": {
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
@@ -30,5 +32,11 @@
|
||||
},
|
||||
"BREADCRUMB": {
|
||||
"ARIA_LABEL": "Breadcrumb"
|
||||
},
|
||||
"SWITCH": {
|
||||
"TOGGLE": "Toggle switch"
|
||||
},
|
||||
"LABEL": {
|
||||
"TAG_BUTTON": "tag"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,62 @@
|
||||
},
|
||||
|
||||
"CONTACTS_LAYOUT": {
|
||||
"HEADER": {
|
||||
"TITLE": "Contacts",
|
||||
"SEARCH_TITLE": "Search contacts",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contacts"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"CONTACT_CREATION": {
|
||||
"ADD_CONTACT": "Add contact",
|
||||
"EXPORT_CONTACT": "Export contacts",
|
||||
"IMPORT_CONTACT": "Import contacts",
|
||||
"SAVE_CONTACT": "Save contact"
|
||||
},
|
||||
"IMPORT_CONTACT": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||
"DOWNLOAD_LABEL": "Download a sample csv.",
|
||||
"LABEL": "CSV File:",
|
||||
"CHOOSE_FILE": "Choose file",
|
||||
"CHANGE": "Change",
|
||||
"CANCEL": "Cancel",
|
||||
"IMPORT": "Import",
|
||||
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"EXPORT_CONTACT": {
|
||||
"TITLE": "Export contacts",
|
||||
"DESCRIPTION": "Quickly export a csv file with comprehensive details of your contacts",
|
||||
"CONFIRM": "Export",
|
||||
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"SORT_BY": {
|
||||
"LABEL": "Sort by",
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"EMAIL": "Email",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"COMPANY": "Company",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"CREATED_AT": "Created at"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
"LABEL": "Ordering",
|
||||
"OPTIONS": {
|
||||
"ASCENDING": "Ascending",
|
||||
"DESCENDING": "Descending"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CARD": {
|
||||
"OF": "of",
|
||||
"VIEW_DETAILS": "View details",
|
||||
@@ -440,6 +496,60 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"ATTRIBUTES": {
|
||||
"SEARCH_PLACEHOLDER": "Search for attributes",
|
||||
"UNUSED_ATTRIBUTES": "{count} Used attribute | {count} Unused attributes",
|
||||
"EMPTY_STATE": "There are no contact custom attributes available in this account. You can create a custom attribute in settings.",
|
||||
"YES": "Yes",
|
||||
"NO": "No",
|
||||
"TRIGGER": {
|
||||
"SELECT": "Select value",
|
||||
"INPUT": "Enter value"
|
||||
},
|
||||
"VALIDATIONS": {
|
||||
"INVALID_NUMBER": "Invalid number",
|
||||
"REQUIRED": "Valid value is required",
|
||||
"INVALID_INPUT": "Invalid input",
|
||||
"INVALID_URL": "Invalid URL",
|
||||
"INVALID_DATE": "Invalid date"
|
||||
},
|
||||
"NO_ATTRIBUTES": "No attributes found",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Attribute updated successfully",
|
||||
"DELETE_SUCCESS_MESSAGE": "Attribute deleted successfully",
|
||||
"UPDATE_ERROR": "Unable to update attribute. Please try again later",
|
||||
"DELETE_ERROR": "Unable to delete attribute. Please try again later"
|
||||
}
|
||||
},
|
||||
"MERGE": {
|
||||
"TITLE": "Merge contact",
|
||||
"DESCRIPTION": "Combine two profiles into one, including all attributes and conversations. In case of conflict, the primary contact’s attributes will take precedence.",
|
||||
"PRIMARY": "Primary contact",
|
||||
"PRIMARY_HELP_LABEL": "To be saved",
|
||||
"PRIMARY_REQUIRED_ERROR": "Please select a contact to merge with before proceeding",
|
||||
"PARENT": "To be merged",
|
||||
"PARENT_HELP_LABEL": "To be deleted",
|
||||
"EMPTY_STATE": "No contacts found",
|
||||
"PLACEHOLDER": "Search for primary contact",
|
||||
"SEARCH_PLACEHOLDER": "Search for a contact",
|
||||
"SEARCH_ERROR_MESSAGE": "Could not search for contacts. Please try again later.",
|
||||
"SUCCESS_MESSAGE": "Contact merged successfully",
|
||||
"ERROR_MESSAGE": "Could not merge contacts, try again!",
|
||||
"IS_SEARCHING": "Searching...",
|
||||
"BUTTONS": {
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM": "Merge contact"
|
||||
}
|
||||
},
|
||||
"NOTES": {
|
||||
"PLACEHOLDER": "Add a note",
|
||||
"WROTE": "wrote",
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ const table = useVueTable({
|
||||
<template>
|
||||
<section class="flex-1 h-full overflow-auto bg-white dark:bg-slate-900">
|
||||
<section class="overflow-x-auto">
|
||||
<Table fixed :table="table" />
|
||||
<Table fixed :table="table" type="compact" />
|
||||
</section>
|
||||
|
||||
<EmptyState
|
||||
|
||||
+9
-5
@@ -34,10 +34,11 @@ const portalLink = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const saveArticle = async ({ ...values }) => {
|
||||
const saveArticle = async ({ ...values }, isAsync = false) => {
|
||||
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
await store.dispatch(actionToDispatch, {
|
||||
portalSlug,
|
||||
articleId: articleSlug,
|
||||
...values,
|
||||
@@ -55,6 +56,10 @@ const saveArticle = async ({ ...values }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveArticleAsync = async ({ ...values }) => {
|
||||
saveArticle({ ...values }, true);
|
||||
};
|
||||
|
||||
const isCategoryArticles = computed(() => {
|
||||
return (
|
||||
route.name === 'portals_categories_articles_index' ||
|
||||
@@ -92,9 +97,7 @@ const previewArticle = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchArticleDetails();
|
||||
});
|
||||
onMounted(fetchArticleDetails);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,6 +106,7 @@ onMounted(() => {
|
||||
:is-updating="isUpdating"
|
||||
:is-saved="isSaved"
|
||||
@save-article="saveArticle"
|
||||
@save-article-async="saveArticleAsync"
|
||||
@preview-article="previewArticle"
|
||||
@go-back="goBackToArticles"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,5 +9,6 @@ export default {
|
||||
getter-key="agents/getAgents"
|
||||
action-key="agents/get"
|
||||
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
||||
:report-title="$t('AGENT_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,13 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'BotReports',
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
@@ -84,7 +86,8 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
|
||||
@@ -7,6 +7,8 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { generateFileName } from '../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'CsatResponses',
|
||||
@@ -14,6 +16,8 @@ export default {
|
||||
CsatMetrics,
|
||||
CsatTable,
|
||||
ReportFilterSelector,
|
||||
ReportHeader,
|
||||
V4Button,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -108,7 +112,16 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<ReportHeader :header-title="$t('CSAT_REPORTS.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('CSAT_REPORTS.DOWNLOAD')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilterSelector
|
||||
show-agents-filter
|
||||
show-inbox-filter
|
||||
@@ -117,14 +130,7 @@ export default {
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
|
||||
</woot-button>
|
||||
|
||||
<CsatMetrics :filters="requestPayload" />
|
||||
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,5 +9,6 @@ export default {
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
@@ -6,6 +7,7 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
@@ -20,8 +22,10 @@ const REPORTS_KEYS = {
|
||||
export default {
|
||||
name: 'ConversationReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
V4Button,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -98,15 +102,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
<ReportHeader :header-title="$t('REPORT.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadAgentReports"
|
||||
>
|
||||
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,5 +9,6 @@ export default {
|
||||
getter-key="labels/getLabels"
|
||||
action-key="labels/get"
|
||||
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||
:report-title="$t('LABEL_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -10,10 +10,12 @@ import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'LiveReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
AgentTable,
|
||||
MetricCard,
|
||||
ReportHeatmap,
|
||||
@@ -123,8 +125,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<div class="flex flex-col items-center md:flex-row">
|
||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<div class="flex flex-col items-center md:flex-row gap-4">
|
||||
<div
|
||||
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
||||
>
|
||||
@@ -140,10 +143,10 @@ export default {
|
||||
:key="index"
|
||||
class="flex-1 min-w-0 pb-2"
|
||||
>
|
||||
<h3 class="text-base text-slate-700 dark:text-slate-100">
|
||||
<h3 class="text-base text-n-slate-11">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<p class="text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1">
|
||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||
{{ metric }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,17 +159,17 @@ export default {
|
||||
:key="index"
|
||||
class="flex-1 min-w-0 pb-2"
|
||||
>
|
||||
<h3 class="text-base text-slate-700 dark:text-slate-100">
|
||||
<h3 class="text-base text-n-slate-11">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<p class="text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1">
|
||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||
{{ metric }}
|
||||
</p>
|
||||
</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap max-w-full ml-auto mr-auto">
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||
<template #control>
|
||||
<woot-button
|
||||
@@ -185,7 +188,7 @@ export default {
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap max-w-full ml-auto mr-auto">
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||
<AgentTable
|
||||
:agents="agents"
|
||||
|
||||
@@ -135,7 +135,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 p-2 bg-white border rounded-md md:grid-cols-2 lg:grid-cols-3 dark:bg-slate-800 border-slate-100 dark:border-slate-700"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
v-for="metric in metrics"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SLAMetrics from './components/SLA/SLAMetrics.vue';
|
||||
import SLATable from './components/SLA/SLATable.vue';
|
||||
import SLAReportFilters from './components/SLA/SLAReportFilters.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
export default {
|
||||
name: 'SLAReports',
|
||||
components: {
|
||||
V4Button,
|
||||
ReportHeader,
|
||||
SLAMetrics,
|
||||
SLATable,
|
||||
SLAReportFilters,
|
||||
@@ -77,30 +81,28 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 gap-6 px-4 pt-4 overflow-auto">
|
||||
<SLAReportFilters @filter-change="onFilterChange" />
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
<ReportHeader :header-title="$t('SLA_REPORTS.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ $t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS') }}
|
||||
</woot-button>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SLAMetrics
|
||||
:hit-rate="slaMetrics.hitRate"
|
||||
:no-of-breaches="slaMetrics.numberOfSLAMisses"
|
||||
:no-of-conversations="slaMetrics.numberOfConversations"
|
||||
:is-loading="uiFlags.isFetchingMetrics"
|
||||
/>
|
||||
<SLATable
|
||||
:sla-reports="slaReports"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:current-page="Number(slaMeta.currentPage)"
|
||||
:total-count="Number(slaMeta.count)"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col flex-1 gap-6">
|
||||
<SLAReportFilters @filter-change="onFilterChange" />
|
||||
<SLAMetrics
|
||||
:hit-rate="slaMetrics.hitRate"
|
||||
:no-of-breaches="slaMetrics.numberOfSLAMisses"
|
||||
:no-of-conversations="slaMetrics.numberOfConversations"
|
||||
:is-loading="uiFlags.isFetchingMetrics"
|
||||
/>
|
||||
<SLATable
|
||||
:sla-reports="slaReports"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:current-page="Number(slaMeta.currentPage)"
|
||||
:total-count="Number(slaMeta.count)"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,5 +9,6 @@ export default {
|
||||
getter-key="teams/getTeams"
|
||||
action-key="teams/get"
|
||||
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
|
||||
:report-title="$t('TEAM_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ onMounted(fetchMetrics);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
|
||||
+2
-2
@@ -29,11 +29,11 @@ const trendColor = (value, key) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-slate-900 dark:text-slate-100">
|
||||
<div class="text-n-slate-11">
|
||||
<span class="text-sm">
|
||||
{{ metric.NAME }}
|
||||
</span>
|
||||
<div class="flex items-end">
|
||||
<div class="flex items-end text-n-slate-12">
|
||||
<div class="text-xl font-medium">
|
||||
{{ displayMetric(metric.KEY) }}
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -86,7 +86,7 @@ export default {
|
||||
<!-- Added ref for writing specs -->
|
||||
<template>
|
||||
<div
|
||||
class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
|
||||
>
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
@@ -111,10 +111,10 @@ export default {
|
||||
<div
|
||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||
ref="csatBarChart"
|
||||
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial] p-4"
|
||||
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
|
||||
>
|
||||
<h3
|
||||
class="flex items-center m-0 text-xs font-medium md:text-sm text-slate-800 dark:text-slate-100"
|
||||
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
|
||||
>
|
||||
<div class="flex flex-row-reverse justify-end">
|
||||
<div
|
||||
|
||||
+5
-20
@@ -145,14 +145,13 @@ const table = useVueTable({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="csat--table-container">
|
||||
<Table
|
||||
:table="table"
|
||||
class="max-h-[calc(100vh-21.875rem)] border bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800"
|
||||
/>
|
||||
<div
|
||||
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||
<div
|
||||
v-show="!tableData.length"
|
||||
class="csat--empty-records text-slate-600 dark:text-slate-200 bg-white dark:bg-slate-900 border border-t-0 border-solid border-slate-75 dark:border-slate-700"
|
||||
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
|
||||
</div>
|
||||
@@ -161,17 +160,3 @@ const table = useVueTable({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.csat--empty-records {
|
||||
align-items: center;
|
||||
// border: 1px solid var(--color-border);
|
||||
border-top: 0;
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
height: 12.5rem;
|
||||
justify-content: center;
|
||||
margin-top: -1px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 mb-4 md:flex-row">
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div
|
||||
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
|
||||
-1
@@ -53,7 +53,6 @@ const closeDropdown = () => emit('closeDropdown');
|
||||
<FilterButton
|
||||
right-icon="chevron-down"
|
||||
:button-text="name"
|
||||
class="bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="showMenu && activeFilterType === type" #dropdown>
|
||||
|
||||
@@ -63,8 +63,7 @@ function getDayOfTheWeek(date) {
|
||||
return days[dayIndex];
|
||||
}
|
||||
function getHeatmapLevelClass(value) {
|
||||
if (!value)
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
if (!value) return 'outline-n-container dark:bg-slate-700/40 bg-slate-50/50';
|
||||
|
||||
let level = [...quantileRange.value, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
@@ -73,7 +72,7 @@ function getHeatmapLevelClass(value) {
|
||||
if (level > 6) level = 5;
|
||||
|
||||
if (level === 0) {
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
return 'outline-n-container dark:bg-slate-700/40 bg-slate-50/50';
|
||||
}
|
||||
|
||||
const classes = [
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
headerTitle: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
+3
-6
@@ -22,26 +22,23 @@ defineProps({
|
||||
<template>
|
||||
<div
|
||||
data-test-id="reportMetricContainer"
|
||||
class="p-4 m-0"
|
||||
:class="{
|
||||
'grayscale pointer-events-none opacity-30': disabled,
|
||||
}"
|
||||
>
|
||||
<h3
|
||||
class="flex items-center m-0 text-sm font-medium text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<h3 class="flex items-center m-0 text-sm font-medium text-n-slate-11">
|
||||
<span data-test-id="reportMetricLabel">{{ label }}</span>
|
||||
<fluent-icon
|
||||
v-tooltip="infoText"
|
||||
data-test-id="reportMetricInfo"
|
||||
size="14"
|
||||
icon="info"
|
||||
class="text-slate-500 dark:text-slate-200 my-0 mx-1 mt-0.5"
|
||||
class="text-n-slate-11 my-0 mx-1 mt-0.5"
|
||||
/>
|
||||
</h3>
|
||||
<h4
|
||||
data-test-id="reportMetricValue"
|
||||
class="mt-1 mb-0 text-3xl font-thin text-slate-700 dark:text-slate-100"
|
||||
class="mt-1 mb-0 text-2xl text-n-slate-12"
|
||||
>
|
||||
{{ value }}
|
||||
</h4>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
|
||||
>
|
||||
<div class="max-w-[960px] mx-auto pb-12">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reports--wrapper {
|
||||
::v-deep {
|
||||
.multiselect--disabled {
|
||||
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-n-solid-2 border border-n-weak text-n-slate-12;
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-slate-1 border border-n-weak m-0 min-h-[2.875rem] pt-0;
|
||||
|
||||
input[type='text'] {
|
||||
@apply bg-n-alpha-3 border-n-weak !min-h-[2.375rem] !h-[2.375rem] !ps-0.5 !py-0 !text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply bg-n-alpha-3 text-n-slate-11;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply text-sm !h-[2.375rem] mb-0 !py-0;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-n-alpha-3 !border-n-weak text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.mx-input-wrapper {
|
||||
@apply bg-n-alpha-3 !border-n-weak text-n-slate-12 rounded-lg text-sm;
|
||||
|
||||
input {
|
||||
@apply border-n-weak text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__option {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
.mx-input {
|
||||
@apply bg-n-alpha-3;
|
||||
}
|
||||
|
||||
.mx-input-wrapper input::placeholder {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
|
||||
.mx-input-wrapper input {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__current,
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__input,
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__tags {
|
||||
@apply rounded-b-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+4
-4
@@ -24,7 +24,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
<fluent-icon
|
||||
@@ -32,15 +32,15 @@ export default {
|
||||
size="14"
|
||||
icon="information"
|
||||
type="outline"
|
||||
class="flex flex-shrink-0 text-sm font-normal sm:font-medium text-slate-500 dark:text-slate-500"
|
||||
class="flex flex-shrink-0 text-sm font-normal sm:font-medium text-n-slate-10"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="w-12 h-6 mb-0.5 rounded-md bg-slate-50 dark:bg-slate-800 animate-pulse"
|
||||
class="w-12 h-6 mb-0.5 rounded-md bg-n-slate-3 animate-pulse"
|
||||
/>
|
||||
|
||||
<span v-else class="text-2xl font-medium text-slate-900 dark:text-slate-25">
|
||||
<span v-else class="text-2xl font-medium text-n-slate-12">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+3
-7
@@ -22,7 +22,7 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 bg-white dark:bg-slate-900 rounded-xl border border-slate-75 dark:border-slate-700/50 px-6 py-4"
|
||||
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.HIT_RATE.LABEL')"
|
||||
@@ -31,18 +31,14 @@ defineProps({
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
|
||||
/>
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.LABEL')"
|
||||
:value="noOfBreaches"
|
||||
:tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.TOOLTIP')"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
<div
|
||||
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
|
||||
/>
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.NO_OF_CONVERSATIONS.LABEL')"
|
||||
:value="noOfConversations"
|
||||
|
||||
+7
-6
@@ -31,22 +31,23 @@ const conversationLabels = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid items-center content-center w-full h-16 grid-cols-12 gap-4 px-6 py-0 bg-white border-b last:border-b-0 last:rounded-b-xl border-slate-75 dark:border-slate-800/50 dark:bg-slate-900"
|
||||
class="grid items-center content-center w-full h-16 grid-cols-12 gap-4 px-6 py-0 border-b last:border-b-0 last:rounded-b-xl border-n-weak"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
<span class="text-n-slate-12">
|
||||
{{ `#${conversationId} ` }}
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-300">
|
||||
<span class="text-slate-11">
|
||||
{{ $t('SLA_REPORTS.WITH') }}
|
||||
</span>
|
||||
<span class="capitalize truncate text-slate-700 dark:text-slate-200">{{
|
||||
<span class="capitalize truncate text-n-slate-12">{{
|
||||
conversation.contact.name
|
||||
}}</span>
|
||||
<CardLabels
|
||||
class="w-[80%]"
|
||||
v-if="conversationLabels.length"
|
||||
class="w-[60%]"
|
||||
:conversation-id="conversationId"
|
||||
:conversation-labels="conversationLabels"
|
||||
/>
|
||||
@@ -61,7 +62,7 @@ const conversationLabels = computed(() => {
|
||||
v-if="conversation.assignee"
|
||||
:user="conversation.assignee"
|
||||
/>
|
||||
<span v-else class="text-slate-600 dark:text-slate-200"> --- </span>
|
||||
<span v-else class="text-n-slate-11"> --- </span>
|
||||
</div>
|
||||
<SLAViewDetails :sla-events="slaEvents" />
|
||||
</div>
|
||||
|
||||
+5
-11
@@ -57,10 +57,10 @@ export default {
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="min-w-full border rounded-xl border-slate-75 dark:border-slate-700/50"
|
||||
class="min-w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 p-6"
|
||||
>
|
||||
<div
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 px-6 py-0 border-b bg-slate-25 border-slate-75 dark:border-slate-800 rounded-t-xl dark:bg-slate-900"
|
||||
class="grid content-center h-12 grid-cols-12 gap-4 px-6 py-0 bg-n-slate-2 rounded-md"
|
||||
>
|
||||
<TableHeaderCell
|
||||
:span="6"
|
||||
@@ -74,13 +74,10 @@ export default {
|
||||
:span="2"
|
||||
:label="$t('SLA_REPORTS.TABLE.HEADER.AGENT')"
|
||||
/>
|
||||
<TableHeaderCell :span="2" label="" />
|
||||
<TableHeaderCell :span="1" label="" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center h-32 bg-white rounded-b-xl dark:bg-slate-900"
|
||||
>
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-32">
|
||||
<Spinner />
|
||||
<span>{{ $t('SLA_REPORTS.LOADING') }}</span>
|
||||
</div>
|
||||
@@ -94,10 +91,7 @@ export default {
|
||||
:sla-events="slaReport.sla_events"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-32 bg-white rounded-b-xl dark:bg-slate-900"
|
||||
>
|
||||
<div v-else class="flex items-center justify-center h-32">
|
||||
{{ $t('SLA_REPORTS.NO_RECORDS') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+17
-18
@@ -29,24 +29,23 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeSlaEvents" class="label-wrap">
|
||||
<div
|
||||
class="flex items-center col-span-2 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<div class="relative">
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
@click="openSlaEvents"
|
||||
>
|
||||
{{ $t('SLA_REPORTS.TABLE.VIEW_DETAILS') }}
|
||||
</woot-button>
|
||||
<SLAPopoverCard
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="right-0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="closeSlaEvents"
|
||||
class="flex items-center col-span-2 text-slate-11 justify-end"
|
||||
>
|
||||
<div class="relative">
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
@click="openSlaEvents"
|
||||
>
|
||||
{{ $t('SLA_REPORTS.TABLE.VIEW_DETAILS') }}
|
||||
</woot-button>
|
||||
<SLAPopoverCard
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="right-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+31
-24
@@ -1,10 +1,12 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './ReportHeader.vue';
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
@@ -26,6 +28,8 @@ const GROUP_BY_OPTIONS = {
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ReportHeader,
|
||||
V4Button,
|
||||
ReportFilters,
|
||||
ReportContainer,
|
||||
},
|
||||
@@ -46,6 +50,10 @@ export default {
|
||||
type: String,
|
||||
default: 'Download Reports',
|
||||
},
|
||||
reportTitle: {
|
||||
type: String,
|
||||
default: 'Download Reports',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -198,30 +206,29 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
<ReportHeader :header-title="reportTitle">
|
||||
<V4Button
|
||||
:label="downloadButtonLabel"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ downloadButtonLabel }}
|
||||
</woot-button>
|
||||
<ReportFilters
|
||||
v-if="filterItemsList"
|
||||
:type="type"
|
||||
:filter-items-list="filterItemsList"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
/>
|
||||
</div>
|
||||
</ReportHeader>
|
||||
|
||||
<ReportFilters
|
||||
v-if="filterItemsList"
|
||||
:type="type"
|
||||
:filter-items-list="filterItemsList"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+2
-5
@@ -144,10 +144,7 @@ const table = useVueTable({
|
||||
|
||||
<template>
|
||||
<div class="agent-table-container">
|
||||
<Table
|
||||
:table="table"
|
||||
class="max-h-[calc(100vh-21.875rem)] border border-slate-50 dark:border-slate-800"
|
||||
/>
|
||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||
<Pagination class="mt-2" :table="table" />
|
||||
<div v-if="isLoading" class="agents-loader">
|
||||
<Spinner />
|
||||
@@ -169,7 +166,7 @@ const table = useVueTable({
|
||||
.ve-table {
|
||||
&::v-deep {
|
||||
th.ve-table-header-th {
|
||||
font-size: var(--font-size-mini) !important;
|
||||
@apply text-sm rounded-xl;
|
||||
padding: var(--space-small) var(--space-two) !important;
|
||||
}
|
||||
|
||||
|
||||
+7
-9
@@ -25,25 +25,23 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="metric-card mb-2 p flex flex-col m-2 p-4 border border-solid overflow-hidden rounded-md flex-grow shadow-sm text-slate-700 dark:text-slate-100 bg-white dark:bg-slate-800 border-slate-75 dark:border-slate-700 min-h-[10rem]"
|
||||
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
|
||||
>
|
||||
<div
|
||||
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
|
||||
>
|
||||
<slot name="header">
|
||||
<div class="flex items-center gap-0.5 flex-row">
|
||||
<h5
|
||||
class="mb-0 text-slate-800 dark:text-slate-100 font-medium text-xl"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-row">
|
||||
<h5 class="mb-0 text-n-slate-12 font-medium text-lg">
|
||||
{{ header }}
|
||||
</h5>
|
||||
<span
|
||||
class="flex flex-row items-center pr-2 pl-2 m-1 rounded-sm text-green-400 dark:text-green-400 text-xs bg-green-100/30 dark:bg-green-100/20"
|
||||
class="flex flex-row items-center py-0.5 px-2 rounded bg-n-teal-3 text-xs"
|
||||
>
|
||||
<span
|
||||
class="bg-green-500 dark:bg-green-500 h-1 w-1 rounded-full mr-1 rtl:mr-0 rtl:ml-0"
|
||||
class="bg-n-teal-9 h-1 w-1 rounded-full mr-1 rtl:mr-0 rtl:ml-0"
|
||||
/>
|
||||
<span>
|
||||
<span class="text-xs text-n-teal-11">
|
||||
{{ $t('OVERVIEW_REPORTS.LIVE') }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -66,7 +64,7 @@ export default {
|
||||
class="items-center flex text-base justify-center px-12 py-6"
|
||||
>
|
||||
<Spinner />
|
||||
<span class="text-slate-300 dark:text-slate-200">
|
||||
<span class="text-n-slate-11">
|
||||
{{ loadingMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CsatMetrics.vue > computes response count correctly 1`] = `
|
||||
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700">
|
||||
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="100"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="--"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="90%"></csat-metric-card-stub>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import ReportsWrapper from './components/ReportsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
import AgentReports from './AgentReports.vue';
|
||||
import LabelReports from './LabelReports.vue';
|
||||
@@ -16,12 +16,7 @@ export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'OVERVIEW_REPORTS.HEADER',
|
||||
icon: 'arrow-trending-lines',
|
||||
keepAlive: false,
|
||||
},
|
||||
component: ReportsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -37,17 +32,6 @@ export default {
|
||||
},
|
||||
component: LiveReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'REPORT.HEADER',
|
||||
icon: 'chat',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'conversation',
|
||||
name: 'conversation_reports',
|
||||
@@ -56,56 +40,6 @@ export default {
|
||||
},
|
||||
component: Index,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'CSAT_REPORTS.HEADER',
|
||||
icon: 'emoji',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'csat',
|
||||
name: 'csat_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: CsatResponses,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'BOT_REPORTS.HEADER',
|
||||
icon: 'bot',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'bot',
|
||||
name: 'bot_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
|
||||
},
|
||||
component: BotReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'AGENT_REPORTS.HEADER',
|
||||
icon: 'people',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_reports',
|
||||
@@ -114,17 +48,6 @@ export default {
|
||||
},
|
||||
component: AgentReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'LABEL_REPORTS.HEADER',
|
||||
icon: 'tag',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'label',
|
||||
name: 'label_reports',
|
||||
@@ -133,17 +56,6 @@ export default {
|
||||
},
|
||||
component: LabelReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'INBOX_REPORTS.HEADER',
|
||||
icon: 'mail-inbox-all',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'inboxes',
|
||||
name: 'inbox_reports',
|
||||
@@ -152,16 +64,6 @@ export default {
|
||||
},
|
||||
component: InboxReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'TEAM_REPORTS.HEADER',
|
||||
icon: 'people-team',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'teams',
|
||||
name: 'team_reports',
|
||||
@@ -170,17 +72,6 @@ export default {
|
||||
},
|
||||
component: TeamReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'SLA_REPORTS.HEADER',
|
||||
icon: 'document-list-clock',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'sla',
|
||||
name: 'sla_reports',
|
||||
@@ -190,6 +81,22 @@ export default {
|
||||
},
|
||||
component: SLAReports,
|
||||
},
|
||||
{
|
||||
path: 'csat',
|
||||
name: 'csat_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: CsatResponses,
|
||||
},
|
||||
{
|
||||
path: 'bot',
|
||||
name: 'bot_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: BotReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -69,6 +69,25 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateAsync: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: { isUpdating: true },
|
||||
articleId,
|
||||
});
|
||||
|
||||
try {
|
||||
await articlesAPI.updateArticle({ portalSlug, articleId, articleObj });
|
||||
return articleId;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: { isUpdating: false },
|
||||
articleId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
|
||||
commit(types.UPDATE_ARTICLE_FLAG, {
|
||||
uiFlags: {
|
||||
|
||||
@@ -4,6 +4,9 @@ import i18nMessages from 'dashboard/i18n';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
|
||||
@@ -15,4 +18,6 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.directive('resize', vResizeObserver);
|
||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
app.directive('on-clickaway', onClickaway);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user