feat: Macros, macros not completely replace canned responses as they do the same job as canned repsonse.

- tiptap editor fixes for hardlines.
- set app font to jakarta, fix shadcn components having a different font.
- UI improvements on /admin
- UI improvements on inbox tab.
This commit is contained in:
Abhinav Raut
2025-01-17 04:57:01 +05:30
parent 0a3cca75f5
commit 87c14aa15d
99 changed files with 2786 additions and 1781 deletions

View File

@@ -20,7 +20,7 @@ Self-hosted 100% open-source support desk. Single binary with minimal dependenci
| **SLA** | Configure and manage service level agreements. | | **SLA** | Configure and manage service level agreements. |
| **CSAT** | Measure customer satisfaction with post-interaction surveys. | | **CSAT** | Measure customer satisfaction with post-interaction surveys. |
| **Reports** | Gain insights and analyze support performance, with complete freedom to integrate analytics tools like Metabase for generating custom reports. | | **Reports** | Gain insights and analyze support performance, with complete freedom to integrate analytics tools like Metabase for generating custom reports. |
| **Canned Responses** | Save and reuse common replies for efficiency. | | **Macros** | Save and reuse common replies and common actions for effciency |
| **Auto Assignment** | Automatically assign tickets to agents based on defined rules. | | **Auto Assignment** | Automatically assign tickets to agents based on defined rules. |
| **Snooze Conversations** | Temporarily pause conversations and set reminders to revisit them later. | | **Snooze Conversations** | Temporarily pause conversations and set reminders to revisit them later. |
| **Automation Rules** | Define rules to automate workflows on conversation creation, updates, or hourly triggers. | | **Automation Rules** | Define rules to automate workflows on conversation creation, updates, or hourly triggers. |

View File

@@ -1,98 +0,0 @@
package main
import (
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/cannedresp/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func handleGetCannedResponses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
c []cmodels.CannedResponse
)
c, err := app.cannedResp.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}
func handleCreateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
err := app.cannedResp.Create(cannedResponse.Title, cannedResponse.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}
func handleDeleteCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := app.cannedResp.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleUpdateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
if err = app.cannedResp.Update(id, cannedResponse.Title, cannedResponse.Content); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}

View File

@@ -481,23 +481,20 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
return r.SendEnvelope("Status updated successfully") return r.SendEnvelope("Status updated successfully")
} }
// handleAddConversationTags adds tags to a conversation. // handleUpdateConversationtags updates conversation tags.
func handleAddConversationTags(r *fastglue.Request) error { func handleUpdateConversationtags(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
tagIDs = []int{} tagNames = []string{}
tagJSON = r.RequestCtx.PostArgs().Peek("tag_ids") tagJSON = r.RequestCtx.PostArgs().Peek("tags")
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
) )
// Parse tag IDs from JSON if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
err := json.Unmarshal(tagJSON, &tagIDs) app.lo.Error("error unmarshalling tags JSON", "error", err)
if err != nil { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
app.lo.Error("unmarshalling tag ids", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
} }
conversation, err := app.conversation.GetConversation(0, uuid) conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -516,7 +513,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
} }
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil { if err := app.conversation.UpsertConversationTags(uuid, tagNames); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope("Tags added successfully") return r.SendEnvelope("Tags added successfully")

View File

@@ -58,7 +58,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority")) g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority"))
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status")) g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status"))
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read")) g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read"))
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations:update_tags")) g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read")) g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read")) g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write")) g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
@@ -86,11 +86,13 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Media. // Media.
g.POST("/api/v1/media", auth(handleMediaUpload)) g.POST("/api/v1/media", auth(handleMediaUpload))
// Canned response. // Macros.
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses)) g.GET("/api/v1/macros", auth(handleGetMacros))
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses:manage")) g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses:manage")) g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage"))
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses:manage")) g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage"))
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
// User. // User.
g.GET("/api/v1/users/me", auth(handleGetCurrentUser)) g.GET("/api/v1/users/me", auth(handleGetCurrentUser))

View File

@@ -18,7 +18,6 @@ import (
"github.com/abhinavxd/libredesk/internal/autoassigner" "github.com/abhinavxd/libredesk/internal/autoassigner"
"github.com/abhinavxd/libredesk/internal/automation" "github.com/abhinavxd/libredesk/internal/automation"
businesshours "github.com/abhinavxd/libredesk/internal/business_hours" businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/cannedresp"
"github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/priority" "github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/conversation/status"
@@ -26,6 +25,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/email"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models" imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/media"
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs" fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3" "github.com/abhinavxd/libredesk/internal/media/stores/s3"
@@ -196,6 +196,7 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
// initConversations inits conversation manager. // initConversations inits conversation manager.
func initConversations( func initConversations(
i18n *i18n.I18n, i18n *i18n.I18n,
sla *sla.Manager,
status *status.Manager, status *status.Manager,
priority *priority.Manager, priority *priority.Manager,
hub *ws.Hub, hub *ws.Hub,
@@ -208,7 +209,7 @@ func initConversations(
automationEngine *automation.Engine, automationEngine *automation.Engine,
template *tmpl.Manager, template *tmpl.Manager,
) *conversation.Manager { ) *conversation.Manager {
c, err := conversation.New(hub, i18n, notif, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{ c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
DB: db, DB: db,
Lo: initLogger("conversation_manager"), Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -246,17 +247,17 @@ func initView(db *sqlx.DB) *view.Manager {
return m return m
} }
// initCannedResponse inits canned response manager. // initMacro inits macro manager.
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager { func initMacro(db *sqlx.DB) *macro.Manager {
var lo = initLogger("canned-response") var lo = initLogger("macro")
c, err := cannedresp.New(cannedresp.Opts{ m, err := macro.New(macro.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing canned responses manager: %v", err) log.Fatalf("error initializing macro manager: %v", err)
} }
return c return m
} }
// initBusinessHours inits business hours manager. // initBusinessHours inits business hours manager.
@@ -414,15 +415,9 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
} }
// initAutomationEngine initializes the automation engine. // initAutomationEngine initializes the automation engine.
func initAutomationEngine(db *sqlx.DB, userManager *user.Manager) *automation.Engine { func initAutomationEngine(db *sqlx.DB) *automation.Engine {
var lo = initLogger("automation_engine") var lo = initLogger("automation_engine")
engine, err := automation.New(automation.Opts{
systemUser, err := userManager.GetSystemUser()
if err != nil {
log.Fatalf("error fetching system user: %v", err)
}
engine, err := automation.New(systemUser, automation.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
}) })

306
cmd/macro.go Normal file
View File

@@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
autoModels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetMacros returns all macros.
func handleGetMacros(r *fastglue.Request) error {
var app = r.Context.(*App)
macros, err := app.macro.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
for i, m := range macros {
var actions []autoModels.RuleAction
if err := json.Unmarshal(m.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macros[i].Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
}
}
return r.SendEnvelope(macros)
}
// handleGetMacro returns a macro.
func handleGetMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
macro, err := app.macro.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
var actions []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macro.Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
}
return r.SendEnvelope(macro)
}
// handleCreateMacro creates new macro.
func handleCreateMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
macro = models.Macro{}
)
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
return sendErrorEnvelope(r, err)
}
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
}
// handleUpdateMacro updates a macro.
func handleUpdateMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
macro = models.Macro{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
}
// handleDeleteMacro deletes macro.
func handleDeleteMacro(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
if err := app.macro.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Macro deleted successfully")
}
// handleApplyMacro applies macro actions to a conversation.
func handleApplyMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Enforce conversation access.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
macro, err := app.macro.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Decode incoming actions.
if err := r.Decode(&incomingActions, "json"); err != nil {
app.lo.Error("error unmashalling incoming actions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
}
// Make sure no duplicate action types are present.
actionTypes := make(map[string]bool, len(incomingActions))
for _, act := range incomingActions {
if actionTypes[act.Type] {
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate action types not allowed", nil, envelope.InputError)
}
actionTypes[act.Type] = true
}
// Validate action permissions.
for _, act := range incomingActions {
if !isMacroActionAllowed(act.Type) {
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
}
if !hasActionPermission(act.Type, user.Permissions) {
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
}
}
// Apply actions.
successCount := 0
for _, act := range incomingActions {
if err := app.conversation.ApplyAction(act, conversation, user); err == nil {
successCount++
}
}
if successCount == 0 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
}
// Increment usage count.
app.macro.IncrementUsageCount(macro.ID)
if successCount < len(incomingActions) {
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
})
}
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
"message": "Macro applied successfully",
})
}
// hasActionPermission checks user permission for given action
func hasActionPermission(action string, userPerms []string) bool {
requiredPerm, exists := autoModels.ActionPermissions[action]
if !exists {
return false
}
return slices.Contains(userPerms, requiredPerm)
}
// setDisplayValues sets display values for actions.
func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
getters := map[string]func(int) (string, error){
autoModels.ActionAssignTeam: func(id int) (string, error) {
t, err := app.team.Get(id)
if err != nil {
app.lo.Warn("team not found for macro action", "team_id", id)
return "", err
}
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err
}
return u.FullName(), nil
},
autoModels.ActionSetPriority: func(id int) (string, error) {
p, err := app.priority.Get(id)
if err != nil {
app.lo.Warn("priority not found for macro action", "priority_id", id)
return "", err
}
return p.Name, nil
},
autoModels.ActionSetStatus: func(id int) (string, error) {
s, err := app.status.Get(id)
if err != nil {
app.lo.Warn("status not found for macro action", "status_id", id)
return "", err
}
return s.Name, nil
},
}
for i := range actions {
actions[i].DisplayValue = []string{}
if getter, ok := getters[actions[i].Type]; ok {
id, _ := strconv.Atoi(actions[i].Value[0])
if name, err := getter(id); err == nil {
actions[i].DisplayValue = append(actions[i].DisplayValue, name)
}
}
}
return nil
}
// validateMacro validates an incoming macro.
func validateMacro(macro models.Macro) error {
if macro.Name == "" {
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
}
var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
}
for _, a := range act {
if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
}
}
return nil
}
// isMacroActionAllowed returns true if the action is allowed in a macro.
func isMacroActionAllowed(action string) bool {
switch action {
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
return false
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
return true
default:
return false
}
}

View File

@@ -14,12 +14,12 @@ import (
businesshours "github.com/abhinavxd/libredesk/internal/business_hours" businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/colorlog" "github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat" "github.com/abhinavxd/libredesk/internal/csat"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/view"
"github.com/abhinavxd/libredesk/internal/automation" "github.com/abhinavxd/libredesk/internal/automation"
"github.com/abhinavxd/libredesk/internal/cannedresp"
"github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/priority" "github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/conversation/status"
@@ -67,7 +67,7 @@ type App struct {
tag *tag.Manager tag *tag.Manager
inbox *inbox.Manager inbox *inbox.Manager
tmpl *template.Manager tmpl *template.Manager
cannedResp *cannedresp.Manager macro *macro.Manager
conversation *conversation.Manager conversation *conversation.Manager
automation *automation.Engine automation *automation.Engine
businessHours *businesshours.Manager businessHours *businesshours.Manager
@@ -149,15 +149,14 @@ func main() {
businessHours = initBusinessHours(db) businessHours = initBusinessHours(db)
user = initUser(i18n, db) user = initUser(i18n, db)
notifier = initNotifier(user) notifier = initNotifier(user)
automation = initAutomationEngine(db, user) automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours) sla = initSLA(db, team, settings, businessHours)
conversation = initConversations(i18n, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template) conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
autoassigner = initAutoAssigner(team, user, conversation) autoassigner = initAutoAssigner(team, user, conversation)
) )
// Set stores. // Set stores.
wsHub.SetConversationStore(conversation) wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation, sla) automation.SetConversationStore(conversation)
// Start inbox receivers. // Start inbox receivers.
startInboxes(ctx, inbox, conversation) startInboxes(ctx, inbox, conversation)
@@ -209,8 +208,8 @@ func main() {
authz: initAuthz(), authz: initAuthz(),
role: initRole(db), role: initRole(db),
tag: initTag(db), tag: initTag(db),
macro: initMacro(db),
ai: initAI(db), ai: initAI(db),
cannedResp: initCannedResponse(db),
} }
// Init fastglue and set app in ctx. // Init fastglue and set app in ctx.
@@ -223,7 +222,7 @@ func main() {
initHandlers(g, wsHub) initHandlers(g, wsHub)
s := &fasthttp.Server{ s := &fasthttp.Server{
Name: "server", Name: "libredesk",
ReadTimeout: ko.MustDuration("app.server.read_timeout"), ReadTimeout: ko.MustDuration("app.server.read_timeout"),
WriteTimeout: ko.MustDuration("app.server.write_timeout"), WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"), MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),

View File

@@ -1,16 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"> rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -20,10 +20,8 @@
"@radix-icons/vue": "^1.0.0", "@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@tanstack/vue-table": "^8.19.2", "@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-hard-break": "^2.11.0",
"@tiptap/extension-image": "^2.5.9", "@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1", "@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-list-item": "^2.4.0",
"@tiptap/extension-ordered-list": "^2.4.0", "@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/pm": "^2.4.0", "@tiptap/pm": "^2.4.0",

View File

@@ -23,18 +23,12 @@ importers:
'@tanstack/vue-table': '@tanstack/vue-table':
specifier: ^8.19.2 specifier: ^8.19.2
version: 8.20.5(vue@3.5.13(typescript@5.7.3)) version: 8.20.5(vue@3.5.13(typescript@5.7.3))
'@tiptap/extension-hard-break':
specifier: ^2.11.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-image': '@tiptap/extension-image':
specifier: ^2.5.9 specifier: ^2.5.9
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2)) version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-link': '@tiptap/extension-link':
specifier: ^2.9.1 specifier: ^2.9.1
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2) version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-list-item':
specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-ordered-list': '@tiptap/extension-ordered-list':
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2)) version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))

View File

@@ -1,5 +1,4 @@
<template> <template>
<Toaster />
<Sidebar <Sidebar
:isLoading="false" :isLoading="false"
:open="sidebarOpen" :open="sidebarOpen"
@@ -10,30 +9,21 @@
@edit-view="editView" @edit-view="editView"
@delete-view="deleteView" @delete-view="deleteView"
> >
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel"> <div class="w-full h-screen border-l">
<ResizableHandle id="resize-handle-1" /> <PageHeader />
<ResizablePanel id="resize-panel-2"> <RouterView />
<div class="w-full h-screen"> </div>
<PageHeader /> <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
<RouterView />
</div>
</ResizablePanel>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</ResizablePanelGroup>
</Sidebar> </Sidebar>
<div class="font-jakarta"> <Command />
<Command />
</div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRouter } from 'vue-router' import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js' import { initWS } from '@/websocket.js'
import { Toaster } from '@/components/ui/sonner'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
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 { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
@@ -42,11 +32,13 @@ import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import PageHeader from './components/common/PageHeader.vue' import PageHeader from './components/common/PageHeader.vue'
import ViewForm from '@/components/ViewForm.vue' import ViewForm from '@/components/ViewForm.vue'
import api from '@/api' import api from '@/api'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/components/command/command.vue' import Command from '@/components/command/CommandBox.vue'
const { toast } = useToast() const { toast } = useToast()
const emitter = useEmitter() const emitter = useEmitter()
@@ -57,7 +49,8 @@ const usersStore = useUsersStore()
const teamStore = useTeamStore() const teamStore = useTeamStore()
const inboxStore = useInboxStore() const inboxStore = useInboxStore()
const slaStore = useSlaStore() const slaStore = useSlaStore()
const router = useRouter() const macroStore = useMacroStore()
const tagStore = useTagStore()
const userViews = ref([]) const userViews = ref([])
const view = ref({}) const view = ref({})
const openCreateViewForm = ref(false) const openCreateViewForm = ref(false)
@@ -66,8 +59,6 @@ initWS()
onMounted(() => { onMounted(() => {
initToaster() initToaster()
listenViewRefresh() listenViewRefresh()
getCurrentUser()
getUserViews()
initStores() initStores()
}) })
@@ -76,14 +67,19 @@ onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews) emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
}) })
// initialize data stores
const initStores = async () => { const initStores = async () => {
await Promise.all([ await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(), conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(), conversationStore.fetchPriorities(),
usersStore.fetchUsers(), usersStore.fetchUsers(),
teamStore.fetchTeams(), teamStore.fetchTeams(),
inboxStore.fetchInboxes(), inboxStore.fetchInboxes(),
slaStore.fetchSlas() slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
]) ])
} }
@@ -98,7 +94,6 @@ const deleteView = async (view) => {
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' }) emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success', title: 'Success',
variant: 'success',
description: 'View deleted successfully' description: 'View deleted successfully'
}) })
} catch (err) { } catch (err) {
@@ -123,14 +118,6 @@ const getUserViews = async () => {
} }
} }
const getCurrentUser = () => {
userStore.getCurrentUser().catch((err) => {
if (err.response && err.response.status === 401) {
router.push('/')
}
})
}
const initToaster = () => { const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast) emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<Toaster /> <Toaster />
<TooltipProvider :delay-duration="200"> <TooltipProvider :delay-duration="200">
<div class="font-inter"> <div class="!font-jakarta">
<RouterView /> <RouterView />
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

@@ -182,10 +182,24 @@ const sendMessage = (uuid, data) =>
}) })
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`) const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`) const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/v1/canned-responses') const getAllMacros = () => http.get('/api/v1/macros')
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data) const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data) const createMacro = (data) => http.post('/api/v1/macros', data, {
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`) headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamUnassignedConversations = (teamID, params) => const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params }) http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params }) const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
@@ -290,10 +304,12 @@ export default {
getConversationMessages, getConversationMessages,
getCurrentUser, getCurrentUser,
getCurrentUserTeams, getCurrentUserTeams,
getCannedResponses, getAllMacros,
createCannedResponse, getMacro,
updateCannedResponse, createMacro,
deleteCannedResponse, updateMacro,
deleteMacro,
applyMacro,
updateCurrentUser, updateCurrentUser,
updateAssignee, updateAssignee,
updateConversationStatus, updateConversationStatus,

View File

@@ -9,10 +9,16 @@
} }
.page-content { .page-content {
padding: 1rem 1rem;
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
padding-bottom: 100px; padding-bottom: 100px;
@apply bg-slate-50;
}
@layer base {
html {
font-family: 'Plus Jakarta Sans', sans-serif;
}
} }
body { body {
@@ -20,7 +26,6 @@ body {
} }
// Theme. // Theme.
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;

View File

@@ -1,11 +1,11 @@
<template> <template>
<Dialog :open="openDialog" @update:open="openDialog = false"> <Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle> <DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogDescription <DialogDescription>
>Views let you create custom filters and save them for reuse.</DialogDescription Views let you create custom filters and save them.
> </DialogDescription>
</DialogHeader> </DialogHeader>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4"> <div class="grid gap-4 py-4">
@@ -50,7 +50,7 @@
<FormControl> <FormControl>
<Filter :fields="filterFields" :showButtons="false" v-bind="componentField" /> <Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
</FormControl> </FormControl>
<FormDescription>Add filters to customize view.</FormDescription> <FormDescription>Add multiple filters to customize view.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@@ -95,7 +95,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation' import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import Filter from '@/components/common/Filter.vue' import Filter from '@/components/common/FilterBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'

View File

@@ -10,6 +10,8 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex gap-5"> <div class="flex gap-5">
<div class="w-48"> <div class="w-48">
<!-- Type -->
<Select <Select
v-model="action.type" v-model="action.type"
@update:modelValue="(value) => handleFieldChange(value, index)" @update:modelValue="(value) => handleFieldChange(value, index)"
@@ -31,12 +33,24 @@
</Select> </Select>
</div> </div>
<!-- Value -->
<div
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
class="w-full"
>
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames"
placeholder="Select tag"
/>
</div>
<div <div
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 <ComboBox
v-model="action.value" v-model="action.value[0]"
:items="conversationActions[action.type]?.options" :items="conversationActions[action.type]?.options"
placeholder="Select" placeholder="Select"
@select="handleValueChange($event, index)" @select="handleValueChange($event, index)"
@@ -100,7 +114,7 @@
> >
<QuillEditor <QuillEditor
theme="snow" theme="snow"
v-model:content="action.value" v-model:content="action.value[0]"
contentType="html" contentType="html"
@update:content="(value) => handleValueChange(value, index)" @update:content="(value) => handleValueChange(value, index)"
class="h-32 mb-12" class="h-32 mb-12"
@@ -119,6 +133,7 @@
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 { X } from 'lucide-vue-next'
import { useTagStore } from '@/stores/tag'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -131,6 +146,7 @@ import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css' import '@vueup/vue-quill/dist/vue-quill.snow.css'
import ComboBox from '@/components/ui/combobox/ComboBox.vue' import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
const props = defineProps({ const props = defineProps({
@@ -142,10 +158,11 @@ const props = defineProps({
const { actions } = toRefs(props) const { actions } = toRefs(props)
const emit = defineEmits(['update-actions', 'add-action', 'remove-action']) const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
const tagsStore = useTagStore()
const { conversationActions } = useConversationFilters() const { conversationActions } = useConversationFilters()
const handleFieldChange = (value, index) => { const handleFieldChange = (value, index) => {
actions.value[index].value = '' actions.value[index].value = []
actions.value[index].type = value actions.value[index].type = value
emitUpdate(index) emitUpdate(index)
} }
@@ -154,7 +171,7 @@ const handleValueChange = (value, index) => {
if (typeof value === 'object') { if (typeof value === 'object') {
value = value.value value = value.value
} }
actions.value[index].value = value actions.value[index].value = [value]
emitUpdate(index) emitUpdate(index)
} }

View File

@@ -1,5 +1,4 @@
<template> <template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/automations'"> <div v-if="router.currentRoute.value.path === '/admin/automations'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="ml-auto"> <div class="ml-auto">
@@ -11,7 +10,6 @@
</div> </div>
</div> </div>
<router-view /> <router-view />
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/business-hours'"> <template v-if="router.currentRoute.value.path === '/admin/business-hours'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div></div> <div></div>
@@ -16,7 +14,6 @@
<template v-else> <template v-else>
<router-view/> <router-view/>
</template> </template>
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -1,105 +0,0 @@
<template>
<div class="w-8/12">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button class="ml-auto">New canned response</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>New canned response</DialogTitle>
<DialogDescription>Set title and content, click save when you're done. </DialogDescription>
</DialogHeader>
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>
</DialogContent>
</Dialog>
</div>
</div>
<Spinner v-if="formLoading"></Spinner>
<div v-else>
<DataTable :columns="columns" :data="cannedResponses" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import CannedResponsesForm from './CannedResponsesForm.vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const formLoading = ref(false)
const cannedResponses = ref([])
const emit = useEmitter()
const dialogOpen = ref(false)
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
onMounted(() => {
getCannedResponses()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
form.setValues({
title: "",
content: "",
})
})
onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
const refreshList = (data) => {
if (data?.model === 'canned_responses') getCannedResponses()
}
const getCannedResponses = async () => {
try {
formLoading.value = true
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
} finally {
formLoading.value = false
}
}
const onSubmit = form.handleSubmit(async (values) => {
try {
formLoading.value = true
await api.createCannedResponse(values)
dialogOpen.value = false
getCannedResponses()
} catch (error) {
console.error('Failed to create canned response:', error)
} finally {
formLoading.value = false
}
})
</script>

View File

@@ -1,43 +0,0 @@
<template>
<form class="space-y-6">
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleInput }" name="content">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<QuillEditor theme="snow" v-model:content="componentField.modelValue" contentType="html"
@update:content="handleInput"></QuillEditor>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Form submit button slot -->
<slot name="footer"></slot>
</form>
</template>
<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>

View File

@@ -1,101 +0,0 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem> Edit </DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteCannedResponse"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>Edit canned response</DialogTitle>
<DialogDescription>Edit title and content, click save when you're done. </DialogDescription>
</DialogHeader>
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import CannedResponsesForm from './CannedResponsesForm.vue'
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { formSchema } from './formSchema.js'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api/index.js'
const dialogOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
cannedResponse: {
type: Object,
required: true,
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit(async (values) => {
await api.updateCannedResponse(props.cannedResponse.id, values)
dialogOpen.value = false
emitRefreshCannedResponseList()
})
const deleteCannedResponse = async () => {
await api.deleteCannedResponse(props.cannedResponse.id)
dialogOpen.value = false
emitRefreshCannedResponseList()
}
const emitRefreshCannedResponseList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'canned_responses'
})
}
// Watch for changes in initialValues and update the form.
watch(
() => props.cannedResponse,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
)
</script>

View File

@@ -1,18 +0,0 @@
import * as z from 'zod'
export const formSchema = z.object({
title: z
.string({
required_error: 'Title is required.'
})
.min(1, {
message: 'Title must be at least 1 character.'
}),
content: z
.string({
required_error: 'Content is required.'
})
.min(1, {
message: 'Content must be atleast 3 characters.'
})
})

View File

@@ -0,0 +1,53 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<MacroForm
:submitForm="onSubmit"
:isLoading="formLoading"
/>
</template>
<script setup>
import { ref } from 'vue'
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const router = useRouter()
const emit = useEmitter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'New macro' }
]
const onSubmit = (values) => {
createMacro(values)
}
const createMacro = async (values) => {
try {
formLoading.value = true
await api.createMacro(values)
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
variant: 'success',
description: 'Macro created successfully'
})
router.push('/admin/conversations/macros')
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"></Spinner>
<MacroForm :initialValues="macro" :submitForm="submitForm" :isLoading="formLoading" v-else />
</template>
<script setup>
import { onMounted, ref } from 'vue'
import api from '@/api'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner'
const macro = ref({})
const isLoading = ref(false)
const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'Edit macro' }
]
const submitForm = (values) => {
updateMacro(values)
}
const updateMacro = async (payload) => {
try {
formLoading.value = true
await api.updateMacro(macro.value.id, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Saved',
description: 'Macro updated successfully'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update macro',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
onMounted(async () => {
try {
isLoading.value = true
const resp = await api.getMacro(props.id)
macro.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
})
const props = defineProps({
id: {
type: String,
required: true
}
})
</script>

View File

@@ -0,0 +1,195 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Macro name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message_content">
<FormItem>
<FormLabel>Response to be sent when macro is used</FormLabel>
<FormControl>
<QuillEditor
v-model:content="componentField.modelValue"
placeholder="Add a response (optional)"
theme="snow"
contentType="html"
class="h-32 mb-12"
@update:content="(value) => componentField.onChange(value)"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="visibility">
<FormItem>
<FormLabel>Visibility</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Team</FormLabel>
<FormControl>
<ComboBox v-bind="componentField" :items="tStore.options" placeholder="Select 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>Select team</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<ComboBox v-bind="componentField" :items="uStore.options" placeholder="Select 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>Select user</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions </FormLabel>
<FormControl>
<ActionBuilder v-bind="componentField" :config="actionConfig" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ActionBuilder from '@/components/common/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { formSchema } from './formSchema.js'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const { conversationActions } = useConversationFilters()
const formLoading = ref(false)
const uStore = useUsersStore()
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: 'Submit'
},
isLoading: {
type: Boolean,
default: false
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const actionConfig = ref({
actions: conversationActions,
typePlaceholder: 'Select action type',
valuePlaceholder: 'Select value',
addButtonText: 'Add new action'
})
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) return
form.setValues(newValues)
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div v-if="router.currentRoute.value.path === '/admin/conversations/macros'">
<div class="flex justify-end mb-5">
<Button @click="toggleForm"> New macro </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable v-else :columns="columns" :data="macros" />
</div>
</div>
<template v-else>
<router-view></router-view>
</template>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import api from '@/api'
const router = useRouter()
const formLoading = ref(false)
const macros = ref([])
const emit = useEmitter()
onMounted(() => {
getMacros()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
const toggleForm = () => {
router.push('/admin/conversations/macros/new')
}
const refreshList = (data) => {
if (data?.model === 'macros') getMacros()
}
const getMacros = async () => {
try {
formLoading.value = true
const resp = await api.getAllMacros()
macros.value = resp.data.data
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
</script>

View File

@@ -4,12 +4,30 @@ import { format } from 'date-fns'
export const columns = [ export const columns = [
{ {
accessorKey: 'title', accessorKey: 'name',
header: function () { header: function () {
return h('div', { class: 'text-center' }, 'Title') return h('div', { class: 'text-center' }, 'Name')
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('title')) return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'visibility',
header: function () {
return h('div', { class: 'text-center' }, 'Visibility')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('visibility'))
}
},
{
accessorKey: 'usage_count',
header: function () {
return h('div', { class: 'text-center' }, 'Usage')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('usage_count'))
} }
}, },
{ {
@@ -34,12 +52,12 @@ export const columns = [
id: 'actions', id: 'actions',
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {
const cannedResponse = row.original const macro = row.original
return h( return h(
'div', 'div',
{ class: 'relative' }, { class: 'relative' },
h(dropdown, { h(dropdown, {
cannedResponse macro
}) })
) )
} }

View File

@@ -0,0 +1,50 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editMacro">Edit</DropdownMenuItem>
<DropdownMenuItem @click="deleteMacro">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useRouter } from 'vue-router'
import api from '@/api/index.js'
const router = useRouter()
const emit = useEmitter()
const props = defineProps({
macro: {
type: Object,
required: true
}
})
const deleteMacro = async () => {
await api.deleteMacro(props.macro.id)
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'macros'
})
}
const editMacro = () => {
router.push({ path: `/admin/conversations/macros/${props.macro.id}/edit` })
}
</script>

View File

@@ -0,0 +1,17 @@
import * as z from 'zod'
const actionSchema = z.array(
z.object({
type: z.string().min(1, 'Action type required'),
value: z.array(z.string().min(1, 'Action value required')).min(1, 'Action value required'),
})
)
export const formSchema = z.object({
name: z.string().min(1, 'Macro name is required'),
message_content: z.string().optional(),
actions: actionSchema,
visibility: z.enum(['all', 'team', 'user']),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})

View File

@@ -1,33 +1,30 @@
<template> <template>
<div class="flex justify-between mb-5">
<div class="w-8/12"> <div class="flex justify-end mb-4 w-full">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DialogTrigger as-child> <DialogTrigger as-child>
<Button class="ml-auto">New Status</Button> <Button class="ml-auto">New Status</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent class="sm:max-w-[425px]"> <DialogContent class="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New status</DialogTitle> <DialogTitle>New status</DialogTitle>
<DialogDescription> Set status name. Click save when you're done. </DialogDescription> <DialogDescription> Set status name. Click save when you're done. </DialogDescription>
</DialogHeader> </DialogHeader>
<StatusForm @submit.prevent="onSubmit"> <StatusForm @submit.prevent="onSubmit">
<template #footer> <template #footer>
<DialogFooter class="mt-10"> <DialogFooter class="mt-10">
<Button type="submit"> Save changes </Button> <Button type="submit"> Save changes </Button>
</DialogFooter> </DialogFooter>
</template> </template>
</StatusForm> </StatusForm>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</div>
<Spinner v-if="isLoading"></Spinner>
<div>
<DataTable :columns="columns" :data="statuses" />
</div> </div>
</div> </div>
<Spinner v-if="isLoading"></Spinner>
<div>
<DataTable :columns="columns" :data="statuses" />
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full"> <div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
@@ -27,7 +25,6 @@
<div v-else> <div v-else>
<DataTable :columns="columns" :data="tags" /> <DataTable :columns="columns" :data="tags" />
</div> </div>
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -1,8 +1,5 @@
<template> <template>
<div> <div class="flex justify-center items-center flex-col">
</div>
<div class="flex justify-center items-center flex-col w-8/12">
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" /> <GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
</div> </div>
</template> </template>

View File

@@ -242,7 +242,7 @@ const onSubmit = form.handleSubmit(async (values) => {
}) })
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update settings', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/inboxes'"> <template v-if="router.currentRoute.value.path === '/admin/inboxes'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div class="flex justify-end w-full mb-4"> <div class="flex justify-end w-full mb-4">
@@ -15,7 +13,6 @@
<template v-else> <template v-else>
<router-view/> <router-view/>
</template> </template>
</div>
</template> </template>
<script setup> <script setup>
@@ -50,7 +47,7 @@ const getInboxes = async () => {
data.value = response.data.data data.value = response.data.data
} catch (error) { } catch (error) {
toast({ toast({
title: 'Could not fetch inboxes', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })

View File

@@ -4,7 +4,7 @@ import { isGoDuration } from '@/utils/strings'
export const formSchema = z.object({ export const formSchema = z.object({
name: z.string().describe('Name').default(''), name: z.string().describe('Name').default(''),
from: z.string().describe('From address').default(''), from: z.string().describe('From address').default(''),
csat_enabled: z.boolean().describe('Enable CSAT'), csat_enabled: z.boolean().describe('Enable CSAT').optional(),
imap: z imap: z
.object({ .object({
host: z.string().describe('Host').default('imap.gmail.com'), host: z.string().describe('Host').default('imap.gmail.com'),

View File

@@ -1,11 +1,11 @@
<template> <template>
<Spinner v-if="formLoading" class="mx-auto" />
<div class="w-8/12"> <NotificationsForm
<div> v-else
<Spinner v-if="formLoading"></Spinner> :initial-values="initialValues"
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" /> :submit-form="submitForm"
</div> :isLoading="formLoading"
</div> />
</template> </template>
<script setup> <script setup>
@@ -23,51 +23,53 @@ const formLoading = ref(false)
const emitter = useEmitter() const emitter = useEmitter()
onMounted(() => { onMounted(() => {
getNotificationSettings() getNotificationSettings()
}) })
const getNotificationSettings = async () => { const getNotificationSettings = async () => {
try { try {
formLoading.value = true formLoading.value = true
const resp = await api.getEmailNotificationSettings() const resp = await api.getEmailNotificationSettings()
initialValues.value = Object.fromEntries( initialValues.value = Object.fromEntries(
Object.entries(resp.data.data).map(([key, value]) => [key.replace('notification.email.', ''), value]) Object.entries(resp.data.data).map(([key, value]) => [
) key.replace('notification.email.', ''),
} catch (error) { value
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { ])
title: 'Could not fetch', )
variant: 'destructive', } catch (error) {
description: handleHTTPError(error).message emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
}) title: 'Could not fetch',
} finally { variant: 'destructive',
formLoading.value = false description: handleHTTPError(error).message
} })
} finally {
formLoading.value = false
}
} }
const submitForm = async (values) => { const submitForm = async (values) => {
try { try {
formLoading.value = true formLoading.value = true
const updatedValues = Object.fromEntries( const updatedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => { Object.entries(values).map(([key, value]) => {
if (key === 'password' && value.includes('•')) { if (key === 'password' && value.includes('•')) {
return [`notification.email.${key}`, ''] return [`notification.email.${key}`, '']
} }
return [`notification.email.${key}`, value] return [`notification.email.${key}`, value]
}) })
); )
await api.updateEmailNotificationSettings(updatedValues) await api.updateEmailNotificationSettings(updatedValues)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: "Saved successfully" description: 'Saved successfully'
}) })
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not save', title: 'Could not save',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
} }
</script>
</script>

View File

@@ -1,22 +1,19 @@
<template> <template>
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
<div class="w-8/12"> <div class="flex justify-between mb-5">
<template v-if="router.currentRoute.value.path === '/admin/oidc'"> <div></div>
<div class="flex justify-between mb-5">
<div></div>
<div>
<Button @click="navigateToAddOIDC">New OIDC</Button>
</div>
</div>
<div> <div>
<Spinner v-if="isLoading"></Spinner> <Button @click="navigateToAddOIDC">New OIDC</Button>
<DataTable :columns="columns" :data="oidc" v-else />
</div> </div>
</template> </div>
<template v-else> <div>
<router-view/> <Spinner v-if="isLoading"></Spinner>
</template> <DataTable :columns="columns" :data="oidc" v-else />
</div> </div>
</template>
<template v-else>
<router-view />
</template>
</template> </template>
<script setup> <script setup>

View File

@@ -1,22 +1,19 @@
<template> <template>
<template v-if="router.currentRoute.value.path === '/admin/sla'">
<div class="w-8/12"> <div class="flex justify-between mb-5">
<template v-if="router.currentRoute.value.path === '/admin/sla'"> <div></div>
<div class="flex justify-between mb-5"> <div>
<div></div> <Button @click="navigateToAddSLA">New SLA</Button>
<div> </div>
<Button @click="navigateToAddSLA">New SLA</Button>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="slas" v-else />
</div>
</template>
<template v-else>
<router-view/>
</template>
</div> </div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="slas" v-else />
</div>
</template>
<template v-else>
<router-view />
</template>
</template> </template>
<script setup> <script setup>
@@ -37,29 +34,29 @@ const router = useRouter()
const emit = useEmitter() const emit = useEmitter()
onMounted(() => { onMounted(() => {
fetchAll() fetchAll()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList) emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
}) })
onUnmounted(() => { onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList) emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
}) })
const refreshList = (data) => { const refreshList = (data) => {
if (data?.model === 'sla') fetchAll() if (data?.model === 'sla') fetchAll()
} }
const fetchAll = async () => { const fetchAll = async () => {
try { try {
isLoading.value = true isLoading.value = true
const resp = await api.getAllSLAs() const resp = await api.getAllSLAs()
slas.value = resp.data.data slas.value = resp.data.data
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const navigateToAddSLA = () => { const navigateToAddSLA = () => {
router.push('/admin/sla/new') router.push('/admin/sla/new')
} }
</script> </script>

View File

@@ -100,7 +100,7 @@ const permissions = ref([
{ name: 'status:manage', label: 'Manage Conversation Statuses' }, { name: 'status:manage', label: 'Manage Conversation Statuses' },
{ name: 'oidc:manage', label: 'Manage SSO Configuration' }, { name: 'oidc:manage', label: 'Manage SSO Configuration' },
{ name: 'tags:manage', label: 'Manage Tags' }, { name: 'tags:manage', label: 'Manage Tags' },
{ name: 'canned_responses:manage', label: 'Manage Canned Responses' }, { name: 'macros:manage', label: 'Manage Macros' },
{ name: 'users:manage', label: 'Manage Users' }, { name: 'users:manage', label: 'Manage Users' },
{ name: 'teams:manage', label: 'Manage Teams' }, { name: 'teams:manage', label: 'Manage Teams' },
{ name: 'automations:manage', label: 'Manage Automations' }, { name: 'automations:manage', label: 'Manage Automations' },

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'"> <div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
<div class="flex justify-end mb-5"> <div class="flex justify-end mb-5">
<Button @click="navigateToAddRole"> New role </Button> <Button @click="navigateToAddRole"> New role </Button>
@@ -11,7 +9,6 @@
</div> </div>
</div> </div>
<router-view></router-view> <router-view></router-view>
</div>
</template> </template>
<script setup> <script setup>
@@ -23,21 +20,15 @@ import { handleHTTPError } from '@/utils/http'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import api from '@/api' import api from '@/api'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const { toast } = useToast() const { toast } = useToast()
const emit = useEmitter() const emit = useEmitter()
const router = useRouter() const router = useRouter()
const roles = ref([]) const roles = ref([])
const isLoading = ref(false) const isLoading = ref(false)
const breadcrumbLinks = [
{ path: '#', label: 'Roles' }
]
const getRoles = async () => { const getRoles = async () => {
try { try {

View File

@@ -1,21 +1,18 @@
<template> <template>
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
<div class="w-8/12"> <div class="flex justify-end mb-5">
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'"> <Button @click="navigateToAddTeam"> New team </Button>
<div class="flex justify-end mb-5"> </div>
<Button @click="navigateToAddTeam"> New team </Button> <div>
</div>
<div> <div>
<div> <Spinner v-if="isLoading"></Spinner>
<Spinner v-if="isLoading"></Spinner> <DataTable :columns="columns" :data="data" v-else />
<DataTable :columns="columns" :data="data" v-else />
</div>
</div> </div>
</div> </div>
<template v-else>
<router-view></router-view>
</template>
</div> </div>
<template v-else>
<router-view></router-view>
</template>
</template> </template>
<script setup> <script setup>
@@ -24,7 +21,6 @@ import { handleHTTPError } from '@/utils/http'
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js' import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import DataTable from '@/components/admin/DataTable.vue' import DataTable from '@/components/admin/DataTable.vue'
import api from '@/api' import api from '@/api'
@@ -33,11 +29,6 @@ import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const breadcrumbLinks = [
{ path: '/admin/teams/', label: 'Teams' }
]
const emit = useEmitter() const emit = useEmitter()
const router = useRouter() const router = useRouter()
const data = ref([]) const data = ref([])

View File

@@ -121,9 +121,9 @@ const roles = ref([])
onMounted(async () => { onMounted(async () => {
try { try {
const [teamsResp, rolesResp] = await Promise.all([api.getTeams(), api.getRoles()]) const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
teams.value = teamsResp.data.data teams.value = teamsResp.value.data.data
roles.value = rolesResp.data.data roles.value = rolesResp.value.data.data
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/users'"> <div v-if="router.currentRoute.value.path === '/admin/teams/users'">
<div class="flex justify-end mb-5"> <div class="flex justify-end mb-5">
<Button @click="navigateToAddUser"> New user </Button> <Button @click="navigateToAddUser"> New user </Button>
@@ -13,7 +11,6 @@
<template v-else> <template v-else>
<router-view></router-view> <router-view></router-view>
</template> </template>
</div>
</template> </template>
<script setup> <script setup>
@@ -25,8 +22,6 @@ import { handleHTTPError } from '@/utils/http'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import api from '@/api'
@@ -36,10 +31,6 @@ const router = useRouter()
const isLoading = ref(false) const isLoading = ref(false)
const data = ref([]) const data = ref([])
const emit = useEmitter() const emit = useEmitter()
const breadcrumbLinks = [
{ path: '#', label: 'Users' }
]
onMounted(async () => { onMounted(async () => {
getData() getData()
@@ -55,7 +46,7 @@ const getData = async () => {
data.value = response.data.data data.value = response.data.data
} catch (error) { } catch (error) {
toast({ toast({
title: 'Uh oh! Could not fetch users.', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })

View File

@@ -1,6 +1,4 @@
<template> <template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/templates'"> <template v-if="router.currentRoute.value.path === '/admin/templates'">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<div></div> <div></div>
@@ -27,7 +25,6 @@
<template v-else> <template v-else>
<router-view/> <router-view/>
</template> </template>
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -1,31 +1,44 @@
<template> <template>
<div class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer"> <div class="flex flex-wrap gap-2 px-2 py-1">
<div v-for="attachment in attachments" :key="attachment.uuid" <TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]"> <div
<!-- Filename tooltip --> v-for="attachment in attachments"
<Tooltip> :key="attachment.uuid"
<TooltipTrigger as-child> class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
<div class="overflow-hidden text-ellipsis whitespace-nowrap"> >
{{ getAttachmentName(attachment.filename) }} <div class="flex items-center space-x-2 px-3 py-2">
</div> <PaperclipIcon size="16" class="text-gray-500 group-hover:text-primary" />
</TooltipTrigger> <Tooltip>
<TooltipContent> <TooltipTrigger as-child>
{{ attachment.filename }} <div
</TooltipContent> class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
</Tooltip> >
<div> {{ getAttachmentName(attachment.filename) }}
{{ formatBytes(attachment.size) }} </div>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ attachment.filename }}</p>
</TooltipContent>
</Tooltip>
<span class="text-xs text-gray-500">
{{ formatBytes(attachment.size) }}
</span>
</div>
<button
@click.stop="onDelete(attachment.uuid)"
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 attachment"
>
<X size="14" />
</button>
</div> </div>
<div @click="onDelete(attachment.uuid)"> </TransitionGroup>
<X size="13" />
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { formatBytes } from '@/utils/file.js' import { formatBytes } from '@/utils/file.js'
import { X } from 'lucide-vue-next' import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({ defineProps({
@@ -40,6 +53,24 @@ defineProps({
}) })
const getAttachmentName = (name) => { const getAttachmentName = (name) => {
return name.substring(0, 20) return name.length > 20 ? name.substring(0, 17) + '...' : name
} }
</script> </script>
<style scoped>
.attachment-list-move,
.attachment-list-enter-active,
.attachment-list-leave-active {
transition: all 0.5s ease;
}
.attachment-list-enter-from,
.attachment-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.attachment-list-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,331 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange">
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList class="!min-h-[400px]">
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
<!-- Commands requiring a conversation to be open -->
<CommandGroup
heading="Conversations"
value="conversations"
v-if="nestedCommand === null && conversationStore.current"
>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
<CommandItem value="apply-macro" @select="setNestedCommand('apply-macro')">
Apply macro
</CommandItem>
</CommandGroup>
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
<CommandItem value="1 hour" @select="handleSnooze(60)">1 hour</CommandItem>
<CommandItem value="3 hours" @select="handleSnooze(180)">3 hours</CommandItem>
<CommandItem value="6 hours" @select="handleSnooze(360)">6 hours</CommandItem>
<CommandItem value="12 hours" @select="handleSnooze(720)">12 hours</CommandItem>
<CommandItem value="1 day" @select="handleSnooze(1440)">1 day</CommandItem>
<CommandItem value="2 days" @select="handleSnooze(2880)">2 days</CommandItem>
<CommandItem value="pick date & time" @select="showCustomDialog">
Pick date & time
</CommandItem>
</CommandGroup>
<!-- Macros -->
<!-- TODO move to a separate component -->
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
<CommandGroup heading="Apply macro" class="pb-2">
<div class="min-h-[400px] overflow-auto">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 border-r border-border/30 pr-2">
<CommandItem
v-for="(macro, index) in macroStore.macroOptions"
:key="macro.value"
:value="macro.label"
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
>
<div class="flex items-center space-x-2 justify-start">
<Zap :size="18" class="text-primary" />
<span class="text-sm overflow">{{ macro.label }}</span>
</div>
</CommandItem>
</div>
<div class="col-span-8 pl-2">
<div class="space-y-3 text-xs">
<div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p>
<div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
v-html="replyContent"
/>
</div>
<div v-if="otherActions.length > 0" class="space-y-1">
<p class="text-xs font-semibold text-primary">Actions</p>
<div class="space-y-1.5 max-w-sm">
<div
v-for="action in otherActions"
:key="action.type"
class="flex items-center gap-2 px-2 py-1.5 bg-muted/30 hover:bg-accent hover:text-accent-foreground rounded-md text-xs transition-all duration-200 group"
>
<div
class="p-1 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-colors duration-200"
>
<User
v-if="action.type === 'assign_user'"
:size="10"
class="shrink-0 text-primary"
/>
<Users
v-else-if="action.type === 'assign_team'"
:size="10"
class="shrink-0 text-primary"
/>
<Pin
v-else-if="action.type === 'set_status'"
:size="10"
class="shrink-0 text-primary"
/>
<Rocket
v-else-if="action.type === 'set_priority'"
:size="10"
class="shrink-0 text-primary"
/>
<Tags
v-else-if="action.type === 'set_tags'"
:size="10"
class="shrink-0 text-primary"
/>
</div>
<span class="truncate">{{ getActionLabel(action) }}</span>
</div>
</div>
</div>
<div
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
>
<p class="text-xs text-muted-foreground italic">
Select a macro to view details
</p>
</div>
</div>
</div>
</div>
</div>
</CommandGroup>
</div>
</CommandList>
<!-- Navigation -->
<!-- TODO: Move to a separate component -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
<span><kbd>Esc</kbd> close</span>
<span><kbd>Backspace</kbd> parent</span>
</div>
</CommandDialog>
<!-- Date Picker for Custom Snooze -->
<!-- TODO: Move to a separate component -->
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Pick Snooze Time</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full justify-start text-left font-normal">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ selectedDate ? selectedDate : 'Pick a date' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar mode="single" v-model="selectedDate" />
</PopoverContent>
</Popover>
<div class="grid gap-2">
<Label>Time</Label>
<Input type="time" v-model="selectedTime" />
</div>
</div>
<DialogFooter>
<Button @click="handleCustomSnooze">Snooze</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { useMacroStore } from '@/stores/macro'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const conversationStore = useConversationStore()
const macroStore = useMacroStore()
const open = ref(false)
const emitter = useEmitter()
const nestedCommand = ref(null)
const showDatePicker = ref(false)
const selectedDate = ref(null)
const selectedTime = ref('12:00')
const keys = useMagicKeys()
const cmdK = keys['meta+k']
const ctrlK = keys['ctrl+k']
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
conversationStore.setMacro(macro)
handleOpenChange()
}
const getActionLabel = computed(() => (action) => {
const prefixes = {
assign_user: 'Assign to user',
assign_team: 'Assign to team',
set_status: 'Set status',
set_priority: 'Set priority',
set_tags: 'Set tags'
}
return `${prefixes[action.type]}: ${action.display_value.length > 0 ? action.display_value.join(', ') : action.value.join(', ')}`
})
const replyContent = computed(() => highlightedMacro.value?.message_content || '')
const otherActions = computed(
() =>
highlightedMacro.value?.actions?.filter(
(a) => a.type !== 'send_private_note' && a.type !== 'send_reply'
) || []
)
function handleOpenChange() {
if (!open.value) nestedCommand.value = null
open.value = !open.value
}
function setNestedCommand(command) {
nestedCommand.value = command
}
function formatDuration(minutes) {
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
}
async function handleSnooze(minutes) {
await conversationStore.snoozeConversation(formatDuration(minutes))
handleOpenChange()
}
async function resolveConversation() {
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
handleOpenChange()
}
function showCustomDialog() {
handleOpenChange()
showDatePicker.value = true
}
function closeDatePicker() {
showDatePicker.value = false
}
function handleCustomSnooze() {
const [hours, minutes] = selectedTime.value.split(':')
const snoozeDate = new Date(selectedDate.value)
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
if (diffMinutes <= 0) {
alert('Select a future time')
return
}
handleSnooze(diffMinutes)
closeDatePicker()
handleOpenChange()
}
function onInputKeydown(e) {
if (e.key === 'Backspace') {
const inputVal = e.target.value || ''
if (!inputVal && nestedCommand.value !== null) {
e.preventDefault()
nestedCommand.value = null
}
}
}
function preventDefaultKey(event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
event.stopPropagation()
return false
}
}
onMounted(() => {
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
setNestedCommand(command)
open.value = true
})
window.addEventListener('keydown', preventDefaultKey)
watchHighlightedMacro()
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
window.removeEventListener('keydown', preventDefaultKey)
})
watch([cmdK, ctrlK], ([mac, win]) => {
if (mac || win) handleOpenChange()
})
const watchHighlightedMacro = () => {
const observer = new MutationObserver(() => {
const highlightedEl = document.querySelector('[data-highlighted]')?.getAttribute('data-index')
highlightedMacro.value = highlightedEl ? macroStore.macroOptions[highlightedEl] : null
})
observer.observe(document.body, {
attributes: true,
subtree: true
})
}
</script>

View File

@@ -1,194 +0,0 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange">
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList>
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
<!-- Commands requiring a conversation to be open -->
<CommandGroup heading="Conversations" value="conversations"
v-if="nestedCommand === null && conversationStore.current">
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
Snooze
</CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation">
Resolve
</CommandItem>
</CommandGroup>
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
<CommandItem value="snooze-1h" @select="handleSnooze(60)">1 hour</CommandItem>
<CommandItem value="snooze-3h" @select="handleSnooze(180)">3 hours</CommandItem>
<CommandItem value="snooze-6h" @select="handleSnooze(360)">6 hours</CommandItem>
<CommandItem value="snooze-12h" @select="handleSnooze(720)">12 hours</CommandItem>
<CommandItem value="snooze-1d" @select="handleSnooze(1440)">1 day</CommandItem>
<CommandItem value="snooze-2d" @select="handleSnooze(2880)">2 days</CommandItem>
<CommandItem value="snooze-custom" @select="showCustomDialog">Pick date & time</CommandItem>
</CommandGroup>
</CommandList>
<!-- Navigation -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
<span><kbd>Esc</kbd> close</span>
<span><kbd>Backspace</kbd> parent</span>
</div>
</CommandDialog>
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Pick Snooze Time</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full justify-start text-left font-normal">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ selectedDate ? selectedDate : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar mode="single" v-model="selectedDate" />
</PopoverContent>
</Popover>
<div class="grid gap-2">
<Label>Time</Label>
<Input type="time" v-model="selectedTime" />
</div>
</div>
<DialogFooter>
<Button @click="handleCustomSnooze">Snooze</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const conversationStore = useConversationStore()
const open = ref(false)
const emitter = useEmitter()
const nestedCommand = ref(null)
const showDatePicker = ref(false)
const selectedDate = ref(null)
const selectedTime = ref('12:00')
const keys = useMagicKeys()
const cmdK = keys['meta+k']
const ctrlK = keys['ctrl+k']
function handleOpenChange () {
if (!open.value) nestedCommand.value = null
open.value = !open.value
}
function setNestedCommand (command) {
nestedCommand.value = command
}
function formatDuration (minutes) {
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
}
async function handleSnooze (minutes) {
await conversationStore.snoozeConversation(formatDuration(minutes))
handleOpenChange()
}
async function resolveConversation () {
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
handleOpenChange()
}
function showCustomDialog () {
handleOpenChange()
showDatePicker.value = true
}
function closeDatePicker () {
showDatePicker.value = false
}
function handleCustomSnooze () {
const [hours, minutes] = selectedTime.value.split(':')
const snoozeDate = new Date(selectedDate.value)
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
if (diffMinutes <= 0) {
alert('Select a future time')
return
}
handleSnooze(diffMinutes)
closeDatePicker()
handleOpenChange()
}
function onInputKeydown (e) {
if (e.key === 'Backspace') {
const inputVal = e.target.value || ''
if (!inputVal && nestedCommand.value !== null) {
e.preventDefault()
nestedCommand.value = null
}
}
}
function preventDefaultKey (event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
event.stopPropagation()
return false
}
}
onMounted(() => {
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
setNestedCommand(command)
open.value = true
})
window.addEventListener('keydown', preventDefaultKey)
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
window.removeEventListener('keydown', preventDefaultKey)
})
watch([cmdK, ctrlK], ([mac, win]) => {
if (mac || win) handleOpenChange()
})
</script>

View File

@@ -0,0 +1,186 @@
<template>
<div class="space-y-5 rounded-lg">
<div class="space-y-5">
<div v-for="(action, index) in model" :key="index" class="space-y-5">
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<Select
v-model="action.type"
@update:modelValue="(value) => updateField(value, index)"
>
<SelectTrigger>
<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>
<div
v-if="action.type && config.actions[action.type]?.type === 'select'"
class="w-48"
>
<ComboBox
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
>
<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>Select user</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>Select team</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
<div v-else>Select</div>
</template>
</ComboBox>
</div>
</div>
<X class="cursor-pointer w-4" @click="remove(index)" />
</div>
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames"
placeholder="Select tag"
/>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
v-model:content="action.value[0]"
theme="snow"
contentType="html"
@update:content="(value) => updateValue(value, index)"
class="h-32 mb-12"
/>
</div>
</div>
</div>
</div>
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const model = defineModel({
type: Array,
required: true,
default: () => []
})
defineProps({
config: {
type: Object,
required: true
}
})
const tagsStore = useTagStore()
const updateField = (value, index) => {
const newModel = [...model.value]
newModel[index] = { type: value, value: [] }
model.value = newModel
}
const updateValue = (value, index) => {
const newModel = [...model.value]
newModel[index] = {
...newModel[index],
value: [value?.value ?? value]
}
model.value = newModel
}
const remove = (index) => {
model.value = model.value.filter((_, i) => i !== index)
}
const add = () => {
model.value = [...model.value, { type: '', value: [] }]
}
</script>

View File

@@ -1,145 +0,0 @@
<template>
<div class="space-y-4">
<div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
<div class="grid grid-cols-3 gap-2 w-full">
<!-- Field -->
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Operator -->
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Value -->
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<Select v-if="getFieldOptions(modelFilter).length > 0" v-model="modelFilter.value">
<SelectTrigger class="bg-transparent hover:bg-slate-100">
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in getFieldOptions(modelFilter)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input v-else v-model="modelFilter.value" class="bg-transparent hover:bg-slate-100" placeholder="Value"
type="text" />
</template>
</div>
</div>
<button v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch, onUnmounted } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps({
fields: {
type: Array,
required: true,
},
showButtons: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
}
})
onUnmounted(() => {
modelValue.value = []
})
const getModel = (field) => {
const fieldConfig = props.fields.find(f => f.field === field)
return fieldConfig?.model || ''
}
watch(() => modelValue.value, (filters) => {
filters.forEach(filter => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
}, { deep: true })
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find(f => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find(f => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="space-y-4">
<div
v-for="(modelFilter, index) in modelValue"
:key="index"
class="group flex items-center gap-3"
>
<div class="grid grid-cols-3 gap-2 w-full">
<!-- Field -->
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Operator -->
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Value -->
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<ComboBox
v-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
placeholder="Select"
>
<template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<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="modelFilter.field === 'assigned_team_id'">
<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="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>
<span v-else>Select user</span>
</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>
<span v-else>Select team</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
placeholder="Value"
type="text"
/>
</template>
</div>
</div>
<button
v-show="modelValue.length > 1"
@click="removeFilter(index)"
class="p-1 hover:bg-slate-100 rounded"
>
<X class="w-4 h-4 text-slate-500" />
</button>
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch, onUnmounted } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
fields: {
type: Array,
required: true
},
showButtons: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
}
})
onUnmounted(() => {
modelValue.value = []
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
watch(
() => modelValue.value,
(filters) => {
filters.forEach((filter) => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
},
{ deep: true }
)
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find((f) => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -1,45 +0,0 @@
<template>
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
<template v-slot="{ navigate, isActive }">
<Button
as="a"
:href="item.href"
variant="ghost"
:class="
cn(
'w-full text-left justify-start h-16 pl-3',
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
)
"
@click="navigate"
>
<div class="flex flex-col items-start space-y-1">
<span class="text-sm">{{ item.title }}</span>
<p class="text-muted-foreground text-xs break-words whitespace-normal">{{ item.description }}</p>
</div>
</Button>
</template>
</router-link>
</nav>
</template>
<script setup>
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useRoute } from 'vue-router'
const route = useRoute()
defineProps({
navItems: {
type: Array,
required: true,
default: () => []
}
})
const isChildActive = (href) => {
return route.path.startsWith(href)
}
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div v-if="conversationStore.messages.data"> <div v-if="conversationStore.messages.data">
<!-- Header --> <!-- Header -->
<div class="p-3 border-b flex items-center justify-between"> <div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm"> <div class="flex items-center space-x-3 text-sm">
<div class="font-medium"> <div class="font-medium">
{{ conversationStore.current.subject }} {{ conversationStore.current.subject }}
@@ -16,7 +16,7 @@
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value" <DropdownMenuItem v-for="status in conversationStore.statusOptions" :key="status.value"
@click="handleUpdateStatus(status.label)"> @click="handleUpdateStatus(status.label)">
{{ status.label }} {{ status.label }}
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -64,7 +64,6 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import HardBreak from '@tiptap/extension-hard-break'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
@@ -95,18 +94,26 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
const editorConfig = { const editorConfig = {
extensions: [ extensions: [
// Lists are unstyled in tailwind, so we need to add classes to them.
StarterKit.configure({ StarterKit.configure({
hardBreak: false bulletList: {
}), HTMLAttributes: {
HardBreak.extend({ class: 'list-disc ml-6 my-2'
addKeyboardShortcuts() { }
return { },
Enter: () => { orderedList: {
if (this.editor.isActive('orderedList') || this.editor.isActive('bulletList')) { HTMLAttributes: {
return this.editor.chain().createParagraphNear().run() class: 'list-decimal ml-6 my-2'
} }
return this.editor.commands.setHardBreak() },
} listItem: {
HTMLAttributes: {
class: 'pl-1'
}
},
heading: {
HTMLAttributes: {
class: 'text-xl font-bold mt-4 mb-2'
} }
} }
}), }),
@@ -194,7 +201,7 @@ watch(
if (!props.clearContent) return if (!props.clearContent) return
editor.value?.commands.clearContent() editor.value?.commands.clearContent()
editor.value?.commands.focus() editor.value?.commands.focus()
// `onUpdate` is not called when clearing content, so we need to manually reset the values. // `onUpdate` is not called when clearing content, so need to reset the content here.
htmlContent.value = '' htmlContent.value = ''
textContent.value = '' textContent.value = ''
cursorPosition.value = 0 cursorPosition.value = 0
@@ -238,10 +245,10 @@ onUnmounted(() => {
// Editor height // Editor height
.ProseMirror { .ProseMirror {
min-height: 150px !important; min-height: 200px !important;
max-height: 100% !important; max-height: 60% !important;
overflow-y: scroll !important; overflow-y: scroll !important;
padding: 10px 10px; padding: 10px;
} }
.tiptap { .tiptap {
@@ -254,8 +261,4 @@ onUnmounted(() => {
} }
} }
} }
br.ProseMirror-trailingBreak {
display: none;
}
</style> </style>

View File

@@ -5,26 +5,6 @@
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event"> <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event">
<DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6"> <DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6">
<div v-if="isEditorFullscreen"> <div v-if="isEditorFullscreen">
<div
v-if="filteredCannedResponses.length > 0"
class="w-full overflow-hidden p-2 border-t backdrop-blur"
>
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
<li
v-for="(response, index) in filteredCannedResponses"
:key="response.id"
:class="[
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]"
@click="selectCannedResponse(response.content)"
@mouseenter="selectedResponseIndex = index"
>
<span class="font-semibold">{{ response.title }}</span> -
{{ getTextFromHTML(response.content).slice(0, 150) }}...
</li>
</ul>
</div>
<Editor <Editor
v-model:selectedText="selectedText" v-model:selectedText="selectedText"
v-model:isBold="isBold" v-model:isBold="isBold"
@@ -33,7 +13,6 @@
v-model:textContent="textContent" v-model:textContent="textContent"
:placeholder="editorPlaceholder" :placeholder="editorPlaceholder"
:aiPrompts="aiPrompts" :aiPrompts="aiPrompts"
@keydown="handleKeydown"
@aiPromptSelected="handleAiPromptSelected" @aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet" :contentToSet="contentToSet"
v-model:cursorPosition="cursorPosition" v-model:cursorPosition="cursorPosition"
@@ -45,28 +24,6 @@
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- Canned responses on non-fullscreen editor -->
<div
v-if="filteredCannedResponses.length > 0 && !isEditorFullscreen"
class="w-full overflow-hidden p-2 border-t backdrop-blur"
>
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
<li
v-for="(response, index) in filteredCannedResponses"
:key="response.id"
:class="[
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]"
@click="selectCannedResponse(response.content)"
@mouseenter="selectedResponseIndex = index"
>
<span class="font-semibold">{{ response.title }}</span> -
{{ getTextFromHTML(response.content).slice(0, 150) }}...
</li>
</ul>
</div>
<!-- Main Editor non-fullscreen --> <!-- Main Editor non-fullscreen -->
<div class="border-t" v-if="!isEditorFullscreen"> <div class="border-t" v-if="!isEditorFullscreen">
<!-- Message type toggle --> <!-- Message type toggle -->
@@ -94,7 +51,6 @@
v-model:textContent="textContent" v-model:textContent="textContent"
:placeholder="editorPlaceholder" :placeholder="editorPlaceholder"
:aiPrompts="aiPrompts" :aiPrompts="aiPrompts"
@keydown="handleKeydown"
@aiPromptSelected="handleAiPromptSelected" @aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet" :contentToSet="contentToSet"
@send="handleSend" @send="handleSend"
@@ -104,18 +60,30 @@
:insertContent="insertContent" :insertContent="insertContent"
/> />
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview --> <!-- Attachments preview -->
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete" /> <AttachmentsPreview
:attachments="attachments"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0"
/>
<!-- Bottom menu bar --> <!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar <ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload" :handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload" :handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold" :isBold="isBold"
:isItalic="isItalic" :isItalic="isItalic"
@toggleBold="toggleBold" @toggleBold="toggleBold"
@toggleItalic="toggleItalic" @toggleItalic="toggleItalic"
:hasText="hasText" :enableSend="enableSend"
:handleSend="handleSend" :handleSend="handleSend"
@emojiSelect="handleEmojiSelect" @emojiSelect="handleEmojiSelect"
> >
@@ -125,25 +93,24 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue' import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings' import { transformImageSrcToCID } from '@/utils/strings'
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 { Fullscreen } from 'lucide-vue-next' import { Fullscreen } from 'lucide-vue-next'
import api from '@/api' import api from '@/api'
import { getTextFromHTML } from '@/utils/strings'
import Editor from './ConversationTextEditor.vue' import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Dialog, DialogContent } from '@/components/ui/dialog' import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue' import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '../macro/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue' import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const emitter = useEmitter() const emitter = useEmitter()
const insertContent = ref(null) const insertContent = ref(null)
const setInlineImage = ref(null) const setInlineImage = ref(null)
const clearEditorContent = ref(false) const clearEditorContent = ref(false)
@@ -155,22 +122,14 @@ const textContent = ref('')
const contentToSet = ref('') const contentToSet = ref('')
const isBold = ref(false) const isBold = ref(false)
const isItalic = ref(false) const isItalic = ref(false)
const uploadedFiles = ref([])
const messageType = ref('reply') const messageType = ref('reply')
const filteredCannedResponses = ref([])
const selectedResponseIndex = ref(-1)
const cannedResponsesRef = ref(null)
const cannedResponses = ref([])
const aiPrompts = ref([]) const aiPrompts = ref([])
const editorPlaceholder = const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
"Press Enter to add a new line; Press '/' to select a Canned Response; Press Ctrl + Enter to send."
onMounted(async () => { onMounted(async () => {
await Promise.all([fetchCannedResponses(), fetchAiPrompts()]) await fetchAiPrompts()
}) })
const fetchAiPrompts = async () => { const fetchAiPrompts = async () => {
@@ -186,19 +145,6 @@ const fetchAiPrompts = async () => {
} }
} }
const fetchCannedResponses = async () => {
try {
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const handleAiPromptSelected = async (key) => { const handleAiPromptSelected = async (key) => {
try { try {
const resp = await api.aiCompletion({ const resp = await api.aiCompletion({
@@ -224,35 +170,20 @@ const toggleItalic = () => {
} }
const attachments = computed(() => { const attachments = computed(() => {
return uploadedFiles.value.filter((upload) => upload.disposition === 'attachment') return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
}) })
// Watch for text content changes and filter canned responses const enableSend = computed(() => {
watch(textContent, (newVal) => { return textContent.value.trim().length > 0 ||
filterCannedResponses(newVal) conversationStore.conversation?.macro?.actions?.length > 0
? true
: false
}) })
const filterCannedResponses = (input) => { const hasTextContent = computed(() => {
// Extract the text after the last `/` return textContent.value.trim().length > 0
const lastSlashIndex = input.lastIndexOf('/')
if (lastSlashIndex !== -1) {
const searchText = input.substring(lastSlashIndex + 1).trim()
// Filter canned responses based on the search text
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
response.title.toLowerCase().includes(searchText.toLowerCase())
)
// Reset the selected response index
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1
} else {
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
}
}
const hasText = computed(() => {
return textContent.value.trim().length > 0 ? true : false
}) })
const handleFileUpload = (event) => { const handleFileUpload = (event) => {
@@ -264,7 +195,7 @@ const handleFileUpload = (event) => {
linked_model: 'messages' linked_model: 'messages'
}) })
.then((resp) => { .then((resp) => {
uploadedFiles.value.push(resp.data.data) conversationStore.conversation.mediaFiles.push(resp.data.data)
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -290,7 +221,7 @@ const handleInlineImageUpload = (event) => {
alt: resp.data.data.filename, alt: resp.data.data.filename,
title: resp.data.data.uuid title: resp.data.data.uuid
} }
uploadedFiles.value.push(resp.data.data) conversationStore.conversation.mediaFiles.push(resp.data.data)
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -304,29 +235,42 @@ const handleInlineImageUpload = (event) => {
const handleSend = async () => { const handleSend = async () => {
try { try {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
// Check which images are still in editor before sending. // Send message if there is text content in the editor.
const parser = new DOMParser() if (hasTextContent.value) {
const doc = parser.parseFromString(htmlContent.value, 'text/html') // Replace inline image url with cid.
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image')) const message = transformImageSrcToCID(htmlContent.value)
.map((img) => img.getAttribute('title'))
.filter(Boolean)
uploadedFiles.value = uploadedFiles.value.filter( // Check which images are still in editor before sending.
(file) => const parser = new DOMParser()
// Keep if: const doc = parser.parseFromString(htmlContent.value, 'text/html')
// 1. Not an inline image OR const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
// 2. Is an inline image that exists in editor .map((img) => img.getAttribute('title'))
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid) .filter(Boolean)
)
await api.sendMessage(conversationStore.current.uuid, { conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
private: messageType.value === 'private_note', (file) =>
message: message, // Keep if:
attachments: uploadedFiles.value.map((file) => file.id) // 1. Not an inline image OR
}) // 2. Is an inline image that exists in editor
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
)
await api.sendMessage(conversationStore.current.uuid, {
private: messageType.value === 'private_note',
message: message,
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id)
})
}
// Apply macro if it exists.
if (conversationStore.conversation?.macro?.actions?.length > 0) {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
}
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error', title: 'Error',
@@ -335,6 +279,8 @@ const handleSend = async () => {
}) })
} finally { } finally {
clearEditorContent.value = true clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()
nextTick(() => { nextTick(() => {
clearEditorContent.value = false clearEditorContent.value = false
}) })
@@ -343,49 +289,23 @@ const handleSend = async () => {
} }
const handleOnFileDelete = (uuid) => { const handleOnFileDelete = (uuid) => {
uploadedFiles.value = uploadedFiles.value.filter((item) => item.uuid !== uuid) conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
} (item) => item.uuid !== uuid
)
const handleKeydown = (event) => {
if (filteredCannedResponses.value.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value + 1) % filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) %
filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'Enter') {
event.preventDefault()
selectCannedResponse(filteredCannedResponses.value[selectedResponseIndex.value].content)
}
}
}
const scrollToSelectedItem = () => {
const list = cannedResponsesRef.value
const selectedItem = list.children[selectedResponseIndex.value]
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
const selectCannedResponse = (content) => {
contentToSet.value = content
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
} }
const handleEmojiSelect = (emoji) => { const handleEmojiSelect = (emoji) => {
insertContent.value = undefined insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times // Force reactivity so the user can select the same emoji multiple times
nextTick(() => insertContent.value = emoji) nextTick(() => (insertContent.value = emoji))
} }
// Watch for changes in macro content and update editor content.
watch(
() => conversationStore.conversation.macro,
() => {
contentToSet.value = conversationStore.conversation.macro.message_content
},
{ deep: true }
)
</script> </script>

View File

@@ -35,7 +35,7 @@
<Smile class="h-4 w-4" /> <Smile class="h-4 w-4" />
</Toggle> </Toggle>
</div> </div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button> <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
</div> </div>
</template> </template>
@@ -57,7 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
defineProps({ defineProps({
isBold: Boolean, isBold: Boolean,
isItalic: Boolean, isItalic: Boolean,
hasText: Boolean, enableSend: Boolean,
handleSend: Function, handleSend: Function,
handleFileUpload: Function, handleFileUpload: Function,
handleInlineImageUpload: Function handleInlineImageUpload: Function

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col items-center justify-center h-64 space-y-2"> <div class="flex flex-col items-center justify-center h-64 space-y-2">
<component :is="icon" :stroke-width="1.4" :size="70" /> <component :is="icon" :stroke-width="1" :size="50" />
<h1 class="text-md font-semibold text-gray-800"> <h1 class="text-md font-semibold text-gray-800">
{{ title }} {{ title }}
</h1> </h1>

View File

@@ -1,21 +1,25 @@
<template> <template>
<div class="h-screen flex flex-col"> <div class="h-screen flex flex-col">
<div class="flex justify-start items-center p-3 w-full space-x-4 border-b"> <!-- Header -->
<SidebarTrigger class="cursor-pointer w-5 h-5" /> <header class="border-b">
<span class="text-xl font-semibold">{{ title }}</span> <div class="flex items-center space-x-4 p-2">
</div> <SidebarTrigger class="text-gray-500 hover:text-gray-700 transition-colors" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
</header>
<div class="flex justify-between px-2 py-2 w-full"> <!-- Filters -->
<div class="bg-white px-4 py-3 flex justify-between items-center">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger asChild>
<Button variant="ghost"> <Button variant="ghost" class="w-30">
{{ conversationStore.getListStatus }} {{ conversationStore.getListStatus }}
<ChevronDown class="w-4 h-4 ml-2" /> <ChevronDown class="w-4 h-4 ml-2 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
v-for="status in conversationStore.statusesForSelect" v-for="status in conversationStore.statusOptions"
:key="status.value" :key="status.value"
@click="handleStatusChange(status)" @click="handleStatusChange(status)"
> >
@@ -24,14 +28,13 @@
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger asChild>
<Button variant="ghost"> <Button variant="ghost" class="w-30">
{{ conversationStore.getListSortField }} {{ conversationStore.getListSortField }}
<ChevronDown class="w-4 h-4 ml-2" /> <ChevronDown class="w-4 h-4 ml-2 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<!-- TODO move hardcoded values to consts -->
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem> <DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem> <DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_first')" <DropdownMenuItem @click="handleSortChange('started_first')"
@@ -53,63 +56,79 @@
</DropdownMenu> </DropdownMenu>
</div> </div>
<!-- Empty --> <!-- Content -->
<EmptyList
class="px-4"
v-if="!hasConversations && !hasErrored && !isLoading"
title="No conversations found"
message="Try adjusting filters."
:icon="MessageCircleQuestion"
></EmptyList>
<!-- List -->
<div class="flex-grow overflow-y-auto"> <div class="flex-grow overflow-y-auto">
<EmptyList <EmptyList
class="px-4" v-if="!hasConversations && !hasErrored && !isLoading"
v-if="conversationStore.conversations.errorMessage" key="empty"
title="Could not fetch conversations" class="px-4 py-8"
:message="conversationStore.conversations.errorMessage" title="No conversations found"
:icon="MessageCircleWarning" message="Try adjusting your filters"
></EmptyList> :icon="MessageCircleQuestion"
/>
<!-- Items --> <!-- Empty State -->
<div v-else> <TransitionGroup
<div class="space-y-5 px-2"> enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0 transform translate-y-4"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition-all duration-300 ease-in-out"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform translate-y-4"
>
<!-- Error State -->
<EmptyList
v-if="conversationStore.conversations.errorMessage"
key="error"
class="px-4 py-8"
title="Could not fetch conversations"
:message="conversationStore.conversations.errorMessage"
:icon="MessageCircleWarning"
/>
<!-- Conversation List -->
<div v-else key="list" class="divide-y divide-gray-200">
<ConversationListItem <ConversationListItem
class="mt-2"
:conversation="conversation"
:currentConversation="conversationStore.current"
v-for="conversation in conversationStore.conversationsList" v-for="conversation in conversationStore.conversationsList"
:key="conversation.uuid" :key="conversation.uuid"
:conversation="conversation"
:currentConversation="conversationStore.current"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" :contactFullName="conversationStore.getContactFullName(conversation.uuid)"
class="transition-colors duration-200 hover:bg-gray-50"
/> />
</div> </div>
</div>
<!-- skeleton --> <!-- Loading Skeleton -->
<div v-if="isLoading"> <div v-if="isLoading" key="loading" class="space-y-4 p-4">
<ConversationListItemSkeleton v-for="index in 10" :key="index" /> <ConversationListItemSkeleton v-for="index in 10" :key="index" />
</div> </div>
</TransitionGroup>
<!-- Load more --> <!-- Load More -->
<div class="flex justify-center items-center p-5 relative" v-if="!hasErrored"> <div
<div v-if="conversationStore.conversations.hasMore"> v-if="!hasErrored && (conversationStore.conversations.hasMore || hasConversations)"
<Button variant="link" @click="loadNextPage"> class="flex justify-center items-center p-5"
<p v-if="!isLoading">Load more</p> >
</Button> <Button
</div> v-if="conversationStore.conversations.hasMore"
<div v-else-if="!conversationStore.conversations.hasMore && hasConversations"> variant="outline"
All conversations loaded @click="loadNextPage"
</div> :disabled="isLoading"
class="transition-all duration-200 ease-in-out transform hover:scale-105"
>
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p v-else class="text-sm text-gray-500">All conversations loaded</p>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, computed, onUnmounted } from 'vue' import { onMounted, computed, onUnmounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next' import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -124,24 +143,26 @@ import { useRoute } from 'vue-router'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue' import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
let reFetchInterval = null const route = useRoute()
let reFetchInterval = ref(null)
// Re-fetch conversations list every 30 seconds for any missed updates. const title = computed(() => {
// FIXME: Figure out a better way to handle this. const typeValue = route.meta?.type?.(route)
return (
(typeValue || route.meta?.title || '').charAt(0).toUpperCase() +
(typeValue || route.meta?.title || '').slice(1)
)
})
// FIXME: Figure how to get missed updates.
onMounted(() => { onMounted(() => {
reFetchInterval = setInterval(() => { reFetchInterval.value = setInterval(() => {
conversationStore.reFetchConversationsList(false) conversationStore.reFetchConversationsList(false)
}, 30000) }, 30000)
}) })
const route = useRoute()
const title = computed(() => {
const typeValue = route.meta?.type?.(route)
return (typeValue || route.meta?.title || '').charAt(0).toUpperCase() + (typeValue || route.meta?.title || '').slice(1)
})
onUnmounted(() => { onUnmounted(() => {
clearInterval(reFetchInterval) clearInterval(reFetchInterval.value)
conversationStore.clearListReRenderInterval() conversationStore.clearListReRenderInterval()
}) })
@@ -157,15 +178,7 @@ const loadNextPage = () => {
conversationStore.fetchNextConversations() conversationStore.fetchNextConversations()
} }
const hasConversations = computed(() => { const hasConversations = computed(() => conversationStore.conversationsList.length !== 0)
return conversationStore.conversationsList.length !== 0 const hasErrored = computed(() => !!conversationStore.conversations.errorMessage)
}) const isLoading = computed(() => conversationStore.conversations.loading)
const hasErrored = computed(() => {
return conversationStore.conversations.errorMessage ? true : false
})
const isLoading = computed(() => {
return conversationStore.conversations.loading
})
</script> </script>

View File

@@ -18,26 +18,11 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ListFilter, ChevronDown } from 'lucide-vue-next' import { ListFilter } from 'lucide-vue-next'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button' import Filter from '@/components/common/FilterBuilder.vue'
import Filter from '@/components/common/Filter.vue'
import api from '@/api' import api from '@/api'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
@@ -52,14 +37,6 @@ onMounted(() => {
localFilters.value = [...conversationStore.conversations.filters] localFilters.value = [...conversationStore.conversations.filters]
}) })
const handleStatusChange = (status) => {
console.log('status', status)
}
const handleSortChange = (order) => {
console.log('order', order)
}
const fetchInitialData = async () => { const fetchInitialData = async () => {
const [statusesResp, prioritiesResp] = await Promise.all([ const [statusesResp, prioritiesResp] = await Promise.all([
api.getStatuses(), api.getStatuses(),

View File

@@ -1,53 +1,49 @@
<template> <template>
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box" <div
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }" class="relative p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
@click="navigateToConversation(conversation.uuid)"> :class="{ 'bg-blue-50': conversation.uuid === currentConversation?.uuid }"
@click="navigateToConversation(conversation.uuid)"
<div class="pl-3"> >
<Avatar class="size-[45px]"> <div class="flex items-start space-x-4">
<Avatar class="w-12 h-12 rounded-full ring-2 ring-white">
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" /> <AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
<AvatarFallback> <AvatarFallback>
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }} {{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</div>
<div class="ml-3 w-full pb-2"> <div class="flex-1 min-w-0">
<div class="flex justify-between pt-2 pr-3"> <div class="flex items-center justify-between">
<div> <h3 class="text-sm font-medium text-gray-900 truncate">
<p class="text-xs text-gray-600 flex gap-x-1">
<Mail size="13" />
{{ conversation.inbox_name }}
</p>
<p class="text-base font-normal">
{{ contactFullName }} {{ contactFullName }}
</p> </h3>
</div> <span class="text-xs text-gray-500" v-if="conversation.last_message_at">
<div>
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
{{ formatTime(conversation.last_message_at) }} {{ formatTime(conversation.last_message_at) }}
</span> </span>
</div> </div>
</div>
<div class="pt-2 pr-3"> <p class="mt-1 text-xs text-gray-500 flex items-center space-x-1">
<div class="flex justify-between"> <Mail class="w-3 h-3" />
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1"> <span>{{ conversation.inbox_name }}</span>
<CheckCheck :size="14" /> {{ trimmedLastMessage }} </p>
</p>
<div class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]" <p class="mt-2 text-sm text-gray-600 line-clamp-2">
v-if="conversation.unread_message_count > 0"> <CheckCheck class="inline w-4 h-4 mr-1 text-green-500" />
<span class="text-white text-xs font-extrabold"> {{ trimmedLastMessage }}
{{ conversation.unread_message_count }} </p>
</span>
</div> <div class="flex items-center mt-2 space-x-2">
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
</div> </div>
</div> </div>
<div class="flex space-x-2 mt-2"> </div>
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'"
:showSLAHit="false" /> <div
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" v-if="conversation.unread_message_count > 0"
:showSLAHit="false" /> class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 bg-blue-500 text-white text-xs font-bold rounded-full"
</div> >
{{ conversation.unread_message_count }}
</div> </div>
</div> </div>
</template> </template>
@@ -88,6 +84,7 @@ const navigateToConversation = (uuid) => {
const trimmedLastMessage = computed(() => { const trimmedLastMessage = computed(() => {
const message = props.conversation.last_message || '' const message = props.conversation.last_message || ''
return message.length > 45 ? message.slice(0, 45) + "..." : message return message.length > 100 ? message.slice(0, 100) + "..." : message
}) })
</script> </script>

View File

@@ -1,13 +1,13 @@
<template> <template>
<div class="flex items-center gap-5 p-6 border-b"> <div class="flex items-center gap-5 p-6 border-b min-w-[200px]">
<Skeleton class="h-12 w-12 rounded-full" /> <Skeleton class="h-12 w-12 rounded-full aspect-square" />
<div class="space-y-2"> <div class="space-y-2 flex-grow">
<Skeleton class="h-4 w-[250px]" /> <Skeleton class="h-4 w-full max-w-[250px]" />
<Skeleton class="h-4 w-[200px]" /> <Skeleton class="h-4 w-full max-w-[200px]" />
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
</script> </script>

View File

@@ -1,15 +1,14 @@
<template> <template>
<div v-if="conversationStore.current"> <div v-if="conversationStore.current">
<ConversationSideBarContact :conversation="conversationStore.current" class="p-3" /> <ConversationSideBarContact :conversation="conversationStore.current" class="p-3" />
<Accordion type="multiple" collapsible class="border-t mt-4" :default-value="[]"> <Accordion type="multiple" collapsible class="border-t" :default-value="[]">
<AccordionItem value="Actions"> <AccordionItem value="Actions">
<AccordionTrigger class="bg-muted p-3"> Actions </AccordionTrigger> <AccordionTrigger class="bg-muted p-3"> Actions </AccordionTrigger>
<AccordionContent class="space-y-5 p-3"> <AccordionContent class="space-y-5 p-3">
<!-- Agent --> <!-- Agent -->
<ComboBox <ComboBox
v-model="assignedUserID" v-model="assignedUserID"
:items="usersStore.forSelect" :items="usersStore.options"
placeholder="Search agent" placeholder="Search agent"
defaultLabel="Assign agent" defaultLabel="Assign agent"
@select="selectAgent" @select="selectAgent"
@@ -43,21 +42,21 @@
<!-- Team --> <!-- Team -->
<ComboBox <ComboBox
v-model="assignedTeamID" v-model="assignedTeamID"
:items="teamsStore.forSelect" :items="teamsStore.options"
placeholder="Search team" placeholder="Search team"
defaultLabel="Assign team" defaultLabel="Assign team"
@select="selectTeam" @select="selectTeam"
> >
<template #item="{ item }"> <template #item="{ item }">
<div class="flex items-center gap-2 ml-2"> <div class="flex items-center gap-2 ml-2">
{{item.emoji}} {{ item.emoji }}
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</div> </div>
</template> </template>
<template #selected="{ selected }"> <template #selected="{ selected }">
<div v-if="selected" class="flex items-center gap-2"> <div v-if="selected" class="flex items-center gap-2">
{{selected.emoji}} {{ selected.emoji }}
<span>{{ selected.label }}</span> <span>{{ selected.label }}</span>
</div> </div>
<span v-else>Select team</span> <span v-else>Select team</span>
@@ -67,7 +66,7 @@
<!-- Priority --> <!-- Priority -->
<ComboBox <ComboBox
v-model="conversationStore.current.priority" v-model="conversationStore.current.priority"
:items="conversationStore.prioritiesForSelect" :items="conversationStore.priorityOptions"
:defaultLabel="conversationStore.current.priority ?? 'Select priority'" :defaultLabel="conversationStore.current.priority ?? 'Select priority'"
placeholder="Select priority" placeholder="Select priority"
@select="selectPriority" @select="selectPriority"
@@ -79,7 +78,6 @@
:items="tags" :items="tags"
placeholder="Select tags" placeholder="Select tags"
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="Information"> <AccordionItem value="Information">
@@ -106,7 +104,6 @@ import {
} from '@/components/ui/accordion' } from '@/components/ui/accordion'
import ConversationInfo from './ConversationInfo.vue' import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue' import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
import ComboBox from '@/components/ui/combobox/ComboBox.vue' import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
@@ -118,45 +115,32 @@ const conversationStore = useConversationStore()
const usersStore = useUsersStore() const usersStore = useUsersStore()
const teamsStore = useTeamStore() const teamsStore = useTeamStore()
const tags = ref([]) const tags = ref([])
const tagIDMap = {}
onMounted(async () => { onMounted(async () => {
await Promise.all([fetchTags()]) await fetchTags()
}) })
// FIXME: Fix race.
watch( watch(
() => conversationStore.current && conversationStore.current.tags, () => conversationStore.current?.tags,
() => { () => {
handleUpsertTags() conversationStore.upsertTags({
tags: JSON.stringify(conversationStore.current.tags)
})
}, },
{ deep: true }
) )
const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id)) const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id))
const assignedTeamID = computed(() => String(conversationStore.current.assigned_team_id)) const assignedTeamID = computed(() => String(conversationStore.current.assigned_team_id))
const handleUpsertTags = () => {
let tagIDs = conversationStore.current.tags.map((tag) => {
if (tag in tagIDMap) {
return tagIDMap[tag]
}
})
conversationStore.upsertTags({
tag_ids: JSON.stringify(tagIDs)
})
}
const fetchTags = async () => { const fetchTags = async () => {
try { try {
const resp = await api.getTags() const resp = await api.getTags()
resp.data.data.forEach((item) => { resp.data.data.forEach((item) => {
tagIDMap[item.name] = item.id
tags.value.push(item.name) tags.value.push(item.name)
}) })
} catch (error) { } catch (error) {
toast({ toast({
title: 'Could not fetch tags', title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<Avatar class="size-20"> <Avatar class="size-20">
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" /> <AvatarImage :src="conversation?.contact?.avatar_url" v-if="conversation?.contact?.avatar_url" />
<AvatarFallback> <AvatarFallback>
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }} {{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback> </AvatarFallback>
@@ -13,7 +13,7 @@
<Mail class="size-3 mt-1"></Mail> <Mail class="size-3 mt-1"></Mail>
{{ conversation.contact.email }} {{ conversation.contact.email }}
</p> </p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.phone_number"> <p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.phone_number">
<Phone class="size-3 mt-1"></Phone> <Phone class="size-3 mt-1"></Phone>
{{ conversation.contact.phone_number }} {{ conversation.contact.phone_number }}
</p> </p>

View File

@@ -1,6 +1,6 @@
<template> <template>
<BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true" :show-x-axis="true" <BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true" :show-x-axis="true"
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" /> :show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" :rounded-corners="4"/>
</template> </template>
<script setup> <script setup>

View File

@@ -0,0 +1,90 @@
<template>
<div class="flex flex-wrap px-2 py-1">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
:key="action.type"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
>
<div class="flex items-center space-x-2 px-3 py-2">
<component
:is="getIcon(action.type)"
size="16"
class="text-primary group-hover:text-primary"
/>
<Tooltip>
<TooltipTrigger as-child>
<div
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
>
{{ getDisplayValue(action) }}
</div>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ getTooltip(action) }}</p>
</TooltipContent>
</Tooltip>
</div>
<button
@click.stop="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"
title="Remove action"
>
<X size="14" />
</button>
</div>
</div>
</div>
</template>
<script setup>
import { X, Users, User, MessageSquare, Tags, Flag, Send } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({
actions: {
type: Array,
required: true
},
onRemove: {
type: Function,
required: true
}
})
const getIcon = (type) =>
({
assign_team: Users,
assign_user: User,
set_status: MessageSquare,
set_priority: Flag,
send_reply: Send,
set_tags: Tags
})[type]
const getDisplayValue = (action) => {
if (action.display_value?.length) {
return action.display_value.join(', ')
}
return action.value.join(', ')
}
const getTooltip = (action) => {
switch (action.type) {
case 'assign_team':
return `Assign to team: ${getDisplayValue(action)}`
case 'assign_user':
return `Assign to user: ${getDisplayValue(action)}`
case 'set_status':
return `Set status to: ${getDisplayValue(action)}`
case 'set_priority':
return `Set priority to: ${getDisplayValue(action)}`
case 'send_reply':
return `Send reply: ${getDisplayValue(action)}`
case 'set_tags':
return `Set tags: ${getDisplayValue(action)}`
default:
return `Action: ${action.type}, Value: ${getDisplayValue(action)}`
}
}
</script>

View File

@@ -174,7 +174,7 @@ const adminNavItems = [
{ {
title: 'Conversations', title: 'Conversations',
href: '/admin/conversations', href: '/admin/conversations',
description: 'Manage tags, canned responses and statuses.', description: 'Manage tags, macros and statuses.',
children: [ children: [
{ {
title: 'Tags', title: 'Tags',
@@ -183,9 +183,9 @@ const adminNavItems = [
permissions: ['tags:manage'], permissions: ['tags:manage'],
}, },
{ {
title: 'Canned responses', title: 'Macros',
href: '/admin/conversations/canned-responses', href: '/admin/conversations/macros',
description: 'Manage canned responses.', description: 'Manage macros.',
permissions: ['tags:manage'], permissions: ['tags:manage'],
}, },
{ {
@@ -300,12 +300,12 @@ const adminNavItems = [
], ],
}, },
{ {
title: 'OpenID Connect SSO', title: 'SSO',
href: '/admin/oidc', href: '/admin/oidc',
description: 'Manage OpenID SSO configurations', description: 'Manage OpenID SSO configurations',
children: [ children: [
{ {
title: 'OpenID Connect SSO', title: 'SSO',
href: '/admin/oidc', href: '/admin/oidc',
description: 'Manage OpenID SSO configurations', description: 'Manage OpenID SSO configurations',
permissions: ['tags:manage'], permissions: ['tags:manage'],
@@ -338,7 +338,7 @@ const hasConversationOpen = computed(() => {
<template> <template>
<div class="flex flex-row justify-between h-full"> <div class="flex flex-row justify-between h-full">
<div class="flex-1"> <div class="flex-1">
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 17rem;"> <SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 16rem;">
<!-- Flex Container that holds all the sidebar components --> <!-- Flex Container that holds all the sidebar components -->
<Sidebar collapsible="icon" class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0"> <Sidebar collapsible="icon" class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0">
@@ -519,7 +519,7 @@ const hasConversationOpen = computed(() => {
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild size="md"> <SidebarMenuButton asChild>
<div> <div>
<span class="font-semibold text-2xl">Inbox</span> <span class="font-semibold text-2xl">Inbox</span>
</div> </div>

View File

@@ -1,35 +1,42 @@
<template> <template>
<div v-if="dueAt" class="flex items-center justify-center"> <div v-if="dueAt" class="flex items-center justify-center">
<span <TransitionGroup name="fade" class="animate-fade-in-down">
v-if="actualAt && isAfterDueTime" <span
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300" v-if="actualAt && isAfterDueTime"
> key="overdue"
<AlertCircle class="w-4 h-4 mr-1" /> class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
<span class="flex items-center">{{ label }} Overdue</span> >
</span> <AlertCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} Overdue</span>
</span>
<span v-else-if="actualAt && !isAfterDueTime" class="flex items-center text-xs text-green-700"> <span
<template v-if="showSLAHit"> v-else-if="actualAt && !isAfterDueTime && showSLAHit"
<CheckCircle class="w-4 h-4 mr-1" /> key="sla-hit"
<span class="flex items-center">{{ label }} SLA Hit</span> class="inline-flex items-center bg-green-50 px-1 py-1 rounded-full text-xs font-medium text-green-700 border border-green-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-green-100 animate-fade-in-down min-w-[90px]"
</template> >
</span> <CheckCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} SLA Hit</span>
</span>
<span <span
v-else-if="sla?.status === 'remaining'" v-else-if="sla?.status === 'remaining'"
class="flex items-center bg-yellow-100 p-1 rounded-lg text-xs text-yellow-700 border border-yellow-300" key="remaining"
> class="inline-flex items-center bg-yellow-50 px-1 py-1 rounded-full text-xs font-medium text-yellow-700 border border-yellow-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-yellow-100 animate-fade-in-down min-w-[90px]"
<Clock class="w-4 h-4 mr-1" /> >
<span class="flex items-center">{{ label }} {{ sla.value }}</span> <Clock class="w-3 h-3 flex-shrink-0" />
</span> <span class="flex-1 text-center">{{ label }} {{ sla.value }}</span>
</span>
<span <span
v-else-if="sla?.status === 'overdue'" v-else-if="sla?.status === 'overdue'"
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300" key="sla-overdue"
> class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
<AlertCircle class="w-4 h-4 mr-1" /> >
<span class="flex items-center">{{ label }} Overdue by {{ sla.value }}</span> <AlertCircle class="w-3 h-3 flex-shrink-0" />
</span> <span class="flex-1 text-center">{{ label }} overdue</span>
</span>
</TransitionGroup>
</div> </div>
</template> </template>

View File

@@ -18,31 +18,31 @@ export function useConversationFilters () {
label: 'Status', label: 'Status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.statusesForSelect options: cStore.statusOptions
}, },
priority_id: { priority_id: {
label: 'Priority', label: 'Priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.prioritiesForSelect options: cStore.priorityOptions
}, },
assigned_team_id: { assigned_team_id: {
label: 'Assigned team', label: 'Assigned team',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: tStore.forSelect options: tStore.options
}, },
assigned_user_id: { assigned_user_id: {
label: 'Assigned user', label: 'Assigned user',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.forSelect options: uStore.options
}, },
inbox_id: { inbox_id: {
label: 'Inbox', label: 'Inbox',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.forSelect options: iStore.options
} }
})) }))
@@ -61,25 +61,25 @@ export function useConversationFilters () {
label: 'Status', label: 'Status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.statusesForSelect options: cStore.statusOptions
}, },
priority: { priority: {
label: 'Priority', label: 'Priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.prioritiesForSelect options: cStore.priorityOptions
}, },
assigned_team: { assigned_team: {
label: 'Assigned team', label: 'Assigned team',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: tStore.forSelect options: tStore.options
}, },
assigned_user: { assigned_user: {
label: 'Assigned agent', label: 'Assigned agent',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.forSelect options: uStore.options
}, },
hours_since_created: { hours_since_created: {
label: 'Hours since created', label: 'Hours since created',
@@ -95,7 +95,7 @@ export function useConversationFilters () {
label: 'Inbox', label: 'Inbox',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.forSelect options: iStore.options
} }
})) }))
@@ -103,22 +103,22 @@ export function useConversationFilters () {
assign_team: { assign_team: {
label: 'Assign to team', label: 'Assign to team',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: tStore.forSelect options: tStore.options
}, },
assign_user: { assign_user: {
label: 'Assign to user', label: 'Assign to user',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: uStore.forSelect options: uStore.options
}, },
set_status: { set_status: {
label: 'Set status', label: 'Set status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.statusesForSelect options: cStore.statusOptionsNoSnooze
}, },
set_priority: { set_priority: {
label: 'Set priority', label: 'Set priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.prioritiesForSelect options: cStore.priorityOptions
}, },
send_private_note: { send_private_note: {
label: 'Send private note', label: 'Send private note',
@@ -131,7 +131,11 @@ export function useConversationFilters () {
set_sla: { set_sla: {
label: 'Set SLA', label: 'Set SLA',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: slaStore.forSelect options: slaStore.options
},
set_tags: {
label: 'Set tags',
type: FIELD_TYPE.TAG
} }
})) }))

View File

@@ -1,6 +1,7 @@
export const EMITTER_EVENTS = { export const EMITTER_EVENTS = {
REFRESH_LIST: 'refresh-list', REFRESH_LIST: 'refresh-list',
SHOW_TOAST: 'show-toast', SHOW_TOAST: 'show-toast',
SHOW_SOONER: 'show-sooner',
NEW_MESSAGE: 'new-message', NEW_MESSAGE: 'new-message',
SET_NESTED_COMMAND: 'set-nested-command', SET_NESTED_COMMAND: 'set-nested-command',
} }

View File

@@ -1,5 +1,6 @@
export const FIELD_TYPE = { export const FIELD_TYPE = {
SELECT: 'select', SELECT: 'select',
TAG: 'tag',
TEXT: 'text', TEXT: 'text',
NUMBER: 'number', NUMBER: 'number',
RICHTEXT: 'richtext' RICHTEXT: 'richtext'

View File

@@ -339,17 +339,30 @@ const routes = [
{ {
path: 'tags', path: 'tags',
component: () => import('@/components/admin/conversation/tags/Tags.vue'), component: () => import('@/components/admin/conversation/tags/Tags.vue'),
meta: { title: 'Conversation Tags' } meta: { title: 'Tags' }
}, },
{ {
path: 'statuses', path: 'statuses',
component: () => import('@/components/admin/conversation/status/Status.vue'), component: () => import('@/components/admin/conversation/status/Status.vue'),
meta: { title: 'Conversation Statuses' } meta: { title: 'Statuses' }
}, },
{ {
path: 'canned-responses', path: 'Macros',
component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'), component: () => import('@/components/admin/conversation/macros/Macros.vue'),
meta: { title: 'Canned Responses' } meta: { title: 'Macros' },
children: [
{
path: 'new',
component: () => import('@/components/admin/conversation/macros/CreateMacro.vue'),
meta: { title: 'Create Macro' }
},
{
path: ':id/edit',
props: true,
component: () => import('@/components/admin/conversation/macros/EditMacro.vue'),
meta: { title: 'Edit Macro' }
},
]
} }
] ]
} }

View File

@@ -13,12 +13,19 @@ export const useConversationStore = defineStore('conversation', () => {
const priorities = ref([]) const priorities = ref([])
const statuses = ref([]) const statuses = ref([])
const prioritiesForSelect = computed(() => { // Options for select fields
const priorityOptions = computed(() => {
return priorities.value.map(p => ({ label: p.name, value: p.id })) return priorities.value.map(p => ({ label: p.name, value: p.id }))
}) })
const statusesForSelect = computed(() => { const statusOptions = computed(() => {
return statuses.value.map(s => ({ label: s.name, value: s.id })) return statuses.value.map(s => ({ label: s.name, value: s.id }))
}) })
const statusOptionsNoSnooze = computed(() =>
statuses.value.filter(s => s.name !== 'Snoozed').map(s => ({
label: s.name,
value: s.id
}))
)
const sortFieldMap = { const sortFieldMap = {
oldest: { oldest: {
@@ -78,6 +85,8 @@ export const useConversationStore = defineStore('conversation', () => {
const conversation = reactive({ const conversation = reactive({
data: null, data: null,
participants: {}, participants: {},
mediaFiles: [],
macro: {},
loading: false, loading: false,
errorMessage: '' errorMessage: ''
}) })
@@ -101,6 +110,22 @@ export const useConversationStore = defineStore('conversation', () => {
clearInterval(reRenderInterval) clearInterval(reRenderInterval)
} }
function setMacro (macros) {
conversation.macro = macros
}
function removeMacroAction (action) {
conversation.macro.actions = conversation.macro.actions.filter(a => a.type !== action.type)
}
function resetMacro () {
conversation.macro = {}
}
function resetMediaFiles () {
conversation.mediaFiles = []
}
function setListStatus (status, fetch = true) { function setListStatus (status, fetch = true) {
if (conversations.status === status) return if (conversations.status === status) return
conversations.status = status conversations.status = status
@@ -193,6 +218,7 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchConversation (uuid) { async function fetchConversation (uuid) {
conversation.loading = true conversation.loading = true
resetCurrentConversation()
try { try {
const resp = await api.getConversation(uuid) const resp = await api.getConversation(uuid)
conversation.data = resp.data.data conversation.data = resp.data.data
@@ -419,7 +445,6 @@ export const useConversationStore = defineStore('conversation', () => {
} }
} }
async function upsertTags (v) { async function upsertTags (v) {
try { try {
await api.upsertTags(conversation.data.uuid, v) await api.upsertTags(conversation.data.uuid, v)
@@ -517,6 +542,8 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, { Object.assign(conversation, {
data: null, data: null,
participants: {}, participants: {},
macro: {},
mediaFiles: [],
loading: false, loading: false,
errorMessage: '' errorMessage: ''
}) })
@@ -574,11 +601,16 @@ export const useConversationStore = defineStore('conversation', () => {
fetchPriorities, fetchPriorities,
setListSortField, setListSortField,
setListStatus, setListStatus,
removeMacroAction,
setMacro,
resetMacro,
resetMediaFiles,
getListSortField, getListSortField,
getListStatus, getListStatus,
statuses, statuses,
priorities, priorities,
prioritiesForSelect, priorityOptions,
statusesForSelect statusOptionsNoSnooze,
statusOptions
} }
}) })

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useInboxStore = defineStore('inbox', () => { export const useInboxStore = defineStore('inbox', () => {
const inboxes = ref([]) const inboxes = ref([])
const emitter = useEmitter() const emitter = useEmitter()
const forSelect = computed(() => inboxes.value.map(inb => ({ const options = computed(() => inboxes.value.map(inb => ({
label: inb.name, label: inb.name,
value: String(inb.id) value: String(inb.id)
}))) })))
@@ -27,7 +27,7 @@ export const useInboxStore = defineStore('inbox', () => {
} }
return { return {
inboxes, inboxes,
forSelect, options,
fetchInboxes, fetchInboxes,
} }
}) })

View File

@@ -0,0 +1,41 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { useUserStore } from './user'
import api from '@/api'
export const useMacroStore = defineStore('macroStore', () => {
const macroList = ref([])
const emitter = useEmitter()
const userStore = useUserStore()
const macroOptions = computed(() => {
const userTeams = userStore.teams.map(team => String(team.id))
return macroList.value.filter(macro =>
macro.visibility === 'all' || userTeams.includes(macro.team_id) || String(macro.user_id) === String(userStore.userID)
).map(macro => ({
...macro,
label: macro.name,
value: String(macro.id),
}))
})
const loadMacros = async () => {
if (macroList.value.length) return
try {
const response = await api.getAllMacros()
macroList.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
return {
macroList,
macroOptions,
loadMacros,
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useSlaStore = defineStore('sla', () => { export const useSlaStore = defineStore('sla', () => {
const slas = ref([]) const slas = ref([])
const emitter = useEmitter() const emitter = useEmitter()
const forSelect = computed(() => slas.value.map(sla => ({ const options = computed(() => slas.value.map(sla => ({
label: sla.name, label: sla.name,
value: String(sla.id) value: String(sla.id)
}))) })))
@@ -27,7 +27,7 @@ export const useSlaStore = defineStore('sla', () => {
} }
return { return {
slas, slas,
forSelect, options,
fetchSlas fetchSlas
} }
}) })

View File

@@ -0,0 +1,37 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useTagStore = defineStore('tags', () => {
const tags = ref([])
const emitter = useEmitter()
const tagNames = computed(() => tags.value.map(tag => tag.name))
const tagOptions = computed(() => tags.value.map(tag => ({
label: tag.name,
value: String(tag.id),
})))
const fetchTags = async () => {
if (tags.value.length) return
try {
const response = await api.getTags()
tags.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
return {
tags,
tagOptions,
tagNames,
fetchTags,
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useTeamStore = defineStore('team', () => { export const useTeamStore = defineStore('team', () => {
const teams = ref([]) const teams = ref([])
const emitter = useEmitter() const emitter = useEmitter()
const forSelect = computed(() => teams.value.map(team => ({ const options = computed(() => teams.value.map(team => ({
label: team.name, label: team.name,
value: String(team.id), value: String(team.id),
emoji: team.emoji, emoji: team.emoji,
@@ -28,7 +28,7 @@ export const useTeamStore = defineStore('team', () => {
} }
return { return {
teams, teams,
forSelect, options,
fetchTeams, fetchTeams,
} }
}) })

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useUsersStore = defineStore('users', () => { export const useUsersStore = defineStore('users', () => {
const users = ref([]) const users = ref([])
const emitter = useEmitter() const emitter = useEmitter()
const forSelect = computed(() => users.value.map(user => ({ const options = computed(() => users.value.map(user => ({
label: user.first_name + ' ' + user.last_name, label: user.first_name + ' ' + user.last_name,
value: String(user.id), value: String(user.id),
avatar_url: user.avatar_url, avatar_url: user.avatar_url,
@@ -28,7 +28,7 @@ export const useUsersStore = defineStore('users', () => {
} }
return { return {
users, users,
forSelect, options,
fetchUsers, fetchUsers,
} }
}) })

View File

@@ -4,6 +4,16 @@ import Admin from '@/components/admin/AdminPage.vue'
<template> <template>
<Admin class="page-content"> <Admin class="page-content">
<router-view></router-view> <main class="p-6 lg:p-8">
<div class="max-w-6xl mx-auto">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="p-6 sm:p-8">
<div class="space-y-6">
<router-view></router-view>
</div>
</div>
</div>
</div>
</main>
</Admin> </Admin>
</template> </template>

View File

@@ -1,21 +1,17 @@
<template> <template>
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel"> <div class="flex">
<ResizablePanel :min-size="23" :default-size="23" :max-size="40"> <div class="border-r w-[380px]">
<ConversationList /> <ConversationList />
</ResizablePanel> </div>
<ResizableHandle /> <div class="border-r flex-1">
<ResizablePanel> <Conversation v-if="conversationStore.current"></Conversation>
<div class="border-r"> <ConversationPlaceholder v-else></ConversationPlaceholder>
<Conversation v-if="conversationStore.current"></Conversation> </div>
<ConversationPlaceholder v-else></ConversationPlaceholder> </div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</template> </template>
<script setup> <script setup>
import { watch, onUnmounted, onMounted } from 'vue' import { watch, onUnmounted, onMounted } from 'vue'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import ConversationList from '@/components/conversation/list/ConversationList.vue' import ConversationList from '@/components/conversation/list/ConversationList.vue'
import Conversation from '@/components/conversation/Conversation.vue' import Conversation from '@/components/conversation/Conversation.vue'
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue' import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'

View File

@@ -77,7 +77,7 @@ const stopRealtimeUpdates = () => {
const getDashboardData = () => { const getDashboardData = () => {
isLoading.value = true isLoading.value = true
Promise.all([getCardStats(), getDashboardCharts()]) Promise.allSettled([getCardStats(), getDashboardCharts()])
.finally(() => { .finally(() => {
isLoading.value = false isLoading.value = false
}) })

View File

@@ -24,7 +24,7 @@ module.exports = {
}, },
extend: { extend: {
fontFamily: { fontFamily: {
inter: ['Inter', 'Helvetica Neue', 'sans-serif'], jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
}, },
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
@@ -84,12 +84,23 @@ module.exports = {
from: { height: 'var(--radix-collapsible-content-height)' }, from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 }, to: { height: 0 },
}, },
'fade-in-down': {
'0%': {
opacity: '0',
transform: 'translateY(-3px)'
},
'100%': {
opacity: '1',
transform: 'translateY(0)'
},
}
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
'collapsible-down': 'collapsible-down 0.2s ease-in-out', 'collapsible-down': 'collapsible-down 0.2s ease-in-out',
'collapsible-up': 'collapsible-up 0.2s ease-in-out', 'collapsible-up': 'collapsible-up 0.2s ease-in-out',
'fade-in-down': 'fade-in-down 0.3s ease-out'
}, },
}, },
}, },

View File

@@ -61,11 +61,11 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
has, err := e.enforcer.HasPolicy(userID, permObj, permAct) has, err := e.enforcer.HasPolicy(userID, permObj, permAct)
if err != nil { if err != nil {
return fmt.Errorf("failed to check policy: %v", err) return fmt.Errorf("failed to check casbin policy: %v", err)
} }
if !has { if !has {
if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil { if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
return fmt.Errorf("failed to add policy: %v", err) return fmt.Errorf("failed to add casbin policy: %v", err)
} }
} }
} }

View File

@@ -24,8 +24,8 @@ const (
// Tags // Tags
PermTagsManage = "tags:manage" PermTagsManage = "tags:manage"
// Canned Responses // Macros
PermCannedResponsesManage = "canned_responses:manage" PermMacrosManage = "macros:manage"
// Users // Users
PermUsersManage = "users:manage" PermUsersManage = "users:manage"
@@ -80,7 +80,7 @@ var validPermissions = map[string]struct{}{
PermViewManage: {}, PermViewManage: {},
PermStatusManage: {}, PermStatusManage: {},
PermTagsManage: {}, PermTagsManage: {},
PermCannedResponsesManage: {}, PermMacrosManage: {},
PermUsersManage: {}, PermUsersManage: {},
PermTeamsManage: {}, PermTeamsManage: {},
PermAutomationsManage: {}, PermAutomationsManage: {},

View File

@@ -15,7 +15,6 @@ import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq" "github.com/lib/pq"
@@ -25,7 +24,6 @@ import (
var ( var (
//go:embed queries.sql //go:embed queries.sql
efs embed.FS efs embed.FS
// MaxQueueSize defines the maximum size of the task queues. // MaxQueueSize defines the maximum size of the task queues.
MaxQueueSize = 5000 MaxQueueSize = 5000
) )
@@ -51,9 +49,7 @@ type Engine struct {
rulesMu sync.RWMutex rulesMu sync.RWMutex
q queries q queries
lo *logf.Logger lo *logf.Logger
conversationStore ConversationStore conversationStore conversationStore
slaStore SLAStore
systemUser umodels.User
taskQueue chan ConversationTask taskQueue chan ConversationTask
closed bool closed bool
closedMu sync.RWMutex closedMu sync.RWMutex
@@ -65,20 +61,10 @@ type Opts struct {
Lo *logf.Logger Lo *logf.Logger
} }
type ConversationStore interface { type conversationStore interface {
GetConversation(id int, uuid string) (cmodels.Conversation, error) ApplyAction(action models.RuleAction, conversation cmodels.Conversation, user umodels.User) error
GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error) GetConversation(teamID int, uuid string) (cmodels.Conversation, error)
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error GetConversationsCreatedAfter(time.Time) ([]cmodels.Conversation, error)
UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error
UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error
UpdateConversationPriority(uuid string, priorityID int, priority string, actor umodels.User) error
SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error
SendReply(media []mmodels.Media, senderID int, conversationUUID, content, meta string) error
RecordSLASet(conversationUUID string, actor umodels.User) error
}
type SLAStore interface {
ApplySLA(conversationID, slaID int) error
} }
type queries struct { type queries struct {
@@ -94,13 +80,12 @@ type queries struct {
} }
// New initializes a new Engine. // New initializes a new Engine.
func New(systemUser umodels.User, opt Opts) (*Engine, error) { func New(opt Opts) (*Engine, error) {
var ( var (
q queries q queries
e = &Engine{ e = &Engine{
systemUser: systemUser, lo: opt.Lo,
lo: opt.Lo, taskQueue: make(chan ConversationTask, MaxQueueSize),
taskQueue: make(chan ConversationTask, MaxQueueSize),
} }
) )
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
@@ -112,9 +97,8 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
} }
// SetConversationStore sets conversations store. // SetConversationStore sets conversations store.
func (e *Engine) SetConversationStore(store ConversationStore, slaStore SLAStore) { func (e *Engine) SetConversationStore(store conversationStore) {
e.conversationStore = store e.conversationStore = store
e.slaStore = slaStore
} }
// ReloadRules reloads automation rules from DB. // ReloadRules reloads automation rules from DB.
@@ -277,43 +261,6 @@ func (e *Engine) UpdateRuleExecutionMode(ruleType, mode string) error {
return nil return nil
} }
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
e.evalConversationRules(rules, conversation)
}
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
e.evalConversationRules(rules, conversation)
}
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
e.lo.Error("error fetching conversations for time trigger", "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)
}
}
// EvaluateNewConversationRules enqueues a new conversation for rule evaluation. // EvaluateNewConversationRules enqueues a new conversation for rule evaluation.
func (e *Engine) EvaluateNewConversationRules(conversationUUID string) { func (e *Engine) EvaluateNewConversationRules(conversationUUID string) {
e.closedMu.RLock() e.closedMu.RLock()
@@ -355,6 +302,43 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT
} }
} }
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
e.evalConversationRules(rules, conversation)
}
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
e.evalConversationRules(rules, conversation)
}
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
e.lo.Error("error fetching conversations for time trigger", "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)
}
}
// queryRules fetches automation rules from the database. // queryRules fetches automation rules from the database.
func (e *Engine) queryRules() []models.Rule { func (e *Engine) queryRules() []models.Rule {
var ( var (

View File

@@ -8,7 +8,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"
mmodels "github.com/abhinavxd/libredesk/internal/media/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
) )
// evalConversationRules evaluates a list of rules against a given conversation. // evalConversationRules evaluates a list of rules against a given conversation.
@@ -39,7 +39,7 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
if evaluateFinalResult(groupEvalResults, rule.GroupOperator) { if evaluateFinalResult(groupEvalResults, rule.GroupOperator) {
e.lo.Debug("rule evaluation successful executing actions", "conversation_uuid", conversation.UUID) e.lo.Debug("rule evaluation successful executing actions", "conversation_uuid", conversation.UUID)
for _, action := range rule.Actions { for _, action := range rule.Actions {
e.applyAction(action, conversation) e.conversationStore.ApplyAction(action, conversation, umodels.User{})
} }
if rule.ExecutionMode == models.ExecutionModeFirstMatch { if rule.ExecutionMode == models.ExecutionModeFirstMatch {
e.lo.Debug("first match rule execution mode, breaking out of rule evaluation", "conversation_uuid", conversation.UUID) e.lo.Debug("first match rule execution mode, breaking out of rule evaluation", "conversation_uuid", conversation.UUID)
@@ -138,7 +138,6 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
return false return false
} }
// Case sensitivity handling
if !rule.CaseSensitiveMatch { if !rule.CaseSensitiveMatch {
valueToCompare = strings.ToLower(valueToCompare) valueToCompare = strings.ToLower(valueToCompare)
rule.Value = strings.ToLower(rule.Value) rule.Value = strings.ToLower(rule.Value)
@@ -210,55 +209,3 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
e.lo.Debug("conversation automation rule status", "has_met", conditionMet, "conversation_uuid", conversation.UUID) e.lo.Debug("conversation automation rule status", "has_met", conditionMet, "conversation_uuid", conversation.UUID)
return conditionMet return conditionMet
} }
// applyAction applies a specific action to the given conversation.
func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
switch action.Type {
case models.ActionAssignTeam:
e.lo.Debug("executing assign team action", "value", action.Action, "conversation_uuid", conversation.UUID)
teamID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationTeamAssignee(conversation.UUID, teamID, e.systemUser); err != nil {
return err
}
case models.ActionAssignUser:
e.lo.Debug("executing assign user action", "value", action.Action, "conversation_uuid", conversation.UUID)
agentID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, agentID, e.systemUser); err != nil {
return err
}
case models.ActionSetPriority:
e.lo.Debug("executing set priority action", "value", action.Action, "conversation_uuid", conversation.UUID)
priorityID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationPriority(conversation.UUID, priorityID, "", e.systemUser); err != nil {
return err
}
case models.ActionSetStatus:
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
statusID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, statusID, "", "", e.systemUser); err != nil {
return err
}
case models.ActionSendPrivateNote:
e.lo.Debug("executing send private note action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.SendPrivateNote([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action); err != nil {
return err
}
case models.ActionReply:
e.lo.Debug("executing reply action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.SendReply([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action, "" /**meta**/); err != nil {
return err
}
case models.ActionSetSLA:
e.lo.Debug("executing SLA action", "value", action.Action, "conversation_uuid", conversation.UUID)
slaID, _ := strconv.Atoi(action.Action)
if err := e.slaStore.ApplySLA(conversation.ID, slaID); err != nil {
return err
}
if err := e.conversationStore.RecordSLASet(conversation.UUID, e.systemUser); err != nil {
return err
}
default:
return fmt.Errorf("unrecognized rule action: %s", action.Type)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"time" "time"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/lib/pq" "github.com/lib/pq"
) )
@@ -15,6 +16,7 @@ const (
ActionSendPrivateNote = "send_private_note" ActionSendPrivateNote = "send_private_note"
ActionReply = "send_reply" ActionReply = "send_reply"
ActionSetSLA = "set_sla" ActionSetSLA = "set_sla"
ActionSetTags = "set_tags"
OperatorAnd = "AND" OperatorAnd = "AND"
OperatorOR = "OR" OperatorOR = "OR"
@@ -52,6 +54,17 @@ const (
ExecutionModeFirstMatch = "first_match" ExecutionModeFirstMatch = "first_match"
) )
// ActionPermissions maps actions to permissions
var ActionPermissions = map[string]string{
ActionAssignTeam: authzModels.PermConversationsUpdateTeamAssignee,
ActionAssignUser: authzModels.PermConversationsUpdateUserAssignee,
ActionSetStatus: authzModels.PermConversationsUpdateStatus,
ActionSetPriority: authzModels.PermConversationsUpdatePriority,
ActionSendPrivateNote: authzModels.PermMessagesWrite,
ActionReply: authzModels.PermMessagesWrite,
ActionSetTags: authzModels.PermConversationsUpdateTags,
}
// RuleRecord represents a rule record in the database // RuleRecord represents a rule record in the database
type RuleRecord struct { type RuleRecord struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
@@ -89,6 +102,7 @@ type RuleDetail struct {
} }
type RuleAction struct { type RuleAction struct {
Type string `json:"type" db:"type"` Type string `json:"type" db:"type"`
Action string `json:"value" db:"value"` Value []string `json:"value" db:"value"`
DisplayValue []string `json:"display_value" db:"-"`
} }

View File

@@ -1,97 +0,0 @@
// Package cannedresp provides functionality to manage canned responses in the system.
package cannedresp
import (
"embed"
"github.com/abhinavxd/libredesk/internal/cannedresp/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager handles the operations related to canned responses.
type Manager struct {
q queries
lo *logf.Logger
}
// Opts holds the options for creating a new Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
type queries struct {
GetAll *sqlx.Stmt `query:"get-all"`
Create *sqlx.Stmt `query:"create"`
Update *sqlx.Stmt `query:"update"`
Delete *sqlx.Stmt `query:"delete"`
}
// New initializes a new Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
}, nil
}
// GetAll retrieves all canned responses.
func (t *Manager) GetAll() ([]models.CannedResponse, error) {
var c = make([]models.CannedResponse, 0)
if err := t.q.GetAll.Select(&c); err != nil {
t.lo.Error("error fetching canned responses", "error", err)
return c, envelope.NewError(envelope.GeneralError, "Error fetching canned responses", nil)
}
return c, nil
}
// Create adds a new canned response.
func (t *Manager) Create(title, content string) error {
if _, err := t.q.Create.Exec(title, content); err != nil {
t.lo.Error("error creating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating canned response", nil)
}
return nil
}
// Update modifies an existing canned response.
func (t *Manager) Update(id int, title, content string) error {
result, err := t.q.Update.Exec(id, title, content)
if err != nil {
t.lo.Error("error updating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}
// Delete removes a canned response by ID.
func (t *Manager) Delete(id int) error {
result, err := t.q.Delete.Exec(id)
if err != nil {
t.lo.Error("error deleting canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}

View File

@@ -1,11 +0,0 @@
package models
import "time"
type CannedResponse struct {
ID string `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
}

View File

@@ -1,13 +0,0 @@
-- name: get-all
SELECT id, title, content, created_at, updated_at FROM canned_responses order by updated_at desc;
-- name: create
INSERT INTO canned_responses (title, content)
VALUES ($1, $2);
-- name: update
UPDATE canned_responses
SET title = $2, content = $3, updated_at = now() where id = $1;
-- name: delete
DELETE FROM canned_responses WHERE id = $1;

View File

@@ -9,10 +9,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strconv"
"sync" "sync"
"time" "time"
"github.com/abhinavxd/libredesk/internal/automation" "github.com/abhinavxd/libredesk/internal/automation"
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/conversation/models"
pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models" pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models"
smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models" smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
@@ -21,6 +23,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox"
mmodels "github.com/abhinavxd/libredesk/internal/media/models" mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
tmodels "github.com/abhinavxd/libredesk/internal/team/models" tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/template"
umodels "github.com/abhinavxd/libredesk/internal/user/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
@@ -52,6 +55,7 @@ type Manager struct {
mediaStore mediaStore mediaStore mediaStore
statusStore statusStore statusStore statusStore
priorityStore priorityStore priorityStore priorityStore
slaStore slaStore
notifier *notifier.Service notifier *notifier.Service
lo *logf.Logger lo *logf.Logger
db *sqlx.DB db *sqlx.DB
@@ -67,6 +71,10 @@ type Manager struct {
wg sync.WaitGroup wg sync.WaitGroup
} }
type slaStore interface {
ApplySLA(conversationID, slaID int) (slaModels.SLAPolicy, error)
}
type statusStore interface { type statusStore interface {
Get(int) (smodels.Status, error) Get(int) (smodels.Status, error)
} }
@@ -82,6 +90,7 @@ type teamStore interface {
type userStore interface { type userStore interface {
Get(int) (umodels.User, error) Get(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error CreateContact(user *umodels.User) error
} }
@@ -110,6 +119,7 @@ func New(
wsHub *ws.Hub, wsHub *ws.Hub,
i18n *i18n.I18n, i18n *i18n.I18n,
notifier *notifier.Service, notifier *notifier.Service,
sla slaStore,
status statusStore, status statusStore,
priority priorityStore, priority priorityStore,
inboxStore inboxStore, inboxStore inboxStore,
@@ -134,6 +144,7 @@ func New(
userStore: userStore, userStore: userStore,
teamStore: teamStore, teamStore: teamStore,
mediaStore: mediaStore, mediaStore: mediaStore,
slaStore: sla,
statusStore: status, statusStore: status,
priorityStore: priority, priorityStore: priority,
automation: automation, automation: automation,
@@ -546,7 +557,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
if err := c.RecordStatusChange(status, uuid, actor); err != nil { if err := c.RecordStatusChange(status, uuid, actor); err != nil {
return envelope.NewError(envelope.GeneralError, "Error recording status change", nil) return envelope.NewError(envelope.GeneralError, "Error recording status change", nil)
} }
// Send WS update to all subscribers. // Send WS update to all subscribers.
c.BroadcastConversationPropertyUpdate(uuid, "status", status) c.BroadcastConversationPropertyUpdate(uuid, "status", status)
return nil return nil
@@ -626,8 +637,8 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
} }
// UpsertConversationTags upserts the tags associated with a conversation. // UpsertConversationTags upserts the tags associated with a conversation.
func (t *Manager) UpsertConversationTags(uuid string, tagIDs []int) error { func (t *Manager) UpsertConversationTags(uuid string, tagNames []string) error {
if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagIDs)); err != nil { if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
t.lo.Error("error upserting conversation tags", "error", err) t.lo.Error("error upserting conversation tags", "error", err)
return envelope.NewError(envelope.GeneralError, "Error upserting tags", nil) return envelope.NewError(envelope.GeneralError, "Error upserting tags", nil)
} }
@@ -747,3 +758,76 @@ func (m *Manager) UnassignOpen(userID int) error {
} }
return nil return nil
} }
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error {
if len(action.Value) == 0 {
m.lo.Warn("no value provided for action", "action", action.Type, "conversation_uuid", conversation.UUID)
return fmt.Errorf("no value provided for action %s", action.Type)
}
// If user is not provided, use system user.
if user.ID == 0 {
systemUser, err := m.userStore.GetSystemUser()
if err != nil {
return fmt.Errorf("could not apply %s action. could not fetch system user: %w", action.Type, err)
}
user = systemUser
}
switch action.Type {
case amodels.ActionAssignTeam:
m.lo.Debug("executing assign team action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
teamID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationTeamAssignee(conversation.UUID, teamID, user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionAssignUser:
m.lo.Debug("executing assign user action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
agentID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationUserAssignee(conversation.UUID, agentID, user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetPriority:
m.lo.Debug("executing set priority action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
priorityID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationPriority(conversation.UUID, priorityID, "", user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetStatus:
m.lo.Debug("executing set status action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
statusID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationStatus(conversation.UUID, statusID, "", "", user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSendPrivateNote:
m.lo.Debug("executing send private note action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
if err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0]); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionReply:
m.lo.Debug("executing reply action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
if err := m.SendReply([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0], ""); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetSLA:
m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
slaID, _ := strconv.Atoi(action.Value[0])
slaPolicy, err := m.slaStore.ApplySLA(conversation.ID, slaID)
if err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
if err := m.RecordSLASet(conversation.UUID, slaPolicy.Name, user); err != nil {
m.lo.Error("error recording SLA set activity", "error", err)
}
case amodels.ActionSetTags:
m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID)
if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
default:
return fmt.Errorf("unrecognized action type %s", action.Type)
}
return nil
}

View File

@@ -376,8 +376,8 @@ func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umod
} }
// RecordSLASet records an activity for an SLA set. // RecordSLASet records an activity for an SLA set.
func (m *Manager) RecordSLASet(conversationUUID string, actor umodels.User) error { func (m *Manager) RecordSLASet(conversationUUID string, slaName string, actor umodels.User) error {
return m.InsertConversationActivity(ActivitySLASet, conversationUUID, "", actor) return m.InsertConversationActivity(ActivitySLASet, conversationUUID, slaName, actor)
} }
// InsertConversationActivity inserts an activity message. // InsertConversationActivity inserts an activity message.
@@ -425,13 +425,13 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
case ActivitySelfAssign: case ActivitySelfAssign:
content = fmt.Sprintf("%s self-assigned this conversation", actorName) content = fmt.Sprintf("%s self-assigned this conversation", actorName)
case ActivityPriorityChange: case ActivityPriorityChange:
content = fmt.Sprintf("%s changed priority to %s", actorName, newValue) content = fmt.Sprintf("%s set priority to %s", actorName, newValue)
case ActivityStatusChange: case ActivityStatusChange:
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue) content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
case ActivityTagChange: case ActivityTagChange:
content = fmt.Sprintf("%s added tags %s", actorName, newValue) content = fmt.Sprintf("%s added tags %s", actorName, newValue)
case ActivitySLASet: case ActivitySLASet:
content = fmt.Sprintf("%s set an SLA to this conversation", actorName) content = fmt.Sprintf("%s set %s SLA", actorName, newValue)
default: default:
return "", fmt.Errorf("invalid activity type %s", activityType) return "", fmt.Errorf("invalid activity type %s", activityType)
} }

View File

@@ -81,7 +81,8 @@ SELECT
ct.first_name as "contact.first_name", ct.first_name as "contact.first_name",
ct.last_name as "contact.last_name", ct.last_name as "contact.last_name",
ct.email as "contact.email", ct.email as "contact.email",
ct.avatar_url as "contact.avatar_url" ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number"
FROM conversations c FROM conversations c
JOIN users ct ON c.contact_id = ct.id JOIN users ct ON c.contact_id = ct.id
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
@@ -320,13 +321,16 @@ WITH conversation_id AS (
), ),
inserted AS ( inserted AS (
INSERT INTO conversation_tags (conversation_id, tag_id) INSERT INTO conversation_tags (conversation_id, tag_id)
SELECT conversation_id.id, unnest($2::int[]) SELECT conversation_id.id, t.id
FROM conversation_id FROM conversation_id, tags t
WHERE t.name = ANY($2::text[])
ON CONFLICT (conversation_id, tag_id) DO UPDATE SET tag_id = EXCLUDED.tag_id ON CONFLICT (conversation_id, tag_id) DO UPDATE SET tag_id = EXCLUDED.tag_id
) )
DELETE FROM conversation_tags DELETE FROM conversation_tags
WHERE conversation_id = (SELECT id FROM conversation_id) WHERE conversation_id = (SELECT id FROM conversation_id)
AND tag_id NOT IN (SELECT unnest($2::int[])); AND tag_id NOT IN (
SELECT id FROM tags WHERE name = ANY($2::text[])
);
-- name: get-to-address -- name: get-to-address
SELECT cc.identifier SELECT cc.identifier

117
internal/macro/macro.go Normal file
View File

@@ -0,0 +1,117 @@
// Package macro provides functionality for managing templated text responses and actions.
package macro
import (
"embed"
"encoding/json"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager is the macro manager.
type Manager struct {
q queries
lo *logf.Logger
}
// Predefined queries.
type queries struct {
Get *sqlx.Stmt `query:"get"`
GetAll *sqlx.Stmt `query:"get-all"`
Create *sqlx.Stmt `query:"create"`
Update *sqlx.Stmt `query:"update"`
Delete *sqlx.Stmt `query:"delete"`
IncUsageCount *sqlx.Stmt `query:"increment-usage-count"`
}
// Opts contains the dependencies for the macro manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
// New initializes a macro manager.
func New(opts Opts) (*Manager, error) {
var q queries
err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs)
if err != nil {
return nil, err
}
return &Manager{q: q, lo: opts.Lo}, nil
}
// Get returns a macro by ID.
func (m *Manager) Get(id int) (models.Macro, error) {
macro := models.Macro{}
err := m.q.Get.Get(&macro, id)
if err != nil {
m.lo.Error("error getting macro", "error", err)
return macro, envelope.NewError(envelope.GeneralError, "Error getting macro", nil)
}
return macro, nil
}
// Create adds a new macro.
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions)
if err != nil {
m.lo.Error("error creating macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating macro", nil)
}
return nil
}
// Update modifies an existing macro.
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions)
if err != nil {
m.lo.Error("error updating macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating macro", nil)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
}
return nil
}
// GetAll returns all macros.
func (m *Manager) GetAll() ([]models.Macro, error) {
macros := make([]models.Macro, 0)
err := m.q.GetAll.Select(&macros)
if err != nil {
m.lo.Error("error fetching macros", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching macros", nil)
}
return macros, nil
}
// Delete deletes a macro by ID.
func (m *Manager) Delete(id int) error {
result, err := m.q.Delete.Exec(id)
if err != nil {
m.lo.Error("error deleting macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting macro", nil)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
}
return nil
}
// IncrementUsageCount increments the usage count of a macro.
func (m *Manager) IncrementUsageCount(id int) error {
if _, err := m.q.IncUsageCount.Exec(id); err != nil {
m.lo.Error("error incrementing usage count", "error", err)
return envelope.NewError(envelope.GeneralError, "Error incrementing macro usage count", nil)
}
return nil
}

View File

@@ -0,0 +1,19 @@
package models
import (
"encoding/json"
"time"
)
type Macro struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
MessageContent string `db:"message_content" json:"message_content"`
Visibility string `db:"visibility" json:"visibility"`
UserID *int `db:"user_id" json:"user_id,string"`
TeamID *int `db:"team_id" json:"team_id,string"`
UsageCount int `db:"usage_count" json:"usage_count"`
Actions json.RawMessage `db:"actions" json:"actions"`
}

View File

@@ -0,0 +1,67 @@
-- name: get
SELECT
id,
name,
message_content,
created_at,
updated_at,
visibility,
user_id,
team_id,
actions,
usage_count
FROM
macros
WHERE
id = $1;
-- name: get-all
SELECT
id,
name,
message_content,
created_at,
updated_at,
visibility,
user_id,
team_id,
actions,
usage_count
FROM
macros
ORDER BY
updated_at DESC;
-- name: create
INSERT INTO
macros (name, message_content, user_id, team_id, visibility, actions)
VALUES
($1, $2, $3, $4, $5, $6);
-- name: update
UPDATE
macros
SET
name = $2,
message_content = $3,
user_id = $4,
team_id = $5,
visibility = $6,
actions = $7,
updated_at = NOW()
WHERE
id = $1;
-- name: delete
DELETE FROM
macros
WHERE
id = $1;
-- name: increment-usage-count
UPDATE
macros
SET
usage_count = usage_count + 1
WHERE
id = $1;

View File

@@ -141,10 +141,10 @@ func (m *Manager) Update(id int, name, description, firstResponseDuration, resol
} }
// ApplySLA associates an SLA policy with a conversation. // ApplySLA associates an SLA policy with a conversation.
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error { func (m *Manager) ApplySLA(conversationID, slaPolicyID int) (models.SLAPolicy, error) {
sla, err := m.Get(slaPolicyID) sla, err := m.Get(slaPolicyID)
if err != nil { if err != nil {
return err return sla, err
} }
for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} { for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
if t == SLATypeFirstResponse && sla.FirstResponseTime == "" { if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
@@ -155,10 +155,10 @@ func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
} }
if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) { if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
m.lo.Error("error applying SLA to conversation", "error", err) m.lo.Error("error applying SLA to conversation", "error", err)
return err return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA to conversation", nil)
} }
} }
return nil return sla, nil
} }
// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking). // Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).

View File

@@ -15,7 +15,7 @@ type Team struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"` Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type"` ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
Timezone string `db:"timezone" json:"timezone,omitempty"` Timezone string `db:"timezone" json:"timezone,omitempty"`
BusinessHoursID int `db:"business_hours_id" json:"business_hours_id,omitempty"` BusinessHoursID int `db:"business_hours_id" json:"business_hours_id,omitempty"`
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/lib/pq" "github.com/lib/pq"
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
) )
type User struct { type User struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -15,6 +16,7 @@ type User struct {
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email,omitempty"` Email null.String `db:"email" json:"email,omitempty"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Disabled bool `db:"disabled" json:"disabled"` Disabled bool `db:"disabled" json:"disabled"`
Password string `db:"password" json:"-"` Password string `db:"password" json:"-"`

View File

@@ -11,7 +11,7 @@ DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact'); DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact');
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai'); DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match'); DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "visibility" AS ENUM ('all', 'team', 'user');
DROP TABLE IF EXISTS conversation_slas CASCADE; DROP TABLE IF EXISTS conversation_slas CASCADE;
CREATE TABLE conversation_slas ( CREATE TABLE conversation_slas (
@@ -163,15 +163,20 @@ CREATE TABLE automation_rules (
CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300) CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300)
); );
DROP TABLE IF EXISTS canned_responses CASCADE; DROP TABLE IF EXISTS macros CASCADE;
CREATE TABLE canned_responses ( CREATE TABLE macros (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
title TEXT NOT NULL, title TEXT NOT NULL,
"content" TEXT NOT NULL, actions JSONB DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 140), visibility macro_visibility NOT NULL,
CONSTRAINT constraint_canned_responses_on_content CHECK (length("content") <= 5000) message_content TEXT NOT NULL,
user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
team_id INT REFERENCES teams(id) ON DELETE CASCADE ON UPDATE CASCADE,
usage_count INT DEFAULT 0 NOT NULL,
CONSTRAINT title_length CHECK (length(title) <= 255),
CONSTRAINT message_content_length CHECK (length(message_content) <= 1000)
); );
DROP TABLE IF EXISTS conversation_participants CASCADE; DROP TABLE IF EXISTS conversation_participants CASCADE;
@@ -252,10 +257,11 @@ CREATE INDEX index_settings_on_key ON settings USING btree ("key");
DROP TABLE IF EXISTS tags CASCADE; DROP TABLE IF EXISTS tags CASCADE;
CREATE TABLE tags ( CREATE TABLE tags (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
"name" TEXT NOT NULL, CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name"),
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name") CONSTRAINT constraint_tags_on_name CHECK (length("name") <= 140)
); );
DROP TABLE IF EXISTS team_members CASCADE; DROP TABLE IF EXISTS team_members CASCADE;
@@ -464,5 +470,5 @@ VALUES
( (
'Admin', 'Admin',
'Role for users who have complete access to everything.', 'Role for users who have complete access to everything.',
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,canned_responses:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}' '{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
); );