feat: allow sending attachments in new conversations

- replace existing combobox selects with common component selectcombobox.vue
This commit is contained in:
Abhinav Raut
2025-06-03 04:03:16 +05:30
parent 5fe5ac5882
commit f6e2fc1956
31 changed files with 512 additions and 685 deletions

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings"
"time" "time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -11,6 +10,7 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil" "github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -18,6 +18,18 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type createConversationRequest struct {
InboxID int `json:"inbox_id" form:"inbox_id"`
AssignedAgentID int `json:"agent_id" form:"agent_id"`
AssignedTeamID int `json:"team_id" form:"team_id"`
Email string `json:"contact_email" form:"contact_email"`
FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"`
Subject string `json:"subject" form:"subject"`
Content string `json:"content" form:"content"`
Attachments []int `json:"attachments" form:"attachments"`
}
// handleGetAllConversations retrieves all conversations. // handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error { func handleGetAllConversations(r *fastglue.Request) error {
var ( var (
@@ -632,36 +644,32 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
// handleCreateConversation creates a new conversation and sends a message to it. // handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error { func handleCreateConversation(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id") req = createConversationRequest{}
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
to = []string{email}
) )
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
to := []string{req.Email}
// Validate required fields // Validate required fields
if inboxID <= 0 { if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
} }
if subject == "" { if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
} }
if content == "" { if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
} }
if email == "" { if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
} }
if firstName == "" { if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
} }
@@ -671,7 +679,7 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Check if inbox exists and is enabled. // Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID) inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -681,11 +689,11 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(email), Email: null.StringFrom(req.Email),
SourceChannelID: null.StringFrom(email), SourceChannelID: null.StringFrom(req.Email),
FirstName: firstName, FirstName: req.FirstName,
LastName: lastName, LastName: req.LastName,
InboxID: inboxID, InboxID: req.InboxID,
} }
if err := app.user.CreateContact(&contact); err != nil { if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
@@ -695,10 +703,10 @@ func handleCreateConversation(r *fastglue.Request) error {
conversationID, conversationUUID, err := app.conversation.CreateConversation( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID, contact.ContactChannelID,
inboxID, req.InboxID,
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
subject, req.Subject,
true, /** append reference number to subject **/ true, /** append reference number to subject **/
) )
if err != nil { if err != nil {
@@ -706,8 +714,19 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
} }
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
// Send reply to the created conversation. // Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails. // Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err) app.lo.Error("error deleting conversation", "error", err)
@@ -716,11 +735,11 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Assign the conversation to the agent or team. // Assign the conversation to the agent or team.
if assignedAgentID > 0 { if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user) app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
} }
if assignedTeamID > 0 { if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user) app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
} }
// Send the created conversation back to the client. // Send the created conversation back to the client.

View File

@@ -81,8 +81,7 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions) if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -110,7 +109,7 @@ func handleUpdateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil { if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -275,13 +274,17 @@ func validateMacro(app *App, macro models.Macro) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
} }
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil { if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
} }
for _, a := range act { for _, a := range act {
if len(a.Value) == 0 { if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
} }
} }
return nil return nil

View File

@@ -132,7 +132,6 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string) cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{} req = messageReq{}
) )
@@ -153,6 +152,7 @@ func handleSendMessage(r *fastglue.Request) error {
} }
// Prepare attachments. // Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
if err != nil { if err != nil {

View File

@@ -231,7 +231,11 @@ const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conv
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const createConversation = (data) => http.post('/api/v1/conversations', data) const createConversation = (data) => http.post('/api/v1/conversations', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data) const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data) const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`) const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)

View File

@@ -0,0 +1,24 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -0,0 +1,58 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else>{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -44,79 +44,47 @@
<div class="flex-1"> <div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator"> <div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'"> <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<ComboBox <SelectComboBox
v-if="getFieldOptions(modelFilter).length > 0" v-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value" v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)" :items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')" :placeholder="t('form.field.select')"
> type="user"
<template #item="{ item }"> />
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-1"> <SelectComboBox
<Avatar class="w-6 h-6"> v-else-if="
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" /> getFieldOptions(modelFilter).length > 0 &&
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback> modelFilter.field === 'assigned_team_id'
</Avatar> "
<span>{{ item.label }}</span> v-model="modelFilter.value"
</div> :items="getFieldOptions(modelFilter)"
</div> :placeholder="t('form.field.select')"
<div v-else-if="modelFilter.field === 'assigned_team_id'"> type="team"
<div class="flex items-center gap-2 ml-2"> />
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span> <SelectComboBox
</div> v-else-if="getFieldOptions(modelFilter).length > 0"
</div> v-model="modelFilter.value"
<div v-else> :items="getFieldOptions(modelFilter)"
{{ item.label }} :placeholder="t('form.field.select')"
</div> />
</template>
<template #selected="{ selected }">
<div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input <Input
v-else v-else
v-model="modelFilter.value" v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100" class="bg-transparent hover:bg-slate-100"
:placeholder="t('form.field.value')" :placeholder="t('globals.terms.value')"
type="text" type="text"
/> />
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<CloseButton :onClose="() => removeFilter(index)" />
<button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
</div> </div>
<div class="flex items-center justify-between pt-3"> <div class="flex items-center justify-between pt-3">
@@ -146,12 +114,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ComboBox from '@/components/ui/combobox/ComboBox.vue' import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -48,63 +48,17 @@
class="w-48" class="w-48"
v-if="action.type && conversationActions[action.type]?.type === 'select'" v-if="action.type && conversationActions[action.type]?.type === 'select'"
> >
<ComboBox <SelectComboBox
v-model="action.value[0]" v-model="action.value[0]"
:items="conversationActions[action.type]?.options" :items="conversationActions[action.type]?.options"
:placeholder="t('form.field.select')" :placeholder="t('form.field.select')"
@select="handleValueChange($event, index)" @select="handleValueChange($event, index)"
> :type="action.type === 'assign_team' ? 'team' : 'user'"
<template #item="{ item }"> />
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="action.type === 'assign_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="action.type === 'assign_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div v-else-if="action.type === 'assign_user'" class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url ?? ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
</div> </div>
</div> </div>
<div class="cursor-pointer" @click.prevent="removeAction(index)"> <CloseButton :onClose="() => removeAction(index)" />
<X size="16" />
</div>
</div> </div>
<div <div
@@ -133,7 +87,7 @@
<script setup> <script setup>
import { toRefs } from 'vue' import { toRefs } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next' import CloseButton from '@/components/button/CloseButton.vue'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import { import {
Select, Select,
@@ -143,13 +97,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '@/utils/strings.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.vue' import Editor from '@/features/conversation/ConversationTextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
actions: { actions: {

View File

@@ -102,59 +102,12 @@
<!-- Select input --> <!-- Select input -->
<div v-if="inputType(index) === 'select'"> <div v-if="inputType(index) === 'select'">
<ComboBox <SelectComboBox
v-model="rule.value" v-model="componentField"
:items="getFieldOptions(rule.field, rule.field_type)" :items="getFieldOptions(rule.field, rule.field_type)"
@select="handleValueChange($event, index)" @select="handleValueChange($event, index)"
> :type="rule.field === 'assigned_user' ? 'user' : 'team'"
<template #item="{ item }"> />
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="rule.field === 'assigned_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="rule.field === 'assigned_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="rule?.field === 'assigned_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div
v-else-if="rule?.field === 'assigned_user'"
class="flex items-center gap-2"
>
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
</div> </div>
<!-- Tag input --> <!-- Tag input -->
@@ -209,9 +162,7 @@
<div v-else class="flex-1"></div> <div v-else class="flex-1"></div>
<!-- Remove condition --> <!-- Remove condition -->
<div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)"> <CloseButton :onClose="() => removeCondition(index)" />
<X size="16" />
</div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -242,6 +193,7 @@ import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -258,13 +210,11 @@ import {
TagsInputItemDelete, TagsInputItemDelete,
TagsInputItemText TagsInputItemText
} from '@/components/ui/tags-input' } from '@/components/ui/tags-input'
import { X } from 'lucide-vue-next'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
ruleGroup: { ruleGroup: {

View File

@@ -23,6 +23,21 @@
</Select> </Select>
</div> </div>
<div
v-if="!isLoading && rules.length === 0"
class="flex flex-col items-center justify-center py-12 px-4"
>
<div class="text-center space-y-2">
<p class="text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: $t('globals.terms.rule', 2).toLowerCase()
})
}}
</p>
</div>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div v-if="type === 'new_conversation'"> <div v-if="type === 'new_conversation'">
<draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd"> <draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">

View File

@@ -1,125 +1,132 @@
<template> <template>
<div class="space-y-5 rounded"> <div class="space-y-6">
<div class="space-y-5"> <!-- Empty State -->
<div v-for="(action, index) in model" :key="index" class="space-y-5"> <div
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" /> v-if="!model.length"
class="text-center py-12 px-6 border-2 border-dashed border-muted rounded-lg"
>
<div class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-3">
<Plus class="w-6 h-6 text-muted-foreground" />
</div>
<h3 class="text-sm font-medium text-foreground mb-2">
{{ $t('globals.messages.no', { name: $t('globals.terms.action', 2).toLowerCase() }) }}
</h3>
<Button @click="add" variant="outline" size="sm" class="inline-flex items-center gap-2">
<Plus class="w-4 h-4" />
{{ config.addButtonText }}
</Button>
</div>
<div class="space-y-3"> <!-- Actions List -->
<div class="flex items-center justify-between"> <div v-else class="space-y-6">
<div class="flex gap-5"> <div v-for="(action, index) in model" :key="index" class="relative">
<div class="w-48"> <!-- Action Card -->
<Select <div class="border rounded p-6 shadow-sm hover:shadow-md transition-shadow">
v-model="action.type" <div class="flex items-start justify-between gap-4">
@update:modelValue="(value) => updateField(value, index)" <div class="flex-1 space-y-4">
<!-- Action Type Selection -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 max-w-xs">
<label class="block text-sm font-medium mb-2">{{
$t('globals.messages.type', {
name: $t('globals.terms.action')
})
}}</label>
<Select
v-model="action.type"
@update:modelValue="(value) => updateField(value, index)"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="config.typePlaceholder" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="(actionConfig, key) in config.actions"
:key="key"
:value="key"
>
{{ actionConfig.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Value Selection -->
<div
v-if="action.type && config.actions[action.type]?.type === 'select'"
class="flex-1 max-w-xs"
> >
<SelectTrigger> <label class="block text-sm font-medium mb-2">Value</label>
<SelectValue :placeholder="config.typePlaceholder" />
</SelectTrigger> <SelectComboBox
<SelectContent> v-if="action.type === 'assign_user'"
<SelectGroup> v-model="action.value[0]"
<SelectItem :items="config.actions[action.type].options"
v-for="(actionConfig, key) in config.actions" :placeholder="config.valuePlaceholder"
:key="key" @update:modelValue="(value) => updateValue(value, index)"
:value="key" type="user"
> />
{{ actionConfig.label }}
</SelectItem> <SelectComboBox
</SelectGroup> v-if="action.type === 'assign_team'"
</SelectContent> v-model="action.value[0]"
</Select> :items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
type="team"
/>
</div>
</div> </div>
<!-- Tag Selection -->
<div <div
v-if="action.type && config.actions[action.type]?.type === 'select'" v-if="action.type && config.actions[action.type]?.type === 'tag'"
class="w-48" class="max-w-md"
> >
<ComboBox <label class="block text-sm font-medium mb-2">Tags</label>
v-model="action.value[0]" <SelectTag
:items="config.actions[action.type].options" v-model="action.value"
:placeholder="config.valuePlaceholder" :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
@update:modelValue="(value) => updateValue(value, index)" placeholder="Select tags"
> />
<template #item="{ item }">
<div v-if="action.type === 'assign_user'">
<div class="flex items-center flex-1 gap-2 ml-2">
<Avatar class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback
>{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else-if="action.type === 'assign_team'">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else>
{{ item.label }}
</div>
</template>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_user'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
</div>
<div v-else-if="action.type === 'assign_team'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>
{{ $t('form.field.selectTeam') }}
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
<div v-else>{{ $t('form.field.select') }}</div>
</template>
</ComboBox>
</div> </div>
</div> </div>
<X class="cursor-pointer w-4" @click="remove(index)" /> <!-- Remove Button -->
</div> <Button
variant="ghost"
<div v-if="action.type && config.actions[action.type]?.type === 'tag'"> size="sm"
<SelectTag @click="remove(index)"
v-model="action.value" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-8 h-8 p-0"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))" >
placeholder="Select tag" <X class="w-4 h-4" />
/> <span class="sr-only">Remove action</span>
</Button>
</div> </div>
</div> </div>
</div> </div>
<!-- Add Action Button -->
<div class="flex justify-center pt-2">
<Button
type="button"
variant="outline"
@click="add"
class="inline-flex items-center gap-2 border-dashed hover:border-solid"
>
<Plus class="w-4 h-4" />
{{ config.addButtonText }}
</Button>
</div>
</div> </div>
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next' import { X, Plus } from 'lucide-vue-next'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -128,10 +135,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue' import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const model = defineModel('actions', { const model = defineModel('actions', {
type: Array, type: Array,

View File

@@ -29,7 +29,9 @@
<FormField v-slot="{ componentField }" name="actions"> <FormField v-slot="{ componentField }" name="actions">
<FormItem> <FormItem>
<FormLabel> {{ t('admin.macro.actions') }}</FormLabel> <FormLabel>
{{ t('globals.terms.action', 2) }} ({{ t('globals.terms.optional', 1).toLowerCase() }})
</FormLabel>
<FormControl> <FormControl>
<ActionBuilder <ActionBuilder
v-model:actions="componentField.modelValue" v-model:actions="componentField.modelValue"
@@ -41,79 +43,69 @@
</FormItem> </FormItem>
</FormField> </FormField>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <FormField v-slot="{ componentField, handleChange }" name="visible_when">
<FormField v-slot="{ componentField }" name="visible_when"> <FormItem>
<FormItem> <FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
<FormLabel>{{ t('admin.macro.visibleWhen') }}</FormLabel> <FormControl>
<FormControl> <SelectTag
<Select v-bind="componentField"> :items="[
<SelectTrigger> { label: t('globals.messages.replying'), value: 'replying' },
<SelectValue /> {
</SelectTrigger> label: t('globals.messages.starting', {
<SelectContent> name: t('globals.terms.conversation').toLowerCase()
<SelectGroup> }),
<SelectItem value="replying">{{ t('admin.macro.replying') }}</SelectItem> value: 'starting_conversation'
<SelectItem value="starting_conversation">{{ },
t('admin.macro.startingConversation') {
}}</SelectItem> label: t('globals.messages.adding', {
<SelectItem value="adding_private_note">{{ name: t('globals.terms.privateNote', 2).toLowerCase()
t('admin.macro.addingPrivateNote') }),
}}</SelectItem> value: 'adding_private_note'
</SelectGroup> }
</SelectContent> ]"
</Select> v-model="componentField.modelValue"
</FormControl> @update:modelValue="handleChange"
<FormMessage /> />
</FormItem> </FormControl>
</FormField> <FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="visibility"> <FormField v-slot="{ componentField }" name="visibility">
<FormItem> <FormItem>
<FormLabel>{{ t('admin.macro.visibility') }}</FormLabel> <FormLabel>{{ t('globals.terms.visibility') }}</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select visibility" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="all">{{ t('admin.macro.visibility.all') }}</SelectItem> <SelectItem value="all">{{
<SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem> t('globals.messages.all', {
<SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem> name: t('globals.terms.user', 2).toLowerCase()
</SelectGroup> })
</SelectContent> }}</SelectItem>
</Select> <SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
</FormControl> <SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
<FormMessage /> </SelectGroup>
</FormItem> </SelectContent>
</FormField> </Select>
</div> </FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id"> <FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormItem> <FormItem>
<FormLabel>{{ t('globals.terms.team') }}</FormLabel> <FormLabel>{{ t('globals.terms.team') }}</FormLabel>
<FormControl> <FormControl>
<ComboBox <SelectComboBox
v-bind="componentField" v-bind="componentField"
:items="tStore.options" :items="tStore.options"
:placeholder="t('form.field.selectTeam')" :placeholder="t('form.field.selectTeam')"
> type="team"
<template #item="{ item }"> />
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>{{ t('form.field.selectTeam') }}</span>
</div>
</template>
</ComboBox>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -123,36 +115,12 @@
<FormItem> <FormItem>
<FormLabel>{{ t('globals.terms.user') }}</FormLabel> <FormLabel>{{ t('globals.terms.user') }}</FormLabel>
<FormControl> <FormControl>
<ComboBox <SelectComboBox
v-bind="componentField" v-bind="componentField"
:items="uStore.options" :items="uStore.options"
:placeholder="t('form.field.selectUser')" :placeholder="t('form.field.selectUser')"
> type="user"
<template #item="{ item }"> />
<div class="flex items-center gap-2 ml-2">
<Avatar class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ t('form.field.selectUser') }}</span>
</div>
</template>
</ComboBox>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -169,22 +137,22 @@ import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue' import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '@/utils/strings.js'
import { createFormSchema } from './formSchema.js' import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
SelectTag
} from '@/components/ui/select' } from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.vue' import Editor from '@/features/conversation/ConversationTextEditor.vue'
@@ -221,7 +189,11 @@ const submitLabel = computed(() => {
const form = useForm({ const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)), validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: { initialValues: {
visible_when: props.initialValues.visible_when || 'replying', visible_when: props.initialValues.visible_when || [
'replying',
'starting_conversation',
'adding_private_note'
],
visibility: props.initialValues.visibility || 'all' visibility: props.initialValues.visibility || 'all'
} }
}) })

View File

@@ -15,7 +15,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'visibility', accessorKey: 'visibility',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('admin.macro.visibility')) return h('div', { class: 'text-center' }, t('globals.terms.visibility'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('visibility')) return h('div', { class: 'text-center' }, row.getValue('visibility'))

View File

@@ -19,7 +19,7 @@
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle> <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{{ $t('admin.macro.deleteConfirmation') }} {{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.macro') }) }}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@@ -13,7 +13,7 @@ export const createFormSchema = (t) => z.object({
message_content: z.string().optional(), message_content: z.string().optional(),
actions: actionSchema(t).optional().default([]), actions: actionSchema(t).optional().default([]),
visibility: z.enum(['all', 'team', 'user']), visibility: z.enum(['all', 'team', 'user']),
visible_when: z.enum(['replying', 'starting_conversation', 'adding_private_note']).optional().default('replying'), visible_when: z.array(z.enum(['replying', 'starting_conversation', 'adding_private_note'])),
team_id: z.string().nullable().optional(), team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(), user_id: z.string().nullable().optional(),
}) })

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event"> <Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col"> <DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@@ -61,7 +61,7 @@
<FormItem> <FormItem>
<FormLabel>{{ $t('form.field.lastName') }}</FormLabel> <FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="" v-bind="componentField" required /> <Input type="text" placeholder="" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -74,7 +74,7 @@
<FormItem> <FormItem>
<FormLabel>{{ $t('form.field.subject') }}</FormLabel> <FormLabel>{{ $t('form.field.subject') }}</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="" v-bind="componentField" required /> <Input type="text" placeholder="" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -117,43 +117,12 @@
}})</FormLabel }})</FormLabel
> >
<FormControl> <FormControl>
<ComboBox <SelectComboBox
v-bind="componentField" v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]" :items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
:placeholder="t('form.field.selectTeam')" :placeholder="t('form.field.selectTeam')"
> type="team"
<template #item="{ item }"> />
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
<span v-if="selected?.emoji">{{ selected?.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">
{{ selected?.label || t('form.field.selectTeam') }}
</span>
</div>
</template>
</ComboBox>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -168,51 +137,12 @@
}})</FormLabel }})</FormLabel
> >
<FormControl> <FormControl>
<ComboBox <SelectComboBox
v-bind="componentField" v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]" :items="[{ value: 'none', label: 'None' }, ...uStore.options]"
:placeholder="t('form.field.selectAgent')" :placeholder="t('form.field.selectAgent')"
> type="user"
<template #item="{ item }"> />
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '' : item.avatar_url || ''"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{
item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="selected?.value === 'none' ? '' : selected?.avatar_url || ''"
:alt="
selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)
"
/>
<AvatarFallback>
{{
selected?.value === 'none'
? 'N'
: selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{
selected?.label || t('form.field.selectAgent')
}}</span>
</div>
</template>
</ComboBox>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -293,7 +223,6 @@ import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { z } from 'zod' import { z } from 'zod'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref, watch, onUnmounted, nextTick, onMounted, computed } from 'vue' import { ref, watch, onUnmounted, nextTick, onMounted, computed } from 'vue'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue' import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
@@ -301,8 +230,6 @@ import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Users } from 'lucide-vue-next'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox' import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
@@ -318,6 +245,8 @@ import {
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useFileUpload } from '@/composables/useFileUpload' import { useFileUpload } from '@/composables/useFileUpload'
import Editor from '@/features/conversation/ConversationTextEditor.vue' import Editor from '@/features/conversation/ConversationTextEditor.vue'
import { useMacroStore } from '@/stores/macro'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import api from '@/api' import api from '@/api'
const dialogOpen = defineModel({ const dialogOpen = defineModel({
@@ -334,6 +263,7 @@ const loading = ref(false)
const searchResults = ref([]) const searchResults = ref([])
const emailQuery = ref('') const emailQuery = ref('')
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const macroStore = useMacroStore()
let timeoutId = null let timeoutId = null
const cursorPosition = ref(null) const cursorPosition = ref(null)
@@ -360,12 +290,7 @@ const isDisabled = computed(() => {
}) })
const formSchema = z.object({ const formSchema = z.object({
subject: z.string().min( subject: z.string().optional(),
3,
t('form.error.min', {
min: 3
})
),
content: z.string().min( content: z.string().min(
1, 1,
t('globals.messages.cannotBeEmpty', { t('globals.messages.cannotBeEmpty', {
@@ -379,7 +304,7 @@ const formSchema = z.object({
agent_id: z.any().optional(), agent_id: z.any().optional(),
contact_email: z.string().email(t('globals.messages.invalidEmailAddress')), contact_email: z.string().email(t('globals.messages.invalidEmailAddress')),
first_name: z.string().min(1, t('globals.messages.required')), first_name: z.string().min(1, t('globals.messages.required')),
last_name: z.string().min(1, t('globals.messages.required')) last_name: z.string().optional()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -393,6 +318,7 @@ onUnmounted(() => {
}) })
onMounted(() => { onMounted(() => {
macroStore.setCurrentView('starting_conversation')
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, { emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
command: 'apply-macro-to-new-conversation', command: 'apply-macro-to-new-conversation',
open: false open: false
@@ -450,6 +376,12 @@ const selectContact = (contact) => {
const createConversation = form.handleSubmit(async (values) => { const createConversation = form.handleSubmit(async (values) => {
loading.value = true loading.value = true
try { try {
// convert ids to numbers if they are not already
values.inbox_id = Number(values.inbox_id)
values.team_id = values.team_id ? Number(values.team_id) : null
values.agent_id = values.agent_id ? Number(values.agent_id) : null
// array of attachment ids.
values.attachments = mediaFiles.value.map((file) => file.id)
const conversation = await api.createConversation(values) const conversation = await api.createConversation(values)
const conversationUUID = conversation.data.data.uuid const conversationUUID = conversation.data.data.uuid
@@ -465,15 +397,14 @@ const createConversation = form.handleSubmit(async (values) => {
}) })
} }
} }
dialogOpen.value = false
form.resetForm()
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
} finally { } finally {
dialogOpen.value = false
emailQuery.value = ''
form.resetForm()
loading.value = false loading.value = false
} }
}) })

View File

@@ -25,7 +25,7 @@
</Tooltip> </Tooltip>
</div> </div>
<button <button
@click.stop="onRemove(action)" @click.prevent="onRemove(action)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out" class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove action" title="Remove action"
> >

View File

@@ -69,7 +69,6 @@
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen" @toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
@send="processSend" @send="processSend"
@fileUpload="handleFileUpload" @fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleFileDelete" @fileDelete="handleFileDelete"
@aiPromptSelected="handleAiPromptSelected" @aiPromptSelected="handleAiPromptSelected"
class="h-full flex-grow" class="h-full flex-grow"
@@ -162,6 +161,7 @@ const {
handleFileUpload, handleFileUpload,
handleFileDelete, handleFileDelete,
mediaFiles, mediaFiles,
clearMediaFiles,
} = useFileUpload({ } = useFileUpload({
linkedModel: 'messages' linkedModel: 'messages'
}) })
@@ -269,7 +269,7 @@ const hasTextContent = computed(() => {
* Processes the send action. * Processes the send action.
*/ */
const processSend = async () => { const processSend = async () => {
let hasAPIErrored = false let hasMessageSendingErrored = false
isEditorFullscreen.value = false isEditorFullscreen.value = false
try { try {
isSending.value = true isSending.value = true
@@ -314,14 +314,14 @@ const processSend = async () => {
} }
} }
} catch (error) { } catch (error) {
hasAPIErrored = true hasMessageSendingErrored = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
} finally { } finally {
// If API has NOT errored clear state. // If API has NOT errored clear state.
if (hasAPIErrored === false) { if (hasMessageSendingErrored === false) {
// Clear editor. // Clear editor.
clearEditorContent.value = true clearEditorContent.value = true
@@ -329,7 +329,7 @@ const processSend = async () => {
conversationStore.resetMacro('reply') conversationStore.resetMacro('reply')
// Clear media files. // Clear media files.
conversationStore.resetMediaFiles() clearMediaFiles()
// Clear any email errors. // Clear any email errors.
emailErrors.value = [] emailErrors.value = []
@@ -340,8 +340,6 @@ const processSend = async () => {
} }
isSending.value = false isSending.value = false
} }
// Update assignee last seen timestamp.
api.updateAssigneeLastSeen(conversationStore.current.uuid)
} }
/** /**

View File

@@ -20,7 +20,7 @@
class="px-3 py-1 rounded transition-colors duration-200" class="px-3 py-1 rounded transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }" :class="{ 'bg-background text-foreground': messageType === 'private_note' }"
> >
{{ $t('replyBox.privateNote') }} {{ $t('globals.terms.privateNote') }}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@@ -105,7 +105,7 @@
<MacroActionsPreview <MacroActionsPreview
v-if="conversationStore.getMacro('reply')?.actions?.length > 0" v-if="conversationStore.getMacro('reply')?.actions?.length > 0"
:actions="conversationStore.getMacro('reply').actions" :actions="conversationStore.getMacro('reply').actions"
:onRemove="(action) => conversationStore.removeMacroAction('reply', action)" :onRemove="(action) => conversationStore.removeMacroAction(action, 'reply')"
class="mt-2" class="mt-2"
/> />
@@ -123,7 +123,6 @@
class="mt-1 shrink-0" class="mt-1 shrink-0"
:isFullscreen="isFullscreen" :isFullscreen="isFullscreen"
:handleFileUpload="handleFileUpload" :handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold" :isBold="isBold"
:isItalic="isItalic" :isItalic="isItalic"
:isSending="isSending" :isSending="isSending"
@@ -138,7 +137,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next' import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from './ConversationTextEditor.vue' import Editor from './ConversationTextEditor.vue'
@@ -152,8 +151,8 @@ import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { validateEmail } from '@/utils/strings' import { validateEmail } from '@/utils/strings'
import { useMacroStore } from '@/stores/macro'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' }) const messageType = defineModel('messageType', { default: 'reply' })
const to = defineModel('to', { default: '' }) const to = defineModel('to', { default: '' })
const cc = defineModel('cc', { default: '' }) const cc = defineModel('cc', { default: '' })
@@ -166,6 +165,7 @@ const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false }) const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false }) const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 }) const cursorPosition = defineModel('cursorPosition', { default: 0 })
const macroStore = useMacroStore()
const props = defineProps({ const props = defineProps({
isFullscreen: { isFullscreen: {
@@ -196,7 +196,7 @@ const props = defineProps({
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
}, }
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -289,10 +289,6 @@ const handleFileUpload = (event) => {
emit('fileUpload', event) emit('fileUpload', event)
} }
const handleInlineImageUpload = (event) => {
emit('inlineImageUpload', event)
}
const handleOnFileDelete = (uuid) => { const handleOnFileDelete = (uuid) => {
emit('fileDelete', uuid) emit('fileDelete', uuid)
} }
@@ -306,4 +302,13 @@ const handleEmojiSelect = (emoji) => {
const handleAiPromptSelected = (key) => { const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key) emit('aiPromptSelected', key)
} }
// Watch and update macro view based on message type this filters our macros.
watch(messageType, (newType) => {
if (newType === 'reply') {
macroStore.setCurrentView('replying')
} else if (newType === 'private_note') {
macroStore.setCurrentView('adding_private_note')
}
}, { immediate: true })
</script> </script>

View File

@@ -13,13 +13,13 @@
<div class="flex justify-items-start gap-2"> <div class="flex justify-items-start gap-2">
<!-- File inputs --> <!-- File inputs -->
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" /> <input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
<input <!-- <input
type="file" type="file"
class="hidden" class="hidden"
ref="inlineImageInput" ref="inlineImageInput"
accept="image/*" accept="image/*"
@change="handleInlineImageUpload" @change="handleInlineImageUpload"
/> /> -->
<!-- Editor buttons --> <!-- Editor buttons -->
<Toggle <Toggle
class="px-2 py-2 border-0" class="px-2 py-2 border-0"
@@ -54,7 +54,7 @@ import EmojiPicker from 'vue3-emoji-picker'
import 'vue3-emoji-picker/css' import 'vue3-emoji-picker/css'
const attachmentInput = ref(null) const attachmentInput = ref(null)
const inlineImageInput = ref(null) // const inlineImageInput = ref(null)
const isEmojiPickerVisible = ref(false) const isEmojiPickerVisible = ref(false)
const emojiPickerRef = ref(null) const emojiPickerRef = ref(null)
const emit = defineEmits(['emojiSelect']) const emit = defineEmits(['emojiSelect'])

View File

@@ -29,7 +29,7 @@
<button <button
v-if="!attachment.loading" v-if="!attachment.loading"
@click.stop="onDelete(attachment.uuid)" @click.prevent="onDelete(attachment.uuid)"
class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out" class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove attachment" title="Remove attachment"
> >

View File

@@ -10,107 +10,31 @@
<!-- `Agent, team, and priority assignment --> <!-- `Agent, team, and priority assignment -->
<AccordionContent class="space-y-4 p-4"> <AccordionContent class="space-y-4 p-4">
<!-- Agent assignment --> <!-- Agent assignment -->
<ComboBox <SelectComboBox
v-model="assignedUserID" v-model="conversationStore.current.assigned_user_id"
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]" :items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
:placeholder="t('form.field.selectAgent')" :placeholder="t('form.field.selectAgent')"
@select="selectAgent" @select="selectAgent"
> type="user"
<template #item="{ item }"> />
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '' : item.avatar_url || ''"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="selected?.value === 'none' ? '' : selected?.avatar_url || ''"
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
/>
<AvatarFallback>
{{
selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || t('form.field.assignAgent') }}</span>
</div>
</template>
</ComboBox>
<!-- Team assignment --> <!-- Team assignment -->
<ComboBox <SelectComboBox
v-model="assignedTeamID" v-model="conversationStore.current.assigned_team_id"
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]" :items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
:placeholder="t('form.field.selectTeam')" :placeholder="t('form.field.selectTeam')"
@select="selectTeam" @select="selectTeam"
> type="team"
<template #item="{ item }"> />
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || t('form.field.assignTeam') }}</span>
</div>
</template>
</ComboBox>
<!-- Priority assignment --> <!-- Priority assignment -->
<ComboBox <SelectComboBox
v-model="priorityID" v-model="conversationStore.current.priority_id"
:items="priorityOptions" :items="priorityOptions"
:placeholder="t('form.field.selectPriority')" :placeholder="t('form.field.selectPriority')"
@select="selectPriority" @select="selectPriority"
> type="priority"
<template #item="{ item }"> />
<div class="flex items-center gap-3 py-2">
<div
class="w-7 h-7 flex items-center text-center justify-center bg-muted rounded-full"
>
<component :is="getPriorityIcon(item.value)" size="14" />
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<div
class="w-7 h-7 flex items-center text-center justify-center bg-muted rounded-full"
v-if="selected"
>
<component :is="getPriorityIcon(selected?.value)" size="14" />
</div>
<span class="text-sm">{{ selected?.label || t('form.field.selectPriority') }}</span>
</div>
</template>
</ComboBox>
<!-- Tags assignment --> <!-- Tags assignment -->
<SelectTag <SelectTag
@@ -169,7 +93,6 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -178,17 +101,16 @@ import {
} from '@/components/ui/accordion' } from '@/components/ui/accordion'
import ConversationInfo from './ConversationInfo.vue' import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/features/conversation/sidebar/ConversationSideBarContact.vue' import ConversationSideBarContact from '@/features/conversation/sidebar/ConversationSideBarContact.vue'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@/components/ui/select'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue' import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue'
import { useCustomAttributeStore } from '@/stores/customAttributes' import { useCustomAttributeStore } from '@/stores/customAttributes'
import PreviousConversations from '@/features/conversation/sidebar/PreviousConversations.vue' import PreviousConversations from '@/features/conversation/sidebar/PreviousConversations.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import api from '@/api' import api from '@/api'
const customAttributeStore = useCustomAttributeStore() const customAttributeStore = useCustomAttributeStore()
@@ -246,9 +168,6 @@ watch(
{ immediate: false } { immediate: false }
) )
const assignedUserID = computed(() => String(conversationStore.current?.assigned_user_id))
const assignedTeamID = computed(() => String(conversationStore.current?.assigned_team_id))
const priorityID = computed(() => String(conversationStore.current?.priority_id))
const priorityOptions = computed(() => conversationStore.priorityOptions) const priorityOptions = computed(() => conversationStore.priorityOptions)
const fetchTags = async () => { const fetchTags = async () => {
@@ -288,7 +207,6 @@ const selectAgent = (agent) => {
handleRemoveAssignee('user') handleRemoveAssignee('user')
return return
} }
if (conversationStore.current.assigned_user_id == agent.value) return
conversationStore.current.assigned_user_id = agent.value conversationStore.current.assigned_user_id = agent.value
handleAssignedUserChange(agent.value) handleAssignedUserChange(agent.value)
} }
@@ -298,31 +216,15 @@ const selectTeam = (team) => {
handleRemoveAssignee('team') handleRemoveAssignee('team')
return return
} }
if (conversationStore.current.assigned_team_id == team.value) return
conversationStore.current.assigned_team_id = team.value
handleAssignedTeamChange(team.value) handleAssignedTeamChange(team.value)
} }
const selectPriority = (priority) => { const selectPriority = (priority) => {
if (conversationStore.current.priority_id == priority.value) return
conversationStore.current.priority = priority.label conversationStore.current.priority = priority.label
conversationStore.current.priority_id = priority.value conversationStore.current.priority_id = priority.value
handlePriorityChange(priority.label) handlePriorityChange(priority.label)
} }
const getPriorityIcon = (value) => {
switch (value) {
case '1':
return SignalLow
case '2':
return SignalMedium
case '3':
return SignalHigh
default:
return CircleAlert
}
}
const updateContactCustomAttributes = async (attributes) => { const updateContactCustomAttributes = async (attributes) => {
let previousAttributes = conversationStore.current.contact.custom_attributes let previousAttributes = conversationStore.current.contact.custom_attributes
try { try {

View File

@@ -196,7 +196,7 @@ const getValidationSchema = (attribute) => {
z z
.number({ .number({
invalid_type_error: t('globals.messages.invalid', { invalid_type_error: t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase() name: t('globals.terms.value').toLowerCase()
}) })
}) })
.nullable() .nullable()
@@ -209,7 +209,7 @@ const getValidationSchema = (attribute) => {
.refine( .refine(
(val) => !isNaN(Date.parse(val)), (val) => !isNaN(Date.parse(val)),
t('globals.messages.invalid', { t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase() name: t('globals.terms.value').toLowerCase()
}) })
) )
.nullable() .nullable()
@@ -227,7 +227,7 @@ const getValidationSchema = (attribute) => {
.string() .string()
.refine((val) => attribute.values.includes(val), { .refine((val) => attribute.values.includes(val), {
message: t('globals.messages.invalid', { message: t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase() name: t('globals.terms.value').toLowerCase()
}) })
}) })
.nullable() .nullable()

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, reactive, ref, nextTick, watchEffect } from 'vue' import { computed, reactive, ref, watchEffect } from 'vue'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { computeRecipientsFromMessage } from '@/utils/email-recipients' import { computeRecipientsFromMessage } from '@/utils/email-recipients'
@@ -100,7 +100,6 @@ export const useConversationStore = defineStore('conversation', () => {
const conversation = reactive({ const conversation = reactive({
data: null, data: null,
participants: {}, participants: {},
mediaFiles: [],
loading: false, loading: false,
errorMessage: '' errorMessage: ''
}) })
@@ -118,10 +117,6 @@ export const useConversationStore = defineStore('conversation', () => {
const incrementMessageVersion = () => setTimeout(() => messages.version++, 0) const incrementMessageVersion = () => setTimeout(() => messages.version++, 0)
function resetMediaFiles () {
conversation.mediaFiles = []
}
function setListStatus (status, fetch = true) { function setListStatus (status, fetch = true) {
conversations.status = status conversations.status = status
if (fetch) { if (fetch) {
@@ -631,7 +626,6 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, { Object.assign(conversation, {
data: null, data: null,
participants: {}, participants: {},
mediaFiles: [],
macro: {}, macro: {},
loading: false, loading: false,
errorMessage: '' errorMessage: ''
@@ -645,9 +639,8 @@ export const useConversationStore = defineStore('conversation', () => {
} }
/** Macros **/ /** Macros for new conversation or open conversation **/
async function setMacro (macro, context) { async function setMacro (macro, context) {
console.debug('Setting macro for context:', context, macro)
macros.value[context] = macro macros.value[context] = macro
} }
@@ -706,7 +699,6 @@ export const useConversationStore = defineStore('conversation', () => {
getMacro, getMacro,
setMacro, setMacro,
resetMacro, resetMacro,
resetMediaFiles,
removeAssignee, removeAssignee,
getListSortField, getListSortField,
getListStatus, getListStatus,

View File

@@ -7,11 +7,11 @@ import { useUserStore } from './user'
import api from '@/api' import api from '@/api'
import { permissions as perms } from '@/constants/permissions.js' import { permissions as perms } from '@/constants/permissions.js'
export const useMacroStore = defineStore('macroStore', () => { export const useMacroStore = defineStore('macroStore', () => {
const macroList = ref([]) const macroList = ref([])
const emitter = useEmitter() const emitter = useEmitter()
const userStore = useUserStore() const userStore = useUserStore()
const currentView = ref('')
// actionPermissions is a map of action names to their corresponding permissions that a user must have to perform the action. // actionPermissions is a map of action names to their corresponding permissions that a user must have to perform the action.
const actionPermissions = { const actionPermissions = {
@@ -34,6 +34,14 @@ export const useMacroStore = defineStore('macroStore', () => {
userTeams.includes(macro.team_id) || userTeams.includes(macro.team_id) ||
String(macro.user_id) === String(userStore.userID) String(macro.user_id) === String(userStore.userID)
) )
// Filter by visible_when if currentView is set.
if (currentView.value) {
filtered = filtered.filter(macro =>
!macro.visible_when?.length || macro.visible_when.includes(currentView.value)
)
}
// Filter macros based on permissions. // Filter macros based on permissions.
filtered.forEach(macro => { filtered.forEach(macro => {
macro.actions = macro.actions.filter(action => { macro.actions = macro.actions.filter(action => {
@@ -42,14 +50,17 @@ export const useMacroStore = defineStore('macroStore', () => {
return userStore.can(permission) return userStore.can(permission)
}) })
}) })
// Skip macros that do not have any actions left AND the macro field `message_content` is empty. // Skip macros that do not have any actions left AND the macro field `message_content` is empty.
filtered = filtered.filter(macro => !(macro.actions.length === 0 && macro.message_content === "")) filtered = filtered.filter(macro => !(macro.actions.length === 0 && macro.message_content === ""))
return filtered.map(macro => ({ return filtered.map(macro => ({
...macro, ...macro,
label: macro.name, label: macro.name,
value: String(macro.id), value: String(macro.id),
})) }))
}) })
const loadMacros = async () => { const loadMacros = async () => {
if (macroList.value.length) return if (macroList.value.length) return
try { try {
@@ -62,9 +73,15 @@ export const useMacroStore = defineStore('macroStore', () => {
}) })
} }
} }
const setCurrentView = (view) => {
currentView.value = view
}
return { return {
macroList, macroList,
macroOptions, macroOptions,
loadMacros, loadMacros,
setCurrentView
} }
}) })

View File

@@ -93,9 +93,14 @@
"globals.terms.security": "Security | Security", "globals.terms.security": "Security | Security",
"globals.terms.myInbox": "My Inbox | My Inboxes", "globals.terms.myInbox": "My Inbox | My Inboxes",
"globals.terms.teamInbox": "Team Inbox | Team Inboxes", "globals.terms.teamInbox": "Team Inbox | Team Inboxes",
"globals.terms.optional": "Optional", "globals.terms.optional": "Optional | Optionals",
"globals.terms.visibility": "Visibility | Visibilities",
"globals.terms.privateNote": "Private note | Private notes",
"globals.terms.automationRule": "Automation Rule | Automation Rules",
"globals.messages.replying": "Replying",
"globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", "globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m",
"globals.messages.badRequest": "Bad request", "globals.messages.badRequest": "Bad request",
"globals.messages.visibleWhen": "Visible when",
"globals.messages.adjustFilters": "Try adjusting filters", "globals.messages.adjustFilters": "Try adjusting filters",
"globals.messages.errorUpdating": "Error updating {name}", "globals.messages.errorUpdating": "Error updating {name}",
"globals.messages.errorCreating": "Error creating {name}", "globals.messages.errorCreating": "Error creating {name}",
@@ -133,21 +138,21 @@
"globals.messages.create": "Create {name}", "globals.messages.create": "Create {name}",
"globals.messages.new": "New {name}", "globals.messages.new": "New {name}",
"globals.messages.add": "Add {name}", "globals.messages.add": "Add {name}",
"globals.messages.adding": "Adding {name}",
"globals.messages.starting": "Starting {name}",
"globals.messages.all": "All {name}", "globals.messages.all": "All {name}",
"globals.messages.denied": "{name} denied", "globals.messages.denied": "{name} denied",
"globals.messages.noResults": "No {name} found", "globals.messages.noResults": "No {name} found",
"globals.messages.enter": "Enter {name}", "globals.messages.enter": "Enter {name}",
"globals.messages.yes": "Yes", "globals.messages.yes": "Yes",
"globals.messages.no": "No", "globals.messages.no": "No {name}",
"globals.messages.type": "{name} type",
"globals.messages.typeOf": "Type of {name}", "globals.messages.typeOf": "Type of {name}",
"globals.messages.invalidEmailAddress": "Invalid email address", "globals.messages.invalidEmailAddress": "Invalid email address",
"globals.messages.pleaseSelectAtLeastOne": "Please select at least one {name}", "globals.messages.pleaseSelectAtLeastOne": "Please select at least one {name}",
"globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", "globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
"globals.messages.couldNotReload": "Could not reload {name}. Please restart the app", "globals.messages.couldNotReload": "Could not reload {name}",
"globals.messages.invalid": "Invalid {name}", "globals.messages.required": "{name} Required",
"globals.messages.disabled": "{name} is disabled",
"globals.messages.fieldRequired": "{name} required",
"globals.messages.required": "Required",
"globals.messages.invalidPortNumber": "Invalid port number", "globals.messages.invalidPortNumber": "Invalid port number",
"globals.messages.mustBeNumber": "Must be a number", "globals.messages.mustBeNumber": "Must be a number",
"globals.messages.fileTypeisNotAnImage": "File type is not an image", "globals.messages.fileTypeisNotAnImage": "File type is not an image",
@@ -171,6 +176,7 @@
"globals.messages.snooze": "Snooze", "globals.messages.snooze": "Snooze",
"globals.messages.resolve": "Resolve", "globals.messages.resolve": "Resolve",
"globals.messages.applyMacro": "Apply macro", "globals.messages.applyMacro": "Apply macro",
"globals.messages.deletionConfirmation": "This action cannot be undone. This will permanently delete this {name}.",
"globals.messages.atleastOneRecipient": "At least one recipient is required", "globals.messages.atleastOneRecipient": "At least one recipient is required",
"globals.messages.startTypingToSearch": "Start typing to search...", "globals.messages.startTypingToSearch": "Start typing to search...",
"globals.messages.goHourMinuteDuration": "Invalid duration format. Should be a number followed by h (hours), m (minutes).", "globals.messages.goHourMinuteDuration": "Invalid duration format. Should be a number followed by h (hours), m (minutes).",
@@ -318,7 +324,6 @@
"form.field.setValue": "Set value", "form.field.setValue": "Set value",
"form.field.selectEvents": "Select events", "form.field.selectEvents": "Select events",
"form.field.selectOperator": "Select operator", "form.field.selectOperator": "Select operator",
"form.field.value": "Value",
"form.error.min": "Must be at least {min} characters", "form.error.min": "Must be at least {min} characters",
"form.error.max": "Must be at most {max} characters", "form.error.max": "Must be at most {max} characters",
"form.error.minmax": "Must be between {min} and {max} characters", "form.error.minmax": "Must be between {min} and {max} characters",
@@ -386,13 +391,10 @@
"admin.conversationTags.deleteConfirmation": "This action cannot be undone. This will permanently delete this tag, and remove it from all conversations.", "admin.conversationTags.deleteConfirmation": "This action cannot be undone. This will permanently delete this tag, and remove it from all conversations.",
"admin.macro.messageContent": "Response to be sent when macro is used (optional)", "admin.macro.messageContent": "Response to be sent when macro is used (optional)",
"admin.macro.actions": "Actions (optional)", "admin.macro.actions": "Actions (optional)",
"admin.macro.visibility": "Visibility",
"admin.macro.visibility.all": "All users",
"admin.macro.messageOrActionRequired": "Either message content or actions are required", "admin.macro.messageOrActionRequired": "Either message content or actions are required",
"admin.macro.actionTypeRequired": "Action type is required", "admin.macro.actionTypeRequired": "Action type is required",
"admin.macro.actionValueRequired": "Action value is required", "admin.macro.actionValueRequired": "Action value is required",
"admin.macro.teamOrUserRequired": "team is required when visibility is `team` & a user is required when visibility is `user`", "admin.macro.teamOrUserRequired": "Team or user is required",
"admin.macro.deleteConfirmation": "This action cannot be undone. This will permanently delete this macro.",
"admin.conversationStatus.name.description": "Set status name. Click save when you're done.", "admin.conversationStatus.name.description": "Set status name. Click save when you're done.",
"admin.conversationStatus.deleteConfirmation": "This action cannot be undone. This will permanently delete this status.", "admin.conversationStatus.deleteConfirmation": "This action cannot be undone. This will permanently delete this status.",
"admin.inbox.name.description": "Name for your inbox.", "admin.inbox.name.description": "Name for your inbox.",
@@ -594,7 +596,6 @@
"ai.enterOpenAIAPIKey": "Enter OpenAI API Key", "ai.enterOpenAIAPIKey": "Enter OpenAI API Key",
"ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.", "ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.",
"replyBox.reply": "Reply", "replyBox.reply": "Reply",
"replyBox.privateNote": "Private note",
"replyBox.emailAddresess": "Email addresses separated by comma", "replyBox.emailAddresess": "Email addresses separated by comma",
"replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.", "replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.",
"replyBox.invalidEmailsIn": "Invalid email(s) in", "replyBox.invalidEmailsIn": "Invalid email(s) in",

View File

@@ -11,6 +11,7 @@ import (
"github.com/abhinavxd/libredesk/internal/macro/models" "github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/lib/pq"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -67,8 +68,8 @@ func (m *Manager) Get(id int) (models.Macro, error) {
} }
// Create adds a new macro. // Create adds a new macro.
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error { func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error {
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions) _, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions)
if err != nil { if err != nil {
m.lo.Error("error creating macro", "error", err) m.lo.Error("error creating macro", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.macro}"), nil) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.macro}"), nil)
@@ -77,8 +78,8 @@ func (m *Manager) Create(name, messageContent string, userID, teamID *int, visib
} }
// Update modifies an existing macro. // Update modifies an existing macro.
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error { func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error {
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions) result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions)
if err != nil { if err != nil {
m.lo.Error("error updating macro", "error", err) m.lo.Error("error updating macro", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.macro}"), nil) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.macro}"), nil)

View File

@@ -3,6 +3,8 @@ package models
import ( import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/lib/pq"
) )
type Macro struct { type Macro struct {
@@ -11,7 +13,8 @@ type Macro struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
MessageContent string `db:"message_content" json:"message_content"` MessageContent string `db:"message_content" json:"message_content"`
Visibility string `db:"visibility" json:"visibility"` VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"`
Visibility string `db:"visibility" json:"visibility"`
UserID *int `db:"user_id" json:"user_id,string"` UserID *int `db:"user_id" json:"user_id,string"`
TeamID *int `db:"team_id" json:"team_id,string"` TeamID *int `db:"team_id" json:"team_id,string"`
UsageCount int `db:"usage_count" json:"usage_count"` UsageCount int `db:"usage_count" json:"usage_count"`

View File

@@ -9,6 +9,7 @@ SELECT
user_id, user_id,
team_id, team_id,
actions, actions,
visible_when,
usage_count usage_count
FROM FROM
macros macros
@@ -26,6 +27,7 @@ SELECT
user_id, user_id,
team_id, team_id,
actions, actions,
visible_when,
usage_count usage_count
FROM FROM
macros macros
@@ -34,9 +36,9 @@ ORDER BY
-- name: create -- name: create
INSERT INTO INSERT INTO
macros (name, message_content, user_id, team_id, visibility, actions) macros (name, message_content, user_id, team_id, visibility, visible_when, actions)
VALUES VALUES
($1, $2, $3, $4, $5, $6); ($1, $2, $3, $4, $5, $6, $7);
-- name: update -- name: update
UPDATE UPDATE
@@ -47,7 +49,8 @@ SET
user_id = $4, user_id = $4,
team_id = $5, team_id = $5,
visibility = $6, visibility = $6,
actions = $7, visible_when = $7,
actions = $8,
updated_at = NOW() updated_at = NOW()
WHERE WHERE
id = $1; id = $1;
@@ -62,6 +65,7 @@ WHERE
UPDATE UPDATE
macros macros
SET SET
usage_count = usage_count + 1 usage_count = usage_count + 1,
updated_at = NOW()
WHERE WHERE
id = $1; id = $1;

View File

@@ -294,7 +294,7 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'macro_visible_when' SELECT 1 FROM pg_type WHERE typname = 'macro_visible_when'
) THEN ) THEN
CREATE TYPE macro_visible_when AS ENUM ('replying', 'starting_conversation', 'adding_note'); CREATE TYPE macro_visible_when AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
END IF; END IF;
END END
$$; $$;
@@ -303,14 +303,15 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err return err
} }
// Add macro_visible_when column to macros table if it doesn't exist // Add visible_when column to macros table if it doesn't exist
_, err = db.Exec(` _, err = db.Exec(`
ALTER TABLE macros ALTER TABLE macros
ADD COLUMN IF NOT EXISTS macro_visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying']; ADD COLUMN IF NOT EXISTS visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY[]::macro_visible_when[];
`) `)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }

View File

@@ -19,7 +19,7 @@ DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" A
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response'); DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response');
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach'); DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online'); DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online');
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note'); DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
-- Sequence to generate reference number for conversations. -- Sequence to generate reference number for conversations.
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100; DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
@@ -292,7 +292,7 @@ CREATE TABLE macros (
name TEXT NOT NULL, name TEXT NOT NULL,
actions JSONB DEFAULT '{}'::jsonb NOT NULL, actions JSONB DEFAULT '{}'::jsonb NOT NULL,
visibility macro_visibility NOT NULL, visibility macro_visibility NOT NULL,
visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying'], visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY[]::macro_visible_when[],
message_content TEXT NOT NULL, message_content TEXT NOT NULL,
-- Cascade deletes when user is deleted. -- Cascade deletes when user is deleted.
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,