mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	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:
		@@ -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.    |
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -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
									
								
							
							
						
						
									
										306
									
								
								cmd/macro.go
									
									
									
									
									
										Normal 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(¯o, "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(¯o, "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
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										15
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -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"),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
@@ -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",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -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))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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%;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
					 | 
				
			||||||
@@ -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>
 | 
					 | 
				
			||||||
@@ -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>
 | 
					 | 
				
			||||||
@@ -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.'
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
							
								
								
									
										195
									
								
								frontend/src/components/admin/conversation/macros/MacroForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								frontend/src/components/admin/conversation/macros/MacroForm.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										65
									
								
								frontend/src/components/admin/conversation/macros/Macros.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								frontend/src/components/admin/conversation/macros/Macros.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -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
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -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(),
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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' },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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([])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										331
									
								
								frontend/src/components/command/CommandBox.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										331
									
								
								frontend/src/components/command/CommandBox.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -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>
 | 
					 | 
				
			||||||
							
								
								
									
										186
									
								
								frontend/src/components/common/ActionBuilder.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								frontend/src/components/common/ActionBuilder.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -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>
 | 
					 | 
				
			||||||
							
								
								
									
										207
									
								
								frontend/src/components/common/FilterBuilder.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								frontend/src/components/common/FilterBuilder.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -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>
 | 
					 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										90
									
								
								frontend/src/components/macro/MacroActionsPreview.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								frontend/src/components/macro/MacroActionsPreview.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -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'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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' }
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
							
								
								
									
										41
									
								
								frontend/src/stores/macro.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								frontend/src/stores/macro.js
									
									
									
									
									
										Normal 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,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -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
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								frontend/src/stores/tag.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/stores/tag.js
									
									
									
									
									
										Normal 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,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -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,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -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,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:               {},
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 (
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:"-"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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;
 | 
					 | 
				
			||||||
@@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
									
								
							
							
						
						
									
										117
									
								
								internal/macro/macro.go
									
									
									
									
									
										Normal 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(¯o, 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(¯os)
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										19
									
								
								internal/macro/models/models.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								internal/macro/models/models.go
									
									
									
									
									
										Normal 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"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										67
									
								
								internal/macro/queries.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								internal/macro/queries.sql
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
@@ -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).
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:"-"`
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										32
									
								
								schema.sql
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								schema.sql
									
									
									
									
									
								
							@@ -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}'
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
		Reference in New Issue
	
	Block a user