mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-17 20:32:24 +00:00
feat: refactor frontend conversations
- remove unused heroicons pkg.
This commit is contained in:
@@ -16,7 +16,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@internationalized/date": "^3.5.5",
|
||||
"@radix-icons/vue": "^1.0.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="relative" v-if="conversationStore.messages.data">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-4 border-b h-[47px] flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
@@ -30,12 +31,17 @@
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header end -->
|
||||
|
||||
<Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error>
|
||||
|
||||
<!-- Messages & reply box -->
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- flex-1-->
|
||||
<MessageList class="flex-1" />
|
||||
<ReplyBox class="h-max mb-12" />
|
||||
</div>
|
||||
<!-- Messages & reply box end -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
<template>
|
||||
<div class="p-3">
|
||||
<div>
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage
|
||||
:src="conversationStore.conversation.data.avatar_url"
|
||||
v-if="conversationStore.conversation.data.avatar_url"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ conversationStore.conversation.data.first_name.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h4 class="mt-3">
|
||||
{{
|
||||
conversationStore.conversation.data.first_name +
|
||||
' ' +
|
||||
conversationStore.conversation.data.last_name
|
||||
}}
|
||||
</h4>
|
||||
<p
|
||||
class="text-sm text-muted-foreground flex gap-2 mt-1"
|
||||
v-if="conversationStore.conversation.data.email"
|
||||
>
|
||||
<Mail class="size-3 mt-1"></Mail>
|
||||
{{ conversationStore.conversation.data.email }}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-muted-foreground flex gap-2 mt-1"
|
||||
v-if="conversationStore.conversation.data.phone_number"
|
||||
>
|
||||
<Phone class="size-3 mt-1"></Phone>
|
||||
{{ conversationStore.conversation.data.phone_number }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
class="border-t mt-4"
|
||||
:default-value="actionAccordion.title"
|
||||
>
|
||||
<AccordionItem :value="actionAccordion.title">
|
||||
<AccordionTrigger>
|
||||
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
{{ actionAccordion.title }}
|
||||
</h4>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<!-- Agent assign -->
|
||||
<div class="mb-3">
|
||||
<Popover v-model:open="agentSelectDropdownOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="agentSelectDropdownOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{
|
||||
conversationStore.conversation.data.assigned_user_id
|
||||
? (() => {
|
||||
console.log(
|
||||
' ->',
|
||||
conversationStore.conversation.data.assigned_user_id,
|
||||
'agents ',
|
||||
agents
|
||||
)
|
||||
const agent = agents.find(
|
||||
(agent) =>
|
||||
agent.id === conversationStore.conversation.data.assigned_user_id
|
||||
)
|
||||
return agent
|
||||
? `${agent.first_name} ${agent.last_name}`
|
||||
: 'Select agent...'
|
||||
})()
|
||||
: 'Select agent...'
|
||||
}}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 PopoverContent">
|
||||
<Command @update:modelValue="handleAssignedUserChange">
|
||||
<CommandInput class="h-9" placeholder="Search agent..." />
|
||||
<CommandEmpty>No agent found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:value="agent.id + ':' + agent.first_name + ' ' + agent.last_name"
|
||||
@select="
|
||||
(ev) => {
|
||||
if (typeof ev.detail.value === 'string') {
|
||||
const id = ev.detail.value.split(':')[0]
|
||||
console.log('setting id ', id)
|
||||
conversationStore.conversation.data.assigned_user_id = Number(id)
|
||||
}
|
||||
agentSelectDropdownOpen = false
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ agent.first_name + ' ' + agent.last_name }}
|
||||
<CheckIcon
|
||||
:class="
|
||||
cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversationStore.conversation.data.assigned_user_id === agent.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<!-- Agent assign end -->
|
||||
|
||||
<!-- Team assign -->
|
||||
<div class="mb-3">
|
||||
<Popover v-model:open="teamSelectDropdownOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="teamSelectDropdownOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{
|
||||
conversationStore.conversation.data.assigned_team_id
|
||||
? teams.find(
|
||||
(team) => team.id === conversationStore.conversation.data.assigned_team_id
|
||||
)?.name
|
||||
: 'Select team...'
|
||||
}}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 PopoverContent">
|
||||
<Command @update:modelValue="handleAssignedTeamChange">
|
||||
<CommandInput class="h-9" placeholder="Search team..." />
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
:value="team.id + ':' + team.name"
|
||||
@select="
|
||||
(ev) => {
|
||||
if (ev.detail.value) {
|
||||
const id = ev.detail.value.split(':')[0]
|
||||
conversationStore.conversation.data.assigned_team_id = Number(id)
|
||||
}
|
||||
teamSelectDropdownOpen = false
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ team.name }}
|
||||
<CheckIcon
|
||||
:class="
|
||||
cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversationStore.conversation.data.assigned_team_id === team.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<!-- Team assign end -->
|
||||
|
||||
<!-- Priority -->
|
||||
<div class="mb-3">
|
||||
<Popover v-model:open="prioritySelectDropdownOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="prioritySelectDropdownOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{
|
||||
conversationStore.conversation.data.priority
|
||||
? priorities.find(
|
||||
(priority) => priority === conversationStore.conversation.data.priority
|
||||
)
|
||||
: 'Select priority...'
|
||||
}}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 PopoverContent">
|
||||
<Command @update:modelValue="handlePriorityChange">
|
||||
<CommandInput class="h-9" placeholder="Search priority..." />
|
||||
<CommandEmpty>No priority found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="priority in priorities"
|
||||
:key="priority"
|
||||
:value="priority"
|
||||
@select="
|
||||
(ev) => {
|
||||
if (ev.detail.value) {
|
||||
const p = ev.detail.value
|
||||
conversationStore.conversation.data.priority = p
|
||||
}
|
||||
prioritySelectDropdownOpen = false
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ priority }}
|
||||
<CheckIcon
|
||||
:class="
|
||||
cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversationStore.conversation.data.priority === priority
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<!-- Priority end -->
|
||||
|
||||
<!-- Tags -->
|
||||
<TagsInput
|
||||
class="px-0 gap-0 w-full"
|
||||
:model-value="tagsSelected"
|
||||
@update:modelValue="handleUpsertTags"
|
||||
>
|
||||
<div class="flex gap-2 flex-wrap items-center px-3">
|
||||
<TagsInputItem v-for="item in tagsSelected" :key="item" :value="item">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
|
||||
<ComboboxRoot
|
||||
v-model="tagsSelected"
|
||||
v-model:open="tagDropdownOpen"
|
||||
v-model:searchTerm="tagSearchTerm"
|
||||
class="w-full"
|
||||
>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxInput placeholder="Add tags..." as-child>
|
||||
<TagsInputInput
|
||||
class="w-full px-3"
|
||||
:class="tagsSelected.length > 0 ? 'mt-2' : ''"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<CommandList
|
||||
position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<CommandEmpty />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="ftag in tagsFiltered"
|
||||
:key="ftag.value"
|
||||
:value="ftag.label"
|
||||
@select.prevent="
|
||||
(ev) => {
|
||||
if (typeof ev.detail.value === 'string') {
|
||||
tagSearchTerm = ''
|
||||
tagsSelected.push(ev.detail.value)
|
||||
tagDropdownOpen = false
|
||||
}
|
||||
|
||||
if (tagsFiltered.length === 0) {
|
||||
tagDropdownOpen = false
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ ftag.label }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</TagsInput>
|
||||
<!-- Tags end -->
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<Accordion type="single" collapsible :default-value="infoAccordion.title">
|
||||
<AccordionItem :value="infoAccordion.title">
|
||||
<AccordionTrigger>
|
||||
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
{{ infoAccordion.title }}
|
||||
</h4>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Initiated at</p>
|
||||
<p>
|
||||
{{ format(conversationStore.conversation.data.created_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<p v-if="conversationStore.conversation.data.first_reply_at">
|
||||
{{ format(conversationStore.conversation.data.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<p v-if="conversationStore.conversation.data.resolved_at">
|
||||
{{ format(conversationStore.conversation.data.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Closed at</p>
|
||||
<p v-if="conversationStore.conversation.data.closed_at">
|
||||
{{ format(conversationStore.conversation.data.closed_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { format } from 'date-fns'
|
||||
import api from '@/api'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
} from '@/components/ui/tags-input'
|
||||
import { Mail, Phone } from 'lucide-vue-next'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
|
||||
const priorities = ref([])
|
||||
const { toast } = useToast()
|
||||
const conversationStore = useConversationStore()
|
||||
const agents = ref([])
|
||||
const teams = ref([])
|
||||
const agentSelectDropdownOpen = ref(false)
|
||||
const teamSelectDropdownOpen = ref(false)
|
||||
const prioritySelectDropdownOpen = ref(false)
|
||||
const tagsSelected = computed(() => conversationStore.conversation.data.tags)
|
||||
const tags = ref([])
|
||||
const tagIDMap = {}
|
||||
const tagDropdownOpen = ref(false)
|
||||
const tagSearchTerm = ref('')
|
||||
const tagsFiltered = computed(() => tags.value.filter((i) => !tagsSelected.value.includes(i.label)))
|
||||
|
||||
const actionAccordion = {
|
||||
title: 'Actions'
|
||||
}
|
||||
const infoAccordion = {
|
||||
title: 'Information'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api
|
||||
.getUsers()
|
||||
.then((resp) => {
|
||||
agents.value = resp.data.data
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'Could not fetch users',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
|
||||
api
|
||||
.getTeams()
|
||||
.then((resp) => {
|
||||
teams.value = resp.data.data
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'Could not fetch teams',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
|
||||
api
|
||||
.getTags()
|
||||
.then(async (resp) => {
|
||||
let dt = resp.data.data
|
||||
dt.forEach((item) => {
|
||||
tags.value.push({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
})
|
||||
tagIDMap[item.name] = item.id
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'Could not fetch tags',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
|
||||
getPrioritites()
|
||||
})
|
||||
|
||||
const getPrioritites = async () => {
|
||||
const resp = await api.getPriorities()
|
||||
priorities.value = resp.data.data.map((priority) => priority.name)
|
||||
}
|
||||
|
||||
const handleAssignedUserChange = (v) => {
|
||||
conversationStore.updateAssignee('user', {
|
||||
assignee_id: v.split(':')[0]
|
||||
})
|
||||
}
|
||||
const handleAssignedTeamChange = (v) => {
|
||||
conversationStore.updateAssignee('team', {
|
||||
assignee_id: v.split(':')[0]
|
||||
})
|
||||
}
|
||||
|
||||
const handlePriorityChange = (v) => {
|
||||
conversationStore.updatePriority(v)
|
||||
}
|
||||
|
||||
const handleUpsertTags = () => {
|
||||
let tagIDs = tagsSelected.value.map((tag) => {
|
||||
if (tag in tagIDMap) {
|
||||
return tagIDMap[tag]
|
||||
}
|
||||
})
|
||||
conversationStore.upsertTags({
|
||||
tag_ids: JSON.stringify(tagIDs)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -39,6 +39,7 @@ const editor = ref(
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
// Common class for all inline images.
|
||||
class: 'inline-image',
|
||||
},
|
||||
}),
|
||||
@@ -49,13 +49,13 @@ import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
import Editor from './Editor.vue'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useCannedResponses } from '@/stores/canned_responses'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxBottomMenuBar.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const clearContent = ref(false)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-y h-14 px-2">
|
||||
<div class="flex justify-items-start gap-2">
|
||||
<!-- File inputs -->
|
||||
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
|
||||
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" />
|
||||
<!-- Editor buttons -->
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
|
||||
<Bold class="h-4 w-4" />
|
||||
</Toggle>
|
||||
@@ -16,8 +18,7 @@
|
||||
<Image class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText"> Send
|
||||
</Button>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-64 space-y-2">
|
||||
<MessageCircleWarning :stroke-width="1.4" :size="90" />
|
||||
<component :is="icon" :stroke-width="1.4" :size="90" />
|
||||
<h1 class="text-lg font-semibold text-gray-800">
|
||||
{{ title }}
|
||||
</h1>
|
||||
@@ -11,9 +11,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MessageCircleWarning } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
111
frontend/src/components/conversation/list/ConversationList.vue
Normal file
111
frontend/src/components/conversation/list/ConversationList.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
|
||||
<!-- Filters -->
|
||||
<ConversationListFilters v-model:type="conversationType" v-model:filter="conversationFilter" :handleFilterChange="handleFilterChange">
|
||||
</ConversationListFilters>
|
||||
|
||||
<!-- Error / Empty list -->
|
||||
<EmptyList v-if="emptyConversations" title="No conversation found" message="Try adjusting filters."
|
||||
:icon="MessageCircleQuestion"></EmptyList>
|
||||
<EmptyList v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
|
||||
:message="conversationStore.conversations.errorMessage" :icon="MessageCircleWarning"></EmptyList>
|
||||
|
||||
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
|
||||
<!-- List skeleton -->
|
||||
<div v-if="conversationsLoading">
|
||||
<ConversationListItemSkeleton v-for="index in 8" :key="index"></ConversationListItemSkeleton>
|
||||
</div>
|
||||
|
||||
<!-- Item -->
|
||||
<ConversationListItem />
|
||||
|
||||
<!-- Load more -->
|
||||
<div class="flex justify-center items-center mt-5 relative">
|
||||
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
|
||||
<Button variant="link" @click="loadNextPage">
|
||||
<Spinner v-if="conversationStore.conversations.loading" />
|
||||
<p v-else>Load more...</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="everythingLoaded">All conversations loaded!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { subscribeConversationsList } from '@/websocket.js'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_FILTERS } from '@/constants/conversation'
|
||||
import { MessageCircleWarning, MessageCircleQuestion } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Spinner from '@/components/ui/spinner/Spinner.vue'
|
||||
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
|
||||
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
|
||||
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
|
||||
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const conversationFilter = ref(CONVERSATION_FILTERS.ALL)
|
||||
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
|
||||
let listRefreshInterval = null
|
||||
|
||||
onMounted(() => {
|
||||
conversationStore.fetchConversations(conversationType.value, conversationFilter.value)
|
||||
subscribeConversationsList(conversationType.value, conversationFilter.value)
|
||||
// Refresh list every min.
|
||||
listRefreshInterval = setInterval(() => {
|
||||
conversationStore.fetchConversations(conversationType.value, conversationFilter.value)
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(listRefreshInterval)
|
||||
})
|
||||
|
||||
watch(conversationType, (newType) => {
|
||||
conversationStore.fetchConversations(newType, conversationFilter.value)
|
||||
subscribeConversationsList(newType, conversationFilter.value)
|
||||
})
|
||||
|
||||
const handleFilterChange = (filter) => {
|
||||
conversationFilter.value = filter
|
||||
conversationStore.fetchConversations(conversationType.value, filter)
|
||||
subscribeConversationsList(conversationType.value, conversationFilter.value)
|
||||
}
|
||||
|
||||
const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations(conversationType.value, conversationFilter.value)
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return (
|
||||
conversationStore.sortedConversations.length !== 0 &&
|
||||
!conversationStore.conversations.errorMessage &&
|
||||
!conversationStore.conversations.loading
|
||||
)
|
||||
})
|
||||
|
||||
const emptyConversations = computed(() => {
|
||||
return (
|
||||
conversationStore.sortedConversations.length === 0 &&
|
||||
!conversationStore.conversations.errorMessage &&
|
||||
!conversationStore.conversations.loading
|
||||
)
|
||||
})
|
||||
|
||||
const hasErrored = computed(() => {
|
||||
return conversationStore.conversations.errorMessage ? true : false
|
||||
})
|
||||
|
||||
const everythingLoaded = computed(() => {
|
||||
return !conversationStore.conversations.errorMessage && !emptyConversations.value
|
||||
})
|
||||
|
||||
const conversationsLoading = computed(() => {
|
||||
return conversationStore.conversations.loading
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex justify-between px-2 py-2 border-b">
|
||||
|
||||
<Tabs v-model="conversationType">
|
||||
<TabsList class="w-full flex justify-evenly">
|
||||
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
|
||||
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
|
||||
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<div class="flex items-center mr-2">
|
||||
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52">
|
||||
<div>
|
||||
<Select v-model="conversationFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="status_all"> All </SelectItem>
|
||||
<SelectItem value="status_open"> Open </SelectItem>
|
||||
<SelectItem value="status_processing"> Processing </SelectItem>
|
||||
<SelectItem value="status_spam"> Spam </SelectItem>
|
||||
<SelectItem value="status_resolved"> Resolved </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineModel, watch } from 'vue'
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
const conversationType = defineModel('type')
|
||||
const conversationFilter = defineModel('filter')
|
||||
const props = defineProps({
|
||||
handleFilterChange: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
watch(conversationFilter, (newValue) => {
|
||||
props.handleFilterChange(newValue)
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@
|
||||
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.conversation.data?.uuid }"
|
||||
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
|
||||
@click="router.push('/conversations/' + conversation.uuid)">
|
||||
|
||||
<div class="pl-3">
|
||||
<Avatar class="size-[45px]">
|
||||
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
|
||||
@@ -11,11 +12,12 @@
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 w-full border-b pb-2">
|
||||
<div class="flex justify-between pt-2 pr-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 flex gap-x-1">
|
||||
<Mail size="12" />
|
||||
<Mail size="13" />
|
||||
{{ conversation.inbox_name }}
|
||||
</p>
|
||||
<p class="text-base font-normal">
|
||||
@@ -49,7 +51,6 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { formatTime } from '@/utils/datetime'
|
||||
|
||||
import { Mail, CheckCheck } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-5 p-6 border-b">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
</script>
|
||||
75
frontend/src/components/conversation/sidebar/AssignAgent.vue
Normal file
75
frontend/src/components/conversation/sidebar/AssignAgent.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ assignedAgentName }}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0">
|
||||
<Command @update:searchTerm="handleFilterAgents">
|
||||
<CommandInput class="h-9" placeholder="Search agent" />
|
||||
<CommandEmpty>No agent found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="agent in filteredAgents" :key="agent.id" :value="agent.id"
|
||||
@select="handleSelectAgent(agent.id)">
|
||||
{{ `${agent.first_name} ${agent.last_name}` }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.assigned_user_id === agent.id ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
selectAgent: Function,
|
||||
agents: Array,
|
||||
})
|
||||
const filteredAgents = ref([])
|
||||
|
||||
const assignedAgentName = computed(() => {
|
||||
const assignedUserId = props.conversation.assigned_user_id
|
||||
if (!assignedUserId) return 'Select agent...'
|
||||
const agent = props.agents.find(agent => agent.id === assignedUserId)
|
||||
return agent ? `${agent.first_name} ${agent.last_name}` : 'Select agent...'
|
||||
})
|
||||
|
||||
const handleFilterAgents = (search) => {
|
||||
filteredAgents.value = props.agents.filter(agent =>
|
||||
`${agent.first_name.toLowerCase()} ${agent.last_name.toLowerCase()}`.includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectAgent = (id) => {
|
||||
props.selectAgent(id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(() => props.agents, (newAgents) => {
|
||||
filteredAgents.value = [...newAgents]
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
74
frontend/src/components/conversation/sidebar/AssignTeam.vue
Normal file
74
frontend/src/components/conversation/sidebar/AssignTeam.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ assignedTeamName }}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0">
|
||||
<Command @update:searchTerm="handleFilterTeams">
|
||||
<CommandInput class="h-9" placeholder="Search team" />
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="team in filteredTeams" :key="team.id" :value="team.id" @select="handleSelectTeam(team.id)">
|
||||
{{ team.name }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.assigned_team_id === team.id ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
selectTeam: Function,
|
||||
teams: Array,
|
||||
})
|
||||
const filteredTeams = ref([])
|
||||
|
||||
const assignedTeamName = computed(() => {
|
||||
const assignedTeamId = props.conversation.assigned_team_id
|
||||
if (!assignedTeamId) return 'Select team...'
|
||||
const team = props.teams.find(team => team.id === assignedTeamId)
|
||||
return team ? team.name : 'Select team...'
|
||||
})
|
||||
|
||||
const handleFilterTeams = (search) => {
|
||||
filteredTeams.value = props.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectTeam = (id) => {
|
||||
props.selectTeam(id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(() => props.teams, (newTeams) => {
|
||||
filteredTeams.value = [...newTeams]
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
hi
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Initiated at</p>
|
||||
<p>
|
||||
{{ format(conversation.created_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<p v-if="conversation.first_reply_at">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<p v-if="conversation.resolved_at">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Closed at</p>
|
||||
<p v-if="conversation.closed_at">
|
||||
{{ format(conversation.closed_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { format } from 'date-fns'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="p-3">
|
||||
<ConversationSideBarContact :conversation="conversationStore.conversation.data"></ConversationSideBarContact>
|
||||
<Accordion type="multiple" collapsible class="border-t mt-4">
|
||||
<AccordionItem value="Actions">
|
||||
<AccordionTrigger>
|
||||
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
Actions
|
||||
</h4>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="space-y-5">
|
||||
<!-- Agent -->
|
||||
<AssignAgent :agents="agents" :conversation="conversationStore.conversation.data" :selectAgent="selectAgent">
|
||||
</AssignAgent>
|
||||
<!-- Team -->
|
||||
<AssignTeam :teams="teams" :conversation="conversationStore.conversation.data" :selectTeam="selectTeam">
|
||||
</AssignTeam>
|
||||
<!-- Priority -->
|
||||
<PriorityChange :priorities="priorities" :conversation="conversationStore.conversation.data"
|
||||
:selectPriority="selectPriority"></PriorityChange>
|
||||
<!-- Tags -->
|
||||
<SelectTag :initialValue="conversationStore.conversation.data.tags" v-model="selectedTags" :items="tags"
|
||||
placeHolder="Select tags"></SelectTag>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="Information">
|
||||
<AccordionTrigger>
|
||||
<span class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
Information
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ConversationInfo :conversation="conversationStore.conversation.data"></ConversationInfo>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import api from '@/api'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import ConversationInfo from './ConversationInfo.vue'
|
||||
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
|
||||
import AssignAgent from './AssignAgent.vue'
|
||||
import AssignTeam from './AssignTeam.vue'
|
||||
import PriorityChange from './PriorityChange.vue'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
|
||||
const priorities = ref([])
|
||||
const { toast } = useToast()
|
||||
const conversationStore = useConversationStore()
|
||||
const agents = ref([])
|
||||
const teams = ref([])
|
||||
const selectedTags = ref([])
|
||||
const tags = ref([])
|
||||
const tagIDMap = {}
|
||||
const filteredAgents = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
fetchTeams()
|
||||
fetchTags()
|
||||
getPrioritites()
|
||||
})
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const resp = await api.getUsers()
|
||||
agents.value = resp.data.data
|
||||
filteredAgents.value = resp.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch users',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTeams = async () => {
|
||||
try {
|
||||
const resp = await api.getTeams()
|
||||
teams.value = resp.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch teams',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const resp = await api.getTags()
|
||||
resp.data.data.forEach(item => {
|
||||
tagIDMap[item.name] = item.id
|
||||
tags.value.push(item.name)
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch tags',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getPrioritites = async () => {
|
||||
const resp = await api.getPriorities()
|
||||
priorities.value = resp.data.data.map((priority) => priority.name)
|
||||
}
|
||||
|
||||
const handleAssignedUserChange = (id) => {
|
||||
conversationStore.updateAssignee('user', {
|
||||
assignee_id: id
|
||||
})
|
||||
}
|
||||
|
||||
const handleAssignedTeamChange = (id) => {
|
||||
conversationStore.updateAssignee('team', {
|
||||
assignee_id: id
|
||||
})
|
||||
}
|
||||
|
||||
const handlePriorityChange = (priority) => {
|
||||
conversationStore.updatePriority(priority)
|
||||
}
|
||||
|
||||
watch(selectedTags, () => {
|
||||
handleUpsertTags()
|
||||
}, { deep: true })
|
||||
|
||||
const handleUpsertTags = () => {
|
||||
let tagIDs = selectedTags.value.map((tag) => {
|
||||
if (tag in tagIDMap) {
|
||||
return tagIDMap[tag]
|
||||
}
|
||||
})
|
||||
conversationStore.upsertTags({
|
||||
tag_ids: JSON.stringify(tagIDs)
|
||||
})
|
||||
}
|
||||
|
||||
const selectAgent = (id) => {
|
||||
conversationStore.conversation.data.assigned_user_id = id
|
||||
handleAssignedUserChange(id)
|
||||
}
|
||||
|
||||
const selectTeam = (id) => {
|
||||
conversationStore.conversation.data.assigned_team_id = id
|
||||
handleAssignedTeamChange(id)
|
||||
}
|
||||
|
||||
const selectPriority = (priority) => {
|
||||
conversationStore.conversation.data.priority = priority
|
||||
handlePriorityChange(priority)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.first_name.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h4 class="mt-3">
|
||||
{{ conversation?.first_name + ' ' + conversation?.last_name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.email">
|
||||
<Mail class="size-3 mt-1"></Mail>
|
||||
{{ conversation.email }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.phone_number">
|
||||
<Phone class="size-3 mt-1"></Phone>
|
||||
{{ conversation.phone_number }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Mail, Phone } from 'lucide-vue-next'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{
|
||||
selectedPriority
|
||||
}}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0 PopoverContent">
|
||||
<Command @update:searchTerm="handleFilterPriorities">
|
||||
<CommandInput class="h-9" placeholder="Search priority" />
|
||||
<CommandEmpty>No priority found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="priority in filteredPriorities" :key="priority" :value="priority"
|
||||
@select="handleSelectPriority(priority)">
|
||||
{{ priority }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.priority === priority ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
priorities: Array,
|
||||
conversation: Object,
|
||||
selectPriority: Function,
|
||||
})
|
||||
const filteredPriorities = ref([])
|
||||
|
||||
const selectedPriority = computed(() => {
|
||||
return props.conversation.priority
|
||||
? props.priorities.find(priority => priority === props.conversation.priority)
|
||||
: 'Select priority'
|
||||
})
|
||||
|
||||
const handleFilterPriorities = (search) => {
|
||||
filteredPriorities.value = props.priorities.filter(priority =>
|
||||
priority.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
watch(() => props.priorities, (newPriorities) => {
|
||||
filteredPriorities.value = [...newPriorities]
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSelectPriority = (priority) => {
|
||||
props.selectPriority(priority)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
<div class="flex justify-between px-2 py-2 border-b">
|
||||
<Tabs v-model:model-value="conversationType">
|
||||
<TabsList class="w-full flex justify-evenly">
|
||||
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
|
||||
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
|
||||
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<div class="flex items-center mr-2">
|
||||
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52">
|
||||
<div>
|
||||
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<!-- <SelectLabel>Status</SelectLabel> -->
|
||||
<SelectItem value="status_all"> All </SelectItem>
|
||||
<SelectItem value="status_open"> Open </SelectItem>
|
||||
<SelectItem value="status_processing"> Processing </SelectItem>
|
||||
<SelectItem value="status_spam"> Spam </SelectItem>
|
||||
<SelectItem value="status_resolved"> Resolved </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<EmptyList v-if="emptyConversations" title="No conversation found" message="Try adjusting filters."></EmptyList>
|
||||
<EmptyList v-if="conversationStore.conversations.errorMessage" title="Something went wrong" :message="conversationStore.conversations.errorMessage"></EmptyList>
|
||||
|
||||
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
|
||||
<ConversationListItem />
|
||||
|
||||
<div v-if="conversationsLoading">
|
||||
<div class="flex items-center gap-5 p-6 border-b" v-for="index in 8" :key="index">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center mt-5 relative">
|
||||
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
|
||||
<Button variant="link" @click="loadNextPage">
|
||||
<Spinner v-if="conversationStore.conversations.loading" />
|
||||
<p v-else>Load more...</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="everythingLoaded">All conversations loaded!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { subscribeConversationsList } from '@/websocket.js'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
|
||||
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import Spinner from '@/components/ui/spinner/Spinner.vue'
|
||||
import EmptyList from '@/components/conversationlist/ConversationEmptyList.vue'
|
||||
import ConversationListItem from '@/components/conversationlist/ConversationListItem.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.ALL)
|
||||
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
|
||||
let listRefreshInterval = null
|
||||
|
||||
onMounted(() => {
|
||||
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
|
||||
subscribeConversationsList(conversationType.value, predefinedFilter.value)
|
||||
// Refesh list every 1 minute to sync any missed changes.
|
||||
listRefreshInterval = setInterval(() => {
|
||||
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(listRefreshInterval)
|
||||
})
|
||||
|
||||
watch(conversationType, (newType) => {
|
||||
conversationStore.fetchConversations(newType, predefinedFilter.value)
|
||||
subscribeConversationsList(newType, predefinedFilter.value)
|
||||
})
|
||||
|
||||
const handleFilterChange = (filter) => {
|
||||
predefinedFilter.value = filter
|
||||
conversationStore.fetchConversations(conversationType.value, filter)
|
||||
subscribeConversationsList(conversationType.value, predefinedFilter.value)
|
||||
}
|
||||
|
||||
const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return (
|
||||
conversationStore.sortedConversations.length !== 0 &&
|
||||
!conversationStore.conversations.errorMessage &&
|
||||
!conversationStore.conversations.loading
|
||||
)
|
||||
})
|
||||
|
||||
const emptyConversations = computed(() => {
|
||||
return (
|
||||
conversationStore.sortedConversations.length === 0 &&
|
||||
!conversationStore.conversations.errorMessage &&
|
||||
!conversationStore.conversations.loading
|
||||
)
|
||||
})
|
||||
|
||||
const hasErrored = computed(() => {
|
||||
return conversationStore.conversations.errorMessage ? true : false
|
||||
})
|
||||
|
||||
const everythingLoaded = computed(() => {
|
||||
return !conversationStore.conversations.errorMessage && !emptyConversations.value
|
||||
})
|
||||
|
||||
const conversationsLoading = computed(() => {
|
||||
return conversationStore.conversations.loading
|
||||
})
|
||||
</script>
|
||||
@@ -11,27 +11,17 @@
|
||||
<ComboboxRoot v-model:open="isOpen" class="w-full">
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxInput :placeholder="placeHolder" as-child>
|
||||
<TagsInputInput
|
||||
class="w-full px-3"
|
||||
:class="selectedItems.length > 0 ? 'mt-2' : ''"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
<TagsInputInput class="w-full px-3" :class="selectedItems.length > 0 ? 'mt-2' : ''"
|
||||
@keydown.enter.prevent />
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<CommandList
|
||||
position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<CommandList position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
|
||||
<CommandEmpty />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="item in filteredItems"
|
||||
:key="item"
|
||||
:value="item"
|
||||
@select.prevent="selectItem(item)"
|
||||
>
|
||||
<CommandItem v-for="item in filteredItems" :key="item" :value="item" @select.prevent="selectItem(item)">
|
||||
{{ item }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
@@ -96,7 +86,7 @@ const filteredItems = computed(() => {
|
||||
return props.items.filter((item) => !selectedItems.value.includes(item))
|
||||
})
|
||||
|
||||
function selectItem(item) {
|
||||
function selectItem (item) {
|
||||
if (!selectedItems.value.includes(item)) {
|
||||
selectedItems.value.push(item)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CONVERSATION_PRE_DEFINED_FILTERS = {
|
||||
export const CONVERSATION_FILTERS = {
|
||||
ALL: 'status_all',
|
||||
STATUS_OPEN: 'status_open',
|
||||
STATUS_PROCESSING: 'status_processing',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<!-- Resizable panel last resize value is stored in the localstorage -->
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="conversation.vue.resizable.panel">
|
||||
<ResizablePanel :min-size="23" :default-size="23" :max-size="40">
|
||||
<ConversationList></ConversationList>
|
||||
@@ -25,9 +24,9 @@
|
||||
import { onMounted, watch } from 'vue'
|
||||
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import ConversationList from '@/components/conversationlist/ConversationList.vue'
|
||||
import ConversationList from '@/components/conversation/list/ConversationList.vue'
|
||||
import Conversation from '@/components/conversation/ConversationPage.vue'
|
||||
import ConversationSideBar from '@/components/conversation/ConversationSideBar.vue'
|
||||
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
|
||||
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export function sendMessage (message) {
|
||||
|
||||
export function subscribeConversationsList (type, filter) {
|
||||
const message = {
|
||||
action: 'conversations_sub',
|
||||
action: 'conversations_list_sub',
|
||||
type: type,
|
||||
filter: filter
|
||||
};
|
||||
|
||||
@@ -703,11 +703,6 @@
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@heroicons/vue@^2.1.1":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@heroicons/vue/-/vue-2.1.3.tgz#7553aea937bf23013b44ab3787ce181a45188356"
|
||||
integrity sha512-CP4ipIwFbV4NEn8ULUCN110wkV0wZq6dsViDL3HwgIh+jn5yQGlRm6QaRN+Mv+o+UsUBbRDei3Je/q0NZHf5Gg==
|
||||
|
||||
"@humanwhocodes/config-array@^0.11.14":
|
||||
version "0.11.14"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
|
||||
|
||||
@@ -2,9 +2,7 @@ package models
|
||||
|
||||
// Action constants for WebSocket messages.
|
||||
const (
|
||||
ActionConversationsListSub = "conversations_sub"
|
||||
ActionConversationSub = "conversation_sub"
|
||||
ActionConversationUnSub = "conversation_unsub"
|
||||
ActionConversationsListSub = "conversations_list_sub"
|
||||
MessageTypeMessagePropUpdate = "message_prop_update"
|
||||
MessageTypeConversationPropertyUpdate = "conversation_prop_update"
|
||||
MessageTypeNewMessage = "new_message"
|
||||
|
||||
Reference in New Issue
Block a user