mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			54 Commits
		
	
	
		
			fix/imap-i
			...
			feat/allow
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					afeec39b59 | ||
| 
						 | 
					fb2a08ec1a | ||
| 
						 | 
					7f2df0082c | ||
| 
						 | 
					6c523ac447 | ||
| 
						 | 
					02fc57c35a | ||
| 
						 | 
					cd0a357695 | ||
| 
						 | 
					2dc751e602 | ||
| 
						 | 
					8bc0cce993 | ||
| 
						 | 
					f6e2fc1956 | ||
| 
						 | 
					5fe5ac5882 | ||
| 
						 | 
					975577555d | ||
| 
						 | 
					f43acb77a1 | ||
| 
						 | 
					331c84fa56 | ||
| 
						 | 
					9314efb9d9 | ||
| 
						 | 
					5c8481af97 | ||
| 
						 | 
					d9bc4d1c0d | ||
| 
						 | 
					087c8ad491 | ||
| 
						 | 
					65cac843cb | ||
| 
						 | 
					23b0481f24 | ||
| 
						 | 
					9a651702ce | ||
| 
						 | 
					a0203f882e | ||
| 
						 | 
					75425ca0dd | ||
| 
						 | 
					c2849fa63d | ||
| 
						 | 
					b20c7845ac | ||
| 
						 | 
					38a5b25b1f | ||
| 
						 | 
					9dce155ebc | ||
| 
						 | 
					314341b40d | ||
| 
						 | 
					1f6e3322aa | ||
| 
						 | 
					102ba99b3c | ||
| 
						 | 
					8285575f1c | ||
| 
						 | 
					01d3b590a9 | ||
| 
						 | 
					210e0de1ae | ||
| 
						 | 
					1f8fdf2ef6 | ||
| 
						 | 
					696e4780ac | ||
| 
						 | 
					3998798e54 | ||
| 
						 | 
					70b5da29e1 | ||
| 
						 | 
					88ef5d26db | ||
| 
						 | 
					54bad59392 | ||
| 
						 | 
					506bb91e20 | ||
| 
						 | 
					d1478e1971 | ||
| 
						 | 
					5583b472f7 | ||
| 
						 | 
					b715483260 | ||
| 
						 | 
					8ce0464603 | ||
| 
						 | 
					a84ed1ed32 | ||
| 
						 | 
					7426a09478 | ||
| 
						 | 
					8ad2f078ac | ||
| 
						 | 
					9226063db3 | ||
| 
						 | 
					a9fd4fe2b6 | ||
| 
						 | 
					7e8c9962c3 | ||
| 
						 | 
					cf20142e40 | ||
| 
						 | 
					8654a04dcf | ||
| 
						 | 
					4c766d8ccb | ||
| 
						 | 
					cb1ec7eb8e | ||
| 
						 | 
					a89c3dbe04 | 
@@ -3,7 +3,6 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
@@ -11,6 +10,7 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
						"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
				
			||||||
	cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
						cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
						"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
				
			||||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
						umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
@@ -18,6 +18,18 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type createConversationRequest struct {
 | 
				
			||||||
 | 
						InboxID         int    `json:"inbox_id" form:"inbox_id"`
 | 
				
			||||||
 | 
						AssignedAgentID int    `json:"agent_id" form:"agent_id"`
 | 
				
			||||||
 | 
						AssignedTeamID  int    `json:"team_id" form:"team_id"`
 | 
				
			||||||
 | 
						Email           string `json:"contact_email" form:"contact_email"`
 | 
				
			||||||
 | 
						FirstName       string `json:"first_name" form:"first_name"`
 | 
				
			||||||
 | 
						LastName        string `json:"last_name" form:"last_name"`
 | 
				
			||||||
 | 
						Subject         string `json:"subject" form:"subject"`
 | 
				
			||||||
 | 
						Content         string `json:"content" form:"content"`
 | 
				
			||||||
 | 
						Attachments     []int  `json:"attachments" form:"attachments"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAllConversations retrieves all conversations.
 | 
					// handleGetAllConversations retrieves all conversations.
 | 
				
			||||||
func handleGetAllConversations(r *fastglue.Request) error {
 | 
					func handleGetAllConversations(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -632,36 +644,32 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
 | 
				
			|||||||
// handleCreateConversation creates a new conversation and sends a message to it.
 | 
					// handleCreateConversation creates a new conversation and sends a message to it.
 | 
				
			||||||
func handleCreateConversation(r *fastglue.Request) error {
 | 
					func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app             = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser           = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		inboxID         = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
 | 
							req   = createConversationRequest{}
 | 
				
			||||||
		assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
 | 
					 | 
				
			||||||
		assignedTeamID  = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
 | 
					 | 
				
			||||||
		email           = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
 | 
					 | 
				
			||||||
		firstName       = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
 | 
					 | 
				
			||||||
		lastName        = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
 | 
					 | 
				
			||||||
		subject         = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
 | 
					 | 
				
			||||||
		content         = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
 | 
					 | 
				
			||||||
		to              = []string{email}
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error decoding create conversation request", "error", err)
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						to := []string{req.Email}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Validate required fields
 | 
						// Validate required fields
 | 
				
			||||||
	if inboxID <= 0 {
 | 
						if req.InboxID <= 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if subject == "" {
 | 
						if req.Content == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if content == "" {
 | 
						if req.Email == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if email == "" {
 | 
						if req.FirstName == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if firstName == "" {
 | 
						if !stringutil.ValidEmail(req.Email) {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if !stringutil.ValidEmail(email) {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -671,7 +679,7 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check if inbox exists and is enabled.
 | 
						// Check if inbox exists and is enabled.
 | 
				
			||||||
	inbox, err := app.inbox.GetDBRecord(inboxID)
 | 
						inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -681,11 +689,11 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Find or create contact.
 | 
						// Find or create contact.
 | 
				
			||||||
	contact := umodels.User{
 | 
						contact := umodels.User{
 | 
				
			||||||
		Email:           null.StringFrom(email),
 | 
							Email:           null.StringFrom(req.Email),
 | 
				
			||||||
		SourceChannelID: null.StringFrom(email),
 | 
							SourceChannelID: null.StringFrom(req.Email),
 | 
				
			||||||
		FirstName:       firstName,
 | 
							FirstName:       req.FirstName,
 | 
				
			||||||
		LastName:        lastName,
 | 
							LastName:        req.LastName,
 | 
				
			||||||
		InboxID:         inboxID,
 | 
							InboxID:         req.InboxID,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.CreateContact(&contact); err != nil {
 | 
						if err := app.user.CreateContact(&contact); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
				
			||||||
@@ -695,10 +703,10 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
	conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
						conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
				
			||||||
		contact.ID,
 | 
							contact.ID,
 | 
				
			||||||
		contact.ContactChannelID,
 | 
							contact.ContactChannelID,
 | 
				
			||||||
		inboxID,
 | 
							req.InboxID,
 | 
				
			||||||
		"",         /** last_message **/
 | 
							"",         /** last_message **/
 | 
				
			||||||
		time.Now(), /** last_message_at **/
 | 
							time.Now(), /** last_message_at **/
 | 
				
			||||||
		subject,
 | 
							req.Subject,
 | 
				
			||||||
		true, /** append reference number to subject **/
 | 
							true, /** append reference number to subject **/
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -706,8 +714,19 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Prepare attachments.
 | 
				
			||||||
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error fetching media", "error", err)
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							media = append(media, m)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send reply to the created conversation.
 | 
						// Send reply to the created conversation.
 | 
				
			||||||
	if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
						if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
				
			||||||
		// Delete the conversation if reply fails.
 | 
							// Delete the conversation if reply fails.
 | 
				
			||||||
		if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
							if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
				
			||||||
			app.lo.Error("error deleting conversation", "error", err)
 | 
								app.lo.Error("error deleting conversation", "error", err)
 | 
				
			||||||
@@ -716,11 +735,11 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Assign the conversation to the agent or team.
 | 
						// Assign the conversation to the agent or team.
 | 
				
			||||||
	if assignedAgentID > 0 {
 | 
						if req.AssignedAgentID > 0 {
 | 
				
			||||||
		app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
 | 
							app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if assignedTeamID > 0 {
 | 
						if req.AssignedTeamID > 0 {
 | 
				
			||||||
		app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
 | 
							app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send the created conversation back to the client.
 | 
						// Send the created conversation back to the client.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								cmd/macro.go
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								cmd/macro.go
									
									
									
									
									
								
							@@ -81,8 +81,7 @@ func handleCreateMacro(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
 | 
						if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,7 +109,7 @@ func handleUpdateMacro(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
 | 
						if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -275,13 +274,17 @@ func validateMacro(app *App, macro models.Macro) error {
 | 
				
			|||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if len(macro.VisibleWhen) == 0 {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var act []autoModels.RuleAction
 | 
						var act []autoModels.RuleAction
 | 
				
			||||||
	if err := json.Unmarshal(macro.Actions, &act); err != nil {
 | 
						if err := json.Unmarshal(macro.Actions, &act); err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	for _, a := range act {
 | 
						for _, a := range act {
 | 
				
			||||||
		if len(a.Value) == 0 {
 | 
							if len(a.Value) == 0 {
 | 
				
			||||||
			return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil)
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -132,7 +132,6 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		media = []medModels.Media{}
 | 
					 | 
				
			||||||
		req   = messageReq{}
 | 
							req   = messageReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -153,6 +152,7 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Prepare attachments.
 | 
						// Prepare attachments.
 | 
				
			||||||
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
	for _, id := range req.Attachments {
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
		m, err := app.media.Get(id, "")
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
@@ -173,6 +173,5 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		// Evaluate automation rules.
 | 
							// Evaluate automation rules.
 | 
				
			||||||
		app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
 | 
							app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										92
									
								
								cmd/sla.go
									
									
									
									
									
								
							
							
						
						
									
										92
									
								
								cmd/sla.go
									
									
									
									
									
								
							@@ -29,7 +29,7 @@ func handleGetSLA(r *fastglue.Request) error {
 | 
				
			|||||||
	)
 | 
						)
 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	if err != nil || id == 0 {
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	sla, err := app.sla.Get(id)
 | 
						sla, err := app.sla.Get(id)
 | 
				
			||||||
@@ -54,7 +54,7 @@ func handleCreateSLA(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
 | 
						if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -70,7 +70,7 @@ func handleUpdateSLA(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	if err != nil || id == 0 {
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := r.Decode(&sla, "json"); err != nil {
 | 
						if err := r.Decode(&sla, "json"); err != nil {
 | 
				
			||||||
@@ -81,11 +81,11 @@ func handleUpdateSLA(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
 | 
						if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope("SLA updated successfully.")
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteSLA deletes the SLA with the given ID.
 | 
					// handleDeleteSLA deletes the SLA with the given ID.
 | 
				
			||||||
@@ -95,7 +95,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
 | 
				
			|||||||
	)
 | 
						)
 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	if err != nil || id == 0 {
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err = app.sla.Delete(id); err != nil {
 | 
						if err = app.sla.Delete(id); err != nil {
 | 
				
			||||||
@@ -108,51 +108,79 @@ func handleDeleteSLA(r *fastglue.Request) error {
 | 
				
			|||||||
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
 | 
					// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
 | 
				
			||||||
func validateSLA(app *App, sla *smodels.SLAPolicy) error {
 | 
					func validateSLA(app *App, sla *smodels.SLAPolicy) error {
 | 
				
			||||||
	if sla.Name == "" {
 | 
						if sla.Name == "" {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `name`"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if sla.FirstResponseTime == "" {
 | 
						if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `first_response_time`"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil)
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if sla.ResolutionTime == "" {
 | 
					 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `resolution_time`"), nil)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Validate notifications if any
 | 
						// Validate notifications if any.
 | 
				
			||||||
	for _, n := range sla.Notifications {
 | 
						for _, n := range sla.Notifications {
 | 
				
			||||||
		if n.Type == "" {
 | 
							if n.Type == "" {
 | 
				
			||||||
			return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `type`"), nil)
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if n.TimeDelayType == "" {
 | 
							if n.TimeDelayType == "" {
 | 
				
			||||||
			return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay_type`"), nil)
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if n.Metric == "" {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if n.TimeDelayType != "immediately" {
 | 
							if n.TimeDelayType != "immediately" {
 | 
				
			||||||
			if n.TimeDelay == "" {
 | 
								if n.TimeDelay == "" {
 | 
				
			||||||
				return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay`"), nil)
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// Validate time delay duration.
 | 
				
			||||||
 | 
								td, err := time.ParseDuration(n.TimeDelay)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								if td.Minutes() < 1 {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if len(n.Recipients) == 0 {
 | 
							if len(n.Recipients) == 0 {
 | 
				
			||||||
			return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `recipients`"), nil)
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Validate time duration strings
 | 
						// Validate first response time duration string if not empty.
 | 
				
			||||||
	frt, err := time.ParseDuration(sla.FirstResponseTime)
 | 
						if sla.FirstResponseTime.String != "" {
 | 
				
			||||||
	if err != nil {
 | 
							frt, err := time.ParseDuration(sla.FirstResponseTime.String)
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
 | 
							if err != nil {
 | 
				
			||||||
	}
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
 | 
				
			||||||
	if frt.Minutes() < 1 {
 | 
							}
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
 | 
							if frt.Minutes() < 1 {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	rt, err := time.ParseDuration(sla.ResolutionTime)
 | 
						// Validate resolution time duration string if not empty.
 | 
				
			||||||
	if err != nil {
 | 
						if sla.ResolutionTime.String != "" {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
 | 
							rt, err := time.ParseDuration(sla.ResolutionTime.String)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if rt.Minutes() < 1 {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							// Compare with first response time if both are present.
 | 
				
			||||||
 | 
							if sla.FirstResponseTime.String != "" {
 | 
				
			||||||
 | 
								frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
 | 
				
			||||||
 | 
								if frt > rt {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if rt.Minutes() < 1 {
 | 
					
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
 | 
						// Validate next response time duration string if not empty.
 | 
				
			||||||
	}
 | 
						if sla.NextResponseTime.String != "" {
 | 
				
			||||||
	if frt > rt {
 | 
							nrt, err := time.ParseDuration(sla.NextResponseTime.String)
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if nrt.Minutes() < 1 {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -72,15 +72,26 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			|||||||
		status = string(r.RequestCtx.PostArgs().Peek("status"))
 | 
							status = string(r.RequestCtx.PostArgs().Peek("status"))
 | 
				
			||||||
		ip     = realip.FromRequest(r.RequestCtx)
 | 
							ip     = realip.FromRequest(r.RequestCtx)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Same status?
 | 
				
			||||||
 | 
						if agent.AvailabilityStatus == status {
 | 
				
			||||||
 | 
							return r.SendEnvelope(true)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update availability status.
 | 
						// Update availability status.
 | 
				
			||||||
	if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
 | 
						if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create activity log.
 | 
						// Skip activity log if agent returns online from away (to avoid spam).
 | 
				
			||||||
	if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
 | 
						if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
 | 
				
			||||||
		app.lo.Error("error creating activity log", "error", err)
 | 
							if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error creating activity log", "error", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,8 +6,7 @@
 | 
				
			|||||||
  <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&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
 | 
					 | 
				
			||||||
    rel="stylesheet">
 | 
					    rel="stylesheet">
 | 
				
			||||||
</head>
 | 
					</head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,7 +33,7 @@
 | 
				
			|||||||
    "@tiptap/vue-3": "^2.4.0",
 | 
					    "@tiptap/vue-3": "^2.4.0",
 | 
				
			||||||
    "@unovis/ts": "^1.4.4",
 | 
					    "@unovis/ts": "^1.4.4",
 | 
				
			||||||
    "@unovis/vue": "^1.4.4",
 | 
					    "@unovis/vue": "^1.4.4",
 | 
				
			||||||
    "@vee-validate/zod": "^4.13.2",
 | 
					    "@vee-validate/zod": "^4.15.0",
 | 
				
			||||||
    "@vueuse/core": "^12.4.0",
 | 
					    "@vueuse/core": "^12.4.0",
 | 
				
			||||||
    "axios": "^1.8.2",
 | 
					    "axios": "^1.8.2",
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "class-variance-authority": "^0.7.0",
 | 
				
			||||||
@@ -47,7 +47,7 @@
 | 
				
			|||||||
    "radix-vue": "^1.9.17",
 | 
					    "radix-vue": "^1.9.17",
 | 
				
			||||||
    "reka-ui": "^2.2.0",
 | 
					    "reka-ui": "^2.2.0",
 | 
				
			||||||
    "tailwind-merge": "^2.3.0",
 | 
					    "tailwind-merge": "^2.3.0",
 | 
				
			||||||
    "vee-validate": "^4.13.2",
 | 
					    "vee-validate": "^4.15.0",
 | 
				
			||||||
    "vue": "^3.4.37",
 | 
					    "vue": "^3.4.37",
 | 
				
			||||||
    "vue-dompurify-html": "^5.2.0",
 | 
					    "vue-dompurify-html": "^5.2.0",
 | 
				
			||||||
    "vue-i18n": "9",
 | 
					    "vue-i18n": "9",
 | 
				
			||||||
@@ -57,7 +57,7 @@
 | 
				
			|||||||
    "vue-sonner": "^1.3.0",
 | 
					    "vue-sonner": "^1.3.0",
 | 
				
			||||||
    "vue3-emoji-picker": "^1.1.8",
 | 
					    "vue3-emoji-picker": "^1.1.8",
 | 
				
			||||||
    "vuedraggable": "^4.1.0",
 | 
					    "vuedraggable": "^4.1.0",
 | 
				
			||||||
    "zod": "^3.23.8"
 | 
					    "zod": "^3.24.1"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@rushstack/eslint-patch": "^1.3.3",
 | 
					    "@rushstack/eslint-patch": "^1.3.3",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -60,7 +60,7 @@ importers:
 | 
				
			|||||||
        specifier: ^1.4.4
 | 
					        specifier: ^1.4.4
 | 
				
			||||||
        version: 1.5.0(@unovis/ts@1.5.0)(vue@3.5.13(typescript@5.7.3))
 | 
					        version: 1.5.0(@unovis/ts@1.5.0)(vue@3.5.13(typescript@5.7.3))
 | 
				
			||||||
      '@vee-validate/zod':
 | 
					      '@vee-validate/zod':
 | 
				
			||||||
        specifier: ^4.13.2
 | 
					        specifier: ^4.15.0
 | 
				
			||||||
        version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
 | 
					        version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
 | 
				
			||||||
      '@vueuse/core':
 | 
					      '@vueuse/core':
 | 
				
			||||||
        specifier: ^12.4.0
 | 
					        specifier: ^12.4.0
 | 
				
			||||||
@@ -102,7 +102,7 @@ importers:
 | 
				
			|||||||
        specifier: ^2.3.0
 | 
					        specifier: ^2.3.0
 | 
				
			||||||
        version: 2.6.0
 | 
					        version: 2.6.0
 | 
				
			||||||
      vee-validate:
 | 
					      vee-validate:
 | 
				
			||||||
        specifier: ^4.13.2
 | 
					        specifier: ^4.15.0
 | 
				
			||||||
        version: 4.15.0(vue@3.5.13(typescript@5.7.3))
 | 
					        version: 4.15.0(vue@3.5.13(typescript@5.7.3))
 | 
				
			||||||
      vue:
 | 
					      vue:
 | 
				
			||||||
        specifier: ^3.4.37
 | 
					        specifier: ^3.4.37
 | 
				
			||||||
@@ -132,7 +132,7 @@ importers:
 | 
				
			|||||||
        specifier: ^4.1.0
 | 
					        specifier: ^4.1.0
 | 
				
			||||||
        version: 4.1.0(vue@3.5.13(typescript@5.7.3))
 | 
					        version: 4.1.0(vue@3.5.13(typescript@5.7.3))
 | 
				
			||||||
      zod:
 | 
					      zod:
 | 
				
			||||||
        specifier: ^3.23.8
 | 
					        specifier: ^3.24.1
 | 
				
			||||||
        version: 3.24.1
 | 
					        version: 3.24.1
 | 
				
			||||||
    devDependencies:
 | 
					    devDependencies:
 | 
				
			||||||
      '@rushstack/eslint-patch':
 | 
					      '@rushstack/eslint-patch':
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex w-full h-screen">
 | 
					  <div class="flex w-full h-screen text-foreground">
 | 
				
			||||||
    <!-- Icon sidebar always visible -->
 | 
					    <!-- Icon sidebar always visible -->
 | 
				
			||||||
    <SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
 | 
					    <SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
 | 
				
			||||||
      <ShadcnSidebar collapsible="none" class="border-r">
 | 
					      <ShadcnSidebar collapsible="none" class="border-r">
 | 
				
			||||||
@@ -8,38 +8,64 @@
 | 
				
			|||||||
            <SidebarGroupContent>
 | 
					            <SidebarGroupContent>
 | 
				
			||||||
              <SidebarMenu>
 | 
					              <SidebarMenu>
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem>
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link :to="{ name: 'inboxes' }">
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      <Inbox />
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
 | 
				
			||||||
                    </router-link>
 | 
					                        <router-link :to="{ name: 'inboxes' }">
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          <Inbox />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.inbox', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem v-if="userStore.can('contacts:read_all')">
 | 
				
			||||||
                  <SidebarMenuButton
 | 
					                  <Tooltip>
 | 
				
			||||||
                    asChild
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                    :isActive="route.path.startsWith('/contacts')"
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
 | 
				
			||||||
                    v-if="userStore.can('contacts:read_all')"
 | 
					                        <router-link :to="{ name: 'contacts' }">
 | 
				
			||||||
                  >
 | 
					                          <BookUser />
 | 
				
			||||||
                    <router-link :to="{ name: 'contacts' }">
 | 
					                        </router-link>
 | 
				
			||||||
                      <BookUser />
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
                    </router-link>
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.contact', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
 | 
					                <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link :to="{ name: 'reports' }">
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      <FileLineChart />
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
 | 
				
			||||||
                    </router-link>
 | 
					                        <router-link :to="{ name: 'reports' }">
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          <FileLineChart />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.report', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
 | 
					                <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      :to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
 | 
				
			||||||
                    >
 | 
					                        <router-link
 | 
				
			||||||
                      <Shield />
 | 
					                          :to="{
 | 
				
			||||||
                    </router-link>
 | 
					                            name: userStore.can('general_settings:manage') ? 'general' : 'admin'
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          }"
 | 
				
			||||||
 | 
					                        >
 | 
				
			||||||
 | 
					                          <Shield />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.admin') }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
              </SidebarMenu>
 | 
					              </SidebarMenu>
 | 
				
			||||||
            </SidebarGroupContent>
 | 
					            </SidebarGroupContent>
 | 
				
			||||||
@@ -80,7 +106,7 @@
 | 
				
			|||||||
  <Command />
 | 
					  <Command />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <!-- Create conversation dialog -->
 | 
					  <!-- Create conversation dialog -->
 | 
				
			||||||
  <CreateConversation v-model="openCreateConversationDialog" />
 | 
					  <CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -122,6 +148,7 @@ import {
 | 
				
			|||||||
  SidebarMenuItem,
 | 
					  SidebarMenuItem,
 | 
				
			||||||
  SidebarProvider
 | 
					  SidebarProvider
 | 
				
			||||||
} from '@/components/ui/sidebar'
 | 
					} from '@/components/ui/sidebar'
 | 
				
			||||||
 | 
					import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
				
			||||||
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
 | 
					import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,7 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <TooltipProvider :delay-duration="150">
 | 
					  <TooltipProvider :delay-duration="150">
 | 
				
			||||||
    <div class="!font-jakarta">
 | 
					    <Toaster class="pointer-events-auto" position="top-center" richColors />
 | 
				
			||||||
      <Toaster class="pointer-events-auto" position="top-center" richColors />
 | 
					    <RouterView />
 | 
				
			||||||
      <RouterView />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </TooltipProvider>
 | 
					  </TooltipProvider>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -231,7 +231,11 @@ const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conv
 | 
				
			|||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const createConversation = (data) => http.post('/api/v1/conversations', data)
 | 
					const createConversation = (data) => http.post('/api/v1/conversations', data, {
 | 
				
			||||||
 | 
					  headers: {
 | 
				
			||||||
 | 
					    'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
 | 
					const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
 | 
				
			||||||
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
 | 
					const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
 | 
				
			||||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
					const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,12 +13,20 @@
 | 
				
			|||||||
    min-height: 100%;
 | 
					    min-height: 100%;
 | 
				
			||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    @apply bg-background text-foreground;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @media (max-width: 768px) {
 | 
					  @media (max-width: 768px) {
 | 
				
			||||||
 | 
					    html,
 | 
				
			||||||
 | 
					    body {
 | 
				
			||||||
      overflow-x: auto;
 | 
					      overflow-x: auto;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  * {
 | 
				
			||||||
 | 
					    @apply border-border;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .native-html {
 | 
					  .native-html {
 | 
				
			||||||
    p {
 | 
					    p {
 | 
				
			||||||
      margin-bottom: 0.5rem;
 | 
					      margin-bottom: 0.5rem;
 | 
				
			||||||
@@ -61,10 +69,39 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					  :root {
 | 
				
			||||||
 | 
					    --sidebar-background: 0 0% 100%;
 | 
				
			||||||
 | 
					    --sidebar-foreground: 240 5.9% 10%;
 | 
				
			||||||
 | 
					    --sidebar-primary: 240 5.9% 10%;
 | 
				
			||||||
 | 
					    --sidebar-primary-foreground: 0 0% 98%;
 | 
				
			||||||
 | 
					    --sidebar-accent: 240 4.8% 95.9%;
 | 
				
			||||||
 | 
					    --sidebar-accent-foreground: 240 5.9% 10%;
 | 
				
			||||||
 | 
					    --sidebar-border: 220 13% 91%;
 | 
				
			||||||
 | 
					    --sidebar-ring: 217.2 91.2% 59.8%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  .dark {
 | 
				
			||||||
 | 
					    --sidebar-background: 240 5.9% 10%;
 | 
				
			||||||
 | 
					    --sidebar-foreground: 240 4.8% 95.9%;
 | 
				
			||||||
 | 
					    --sidebar-primary: 224.3 76.3% 48%;
 | 
				
			||||||
 | 
					    --sidebar-primary-foreground: 0 0% 100%;
 | 
				
			||||||
 | 
					    --sidebar-accent: 240 3.7% 15.9%;
 | 
				
			||||||
 | 
					    --sidebar-accent-foreground: 240 4.8% 95.9%;
 | 
				
			||||||
 | 
					    --sidebar-border: 240 3.7% 15.9%;
 | 
				
			||||||
 | 
					    --sidebar-ring: 217.2 91.2% 59.8%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  :root {
 | 
				
			||||||
 | 
					    --vis-tooltip-background-color: none !important;
 | 
				
			||||||
 | 
					    --vis-tooltip-border-color: none !important;
 | 
				
			||||||
 | 
					    --vis-tooltip-text-color: none !important;
 | 
				
			||||||
 | 
					    --vis-tooltip-shadow-color: none !important;
 | 
				
			||||||
 | 
					    --vis-tooltip-backdrop-filter: none !important;
 | 
				
			||||||
 | 
					    --vis-tooltip-padding: none !important;
 | 
				
			||||||
 | 
					    --vis-primary-color: var(--primary);
 | 
				
			||||||
 | 
					    --vis-secondary-color: 160 81% 40%;
 | 
				
			||||||
 | 
					    --vis-text-color: var(--muted-foreground);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Theme.
 | 
					 | 
				
			||||||
@layer base {
 | 
					 | 
				
			||||||
  :root {
 | 
					  :root {
 | 
				
			||||||
    --background: 0 0% 100%;
 | 
					    --background: 0 0% 100%;
 | 
				
			||||||
    --foreground: 240 10% 3.9%;
 | 
					    --foreground: 240 10% 3.9%;
 | 
				
			||||||
@@ -97,7 +134,7 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .dark {
 | 
					  .dark {
 | 
				
			||||||
    --background: 240 10% 3.9%;
 | 
					    --background: 240 5.9% 10%;
 | 
				
			||||||
    --foreground: 0 0% 98%;
 | 
					    --foreground: 0 0% 98%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    --card: 240 10% 3.9%;
 | 
					    --card: 240 10% 3.9%;
 | 
				
			||||||
@@ -127,64 +164,8 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@layer base {
 | 
					 | 
				
			||||||
  :root {
 | 
					 | 
				
			||||||
    --vis-tooltip-background-color: none !important;
 | 
					 | 
				
			||||||
    --vis-tooltip-border-color: none !important;
 | 
					 | 
				
			||||||
    --vis-tooltip-text-color: none !important;
 | 
					 | 
				
			||||||
    --vis-tooltip-shadow-color: none !important;
 | 
					 | 
				
			||||||
    --vis-tooltip-backdrop-filter: none !important;
 | 
					 | 
				
			||||||
    --vis-tooltip-padding: none !important;
 | 
					 | 
				
			||||||
    --vis-primary-color: var(--primary);
 | 
					 | 
				
			||||||
    --vis-secondary-color: 160 81% 40%;
 | 
					 | 
				
			||||||
    --vis-text-color: var(--muted-foreground);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Shake animation
 | 
					 | 
				
			||||||
@keyframes shake {
 | 
					 | 
				
			||||||
  0% {
 | 
					 | 
				
			||||||
    transform: translateX(0);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  15% {
 | 
					 | 
				
			||||||
    transform: translateX(-5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  25% {
 | 
					 | 
				
			||||||
    transform: translateX(5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  35% {
 | 
					 | 
				
			||||||
    transform: translateX(-5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  45% {
 | 
					 | 
				
			||||||
    transform: translateX(5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  55% {
 | 
					 | 
				
			||||||
    transform: translateX(-5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  65% {
 | 
					 | 
				
			||||||
    transform: translateX(5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  75% {
 | 
					 | 
				
			||||||
    transform: translateX(-5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  85% {
 | 
					 | 
				
			||||||
    transform: translateX(5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  95% {
 | 
					 | 
				
			||||||
    transform: translateX(-5px);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  100% {
 | 
					 | 
				
			||||||
    transform: translateX(0);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.animate-shake {
 | 
					 | 
				
			||||||
  animation: shake 0.5s infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.message-bubble {
 | 
					.message-bubble {
 | 
				
			||||||
  @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded-xl;
 | 
					  @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm;
 | 
				
			||||||
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
 | 
					 | 
				
			||||||
  table {
 | 
					  table {
 | 
				
			||||||
    width: 100% !important;
 | 
					    width: 100% !important;
 | 
				
			||||||
    table-layout: fixed !important;
 | 
					    table-layout: fixed !important;
 | 
				
			||||||
@@ -200,7 +181,7 @@
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.box {
 | 
					.box {
 | 
				
			||||||
  @apply border shadow rounded-lg;
 | 
					  @apply border shadow rounded;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Scrollbar start
 | 
					// Scrollbar start
 | 
				
			||||||
@@ -227,84 +208,9 @@
 | 
				
			|||||||
// End Scrollbar
 | 
					// End Scrollbar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.code-editor {
 | 
					.code-editor {
 | 
				
			||||||
  @apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
 | 
					  @apply rounded border shadow h-[65vh] min-h-[250px] w-full relative;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ql-container {
 | 
					 | 
				
			||||||
  margin: 0 !important;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.ql-container .ql-editor {
 | 
					 | 
				
			||||||
  height: 300px !important;
 | 
					 | 
				
			||||||
  border-radius: var(--radius) !important;
 | 
					 | 
				
			||||||
  @apply rounded-lg rounded-t-none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.ql-toolbar {
 | 
					 | 
				
			||||||
  @apply rounded-t-lg;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.blinking-dot {
 | 
					 | 
				
			||||||
  display: inline-block;
 | 
					 | 
				
			||||||
  width: 8px;
 | 
					 | 
				
			||||||
  height: 8px;
 | 
					 | 
				
			||||||
  background-color: red;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  animation: blink 2s infinite;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes blink {
 | 
					 | 
				
			||||||
  0%,
 | 
					 | 
				
			||||||
  100% {
 | 
					 | 
				
			||||||
    opacity: 1;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  50% {
 | 
					 | 
				
			||||||
    opacity: 0;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Sidebar start
 | 
					 | 
				
			||||||
@layer base {
 | 
					 | 
				
			||||||
  :root {
 | 
					 | 
				
			||||||
    --sidebar-background: 0 0% 96%;
 | 
					 | 
				
			||||||
    --sidebar-foreground: 240 5.3% 26.1%;
 | 
					 | 
				
			||||||
    --sidebar-primary: 240 5.9% 10%;
 | 
					 | 
				
			||||||
    --sidebar-primary-foreground: 0 0% 98%;
 | 
					 | 
				
			||||||
    --sidebar-accent: 240 4.8% 95.9%;
 | 
					 | 
				
			||||||
    --sidebar-accent-foreground: 240 5.9% 10%;
 | 
					 | 
				
			||||||
    --sidebar-border: 220 13% 91%;
 | 
					 | 
				
			||||||
    --sidebar-ring: 217.2 91.2% 59.8%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .dark {
 | 
					 | 
				
			||||||
    --sidebar-background: 240 5.9% 10%;
 | 
					 | 
				
			||||||
    --sidebar-foreground: 240 4.8% 95.9%;
 | 
					 | 
				
			||||||
    --sidebar-primary: 224.3 76.3% 48%;
 | 
					 | 
				
			||||||
    --sidebar-primary-foreground: 0 0% 100%;
 | 
					 | 
				
			||||||
    --sidebar-accent: 240 3.7% 15.9%;
 | 
					 | 
				
			||||||
    --sidebar-accent-foreground: 240 4.8% 95.9%;
 | 
					 | 
				
			||||||
    --sidebar-border: 240 3.7% 15.9%;
 | 
					 | 
				
			||||||
    --sidebar-ring: 217.2 91.2% 59.8%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
a[data-active='true'] {
 | 
					 | 
				
			||||||
  background-color: hsl(var(--sidebar-background)) !important;
 | 
					 | 
				
			||||||
  color: hsl(var(--sidebar-accent-foreground)) !important;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  transition:
 | 
					 | 
				
			||||||
    background-color 0.2s,
 | 
					 | 
				
			||||||
    color 0.2s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
a[data-active='false']:hover {
 | 
					 | 
				
			||||||
  background-color: hsl(var(--sidebar-accent)) !important;
 | 
					 | 
				
			||||||
  color: hsl(var(--sidebar-accent-foreground)) !important;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  transition:
 | 
					 | 
				
			||||||
    background-color 0.2s,
 | 
					 | 
				
			||||||
    color 0.2s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
// Sidebar end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.show-quoted-text {
 | 
					.show-quoted-text {
 | 
				
			||||||
  blockquote {
 | 
					  blockquote {
 | 
				
			||||||
    @apply block;
 | 
					    @apply block;
 | 
				
			||||||
@@ -317,37 +223,6 @@ a[data-active='false']:hover {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dot-loader {
 | 
					 | 
				
			||||||
  display: inline-flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dot {
 | 
					 | 
				
			||||||
  width: 4px;
 | 
					 | 
				
			||||||
  height: 4px;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  background-color: currentColor;
 | 
					 | 
				
			||||||
  margin: 0 2px;
 | 
					 | 
				
			||||||
  animation: dot-flashing 1s infinite linear alternate;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dot:nth-child(2) {
 | 
					 | 
				
			||||||
  animation-delay: 0.2s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dot:nth-child(3) {
 | 
					 | 
				
			||||||
  animation-delay: 0.4s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@keyframes dot-flashing {
 | 
					 | 
				
			||||||
  0% {
 | 
					 | 
				
			||||||
    opacity: 0.2;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  100% {
 | 
					 | 
				
			||||||
    opacity: 1;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[data-radix-popper-content-wrapper] {
 | 
					[data-radix-popper-content-wrapper] {
 | 
				
			||||||
  z-index: 9999 !important;
 | 
					  z-index: 9999 !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										24
									
								
								frontend/src/components/button/CloseButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/button/CloseButton.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Button
 | 
				
			||||||
 | 
					    variant="ghost"
 | 
				
			||||||
 | 
					    @click.prevent="onClose"
 | 
				
			||||||
 | 
					    size="xs"
 | 
				
			||||||
 | 
					    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <slot>
 | 
				
			||||||
 | 
					      <X size="16" />
 | 
				
			||||||
 | 
					    </slot>
 | 
				
			||||||
 | 
					  </Button>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { X } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineProps({
 | 
				
			||||||
 | 
					  onClose: {
 | 
				
			||||||
 | 
					    type: Function,
 | 
				
			||||||
 | 
					    required: true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										61
									
								
								frontend/src/components/combobox/SelectCombobox.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/src/components/combobox/SelectCombobox.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <ComboBox
 | 
				
			||||||
 | 
					    :model-value="normalizedValue"
 | 
				
			||||||
 | 
					    @update:model-value="$emit('update:modelValue', $event)"
 | 
				
			||||||
 | 
					    :items="items"
 | 
				
			||||||
 | 
					    :placeholder="placeholder"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <!-- Items -->
 | 
				
			||||||
 | 
					    <template #item="{ item }">
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-2">
 | 
				
			||||||
 | 
					        <!--USER -->
 | 
				
			||||||
 | 
					        <Avatar v-if="type === 'user'" class="w-7 h-7">
 | 
				
			||||||
 | 
					          <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
				
			||||||
 | 
					          <AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
 | 
				
			||||||
 | 
					        </Avatar>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Others -->
 | 
				
			||||||
 | 
					        <span v-else-if="item.emoji">{{ item.emoji }}</span>
 | 
				
			||||||
 | 
					        <span>{{ item.label }}</span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Selected -->
 | 
				
			||||||
 | 
					    <template #selected="{ selected }">
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-2">
 | 
				
			||||||
 | 
					        <div v-if="selected" class="flex items-center gap-2">
 | 
				
			||||||
 | 
					          <!--USER -->
 | 
				
			||||||
 | 
					          <Avatar v-if="type === 'user'" class="w-7 h-7">
 | 
				
			||||||
 | 
					            <AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
 | 
				
			||||||
 | 
					            <AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
 | 
				
			||||||
 | 
					          </Avatar>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Others -->
 | 
				
			||||||
 | 
					          <span v-else-if="selected.emoji">{{ selected.emoji }}</span>
 | 
				
			||||||
 | 
					          <span>{{ selected.label }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span v-else>{{ placeholder }}</span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					  </ComboBox>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { computed } from 'vue'
 | 
				
			||||||
 | 
					import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
				
			||||||
 | 
					import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  modelValue: [String, Number, Object],
 | 
				
			||||||
 | 
					  placeholder: String,
 | 
				
			||||||
 | 
					  items: Array,
 | 
				
			||||||
 | 
					  type: {
 | 
				
			||||||
 | 
					    type: String
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Convert to str.
 | 
				
			||||||
 | 
					const normalizedValue = computed(() => String(props.modelValue || ''))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineEmits(['update:modelValue'])
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="w-full">
 | 
					  <div class="w-full">
 | 
				
			||||||
    <div class="rounded-md border shadow">
 | 
					    <div class="rounded border shadow">
 | 
				
			||||||
      <Table>
 | 
					      <Table>
 | 
				
			||||||
        <TableHeader>
 | 
					        <TableHeader>
 | 
				
			||||||
          <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
 | 
					          <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,7 @@
 | 
				
			|||||||
      :editor="editor"
 | 
					      :editor="editor"
 | 
				
			||||||
      :tippy-options="{ duration: 100 }"
 | 
					      :tippy-options="{ duration: 100 }"
 | 
				
			||||||
      v-if="editor"
 | 
					      v-if="editor"
 | 
				
			||||||
      class="bg-white p-1 box will-change-transform"
 | 
					      class="bg-background p-1 box will-change-transform"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div class="flex space-x-1 items-center">
 | 
					      <div class="flex space-x-1 items-center">
 | 
				
			||||||
        <DropdownMenu v-if="aiPrompts.length > 0">
 | 
					        <DropdownMenu v-if="aiPrompts.length > 0">
 | 
				
			||||||
@@ -32,7 +32,7 @@
 | 
				
			|||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="isBold = !isBold"
 | 
					          @click.prevent="isBold = !isBold"
 | 
				
			||||||
          :active="isBold"
 | 
					          :active="isBold"
 | 
				
			||||||
          :class="{ 'bg-gray-200': isBold }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': isBold }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Bold size="14" />
 | 
					          <Bold size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
@@ -41,7 +41,7 @@
 | 
				
			|||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="isItalic = !isItalic"
 | 
					          @click.prevent="isItalic = !isItalic"
 | 
				
			||||||
          :active="isItalic"
 | 
					          :active="isItalic"
 | 
				
			||||||
          :class="{ 'bg-gray-200': isItalic }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': isItalic }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Italic size="14" />
 | 
					          <Italic size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
@@ -49,7 +49,7 @@
 | 
				
			|||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="toggleBulletList"
 | 
					          @click.prevent="toggleBulletList"
 | 
				
			||||||
          :class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <List size="14" />
 | 
					          <List size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
@@ -58,7 +58,7 @@
 | 
				
			|||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="toggleOrderedList"
 | 
					          @click.prevent="toggleOrderedList"
 | 
				
			||||||
          :class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <ListOrdered size="14" />
 | 
					          <ListOrdered size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
@@ -66,16 +66,16 @@
 | 
				
			|||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="openLinkModal"
 | 
					          @click.prevent="openLinkModal"
 | 
				
			||||||
          :class="{ 'bg-gray-200': editor?.isActive('link') }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <LinkIcon size="14" />
 | 
					          <LinkIcon size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
        <div v-if="showLinkInput" class="flex space-x-2 p-2 bg-white border rounded-lg">
 | 
					        <div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
 | 
				
			||||||
          <input
 | 
					          <Input
 | 
				
			||||||
            v-model="linkUrl"
 | 
					            v-model="linkUrl"
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
            placeholder="Enter link URL"
 | 
					            placeholder="Enter link URL"
 | 
				
			||||||
            class="border p-1 text-sm"
 | 
					            class="border p-1 text-sm w-[200px]"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <Button size="sm" @click="setLink">
 | 
					          <Button size="sm" @click="setLink">
 | 
				
			||||||
            <Check size="14" />
 | 
					            <Check size="14" />
 | 
				
			||||||
@@ -91,7 +91,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
 | 
					import { ref, watch, watchEffect, onUnmounted, computed } from 'vue'
 | 
				
			||||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
 | 
					import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ChevronDown,
 | 
					  ChevronDown,
 | 
				
			||||||
@@ -111,6 +111,7 @@ import {
 | 
				
			|||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
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 StarterKit from '@tiptap/starter-kit'
 | 
					import StarterKit from '@tiptap/starter-kit'
 | 
				
			||||||
@@ -135,6 +136,10 @@ const props = defineProps({
 | 
				
			|||||||
  setInlineImage: Object,
 | 
					  setInlineImage: Object,
 | 
				
			||||||
  insertContent: String,
 | 
					  insertContent: String,
 | 
				
			||||||
  clearContent: Boolean,
 | 
					  clearContent: Boolean,
 | 
				
			||||||
 | 
					  autoFocus: {
 | 
				
			||||||
 | 
					    type: Boolean,
 | 
				
			||||||
 | 
					    default: true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  aiPrompts: {
 | 
					  aiPrompts: {
 | 
				
			||||||
    type: Array,
 | 
					    type: Array,
 | 
				
			||||||
    default: () => []
 | 
					    default: () => []
 | 
				
			||||||
@@ -187,7 +192,7 @@ const CustomTableHeader = TableHeader.extend({
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const editorConfig = {
 | 
					const editorConfig = computed(() => ({
 | 
				
			||||||
  extensions: [
 | 
					  extensions: [
 | 
				
			||||||
    StarterKit.configure(),
 | 
					    StarterKit.configure(),
 | 
				
			||||||
    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
					    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
				
			||||||
@@ -200,7 +205,7 @@ const editorConfig = {
 | 
				
			|||||||
    CustomTableCell,
 | 
					    CustomTableCell,
 | 
				
			||||||
    CustomTableHeader
 | 
					    CustomTableHeader
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  autofocus: true,
 | 
					  autofocus: props.autoFocus,
 | 
				
			||||||
  editorProps: {
 | 
					  editorProps: {
 | 
				
			||||||
    attributes: { class: 'outline-none' },
 | 
					    attributes: { class: 'outline-none' },
 | 
				
			||||||
    handleKeyDown: (view, event) => {
 | 
					    handleKeyDown: (view, event) => {
 | 
				
			||||||
@@ -209,17 +214,17 @@ const editorConfig = {
 | 
				
			|||||||
        return true
 | 
					        return true
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      if (event.ctrlKey && event.key.toLowerCase() === 'b') {
 | 
					      if (event.ctrlKey && event.key.toLowerCase() === 'b') {
 | 
				
			||||||
         // Prevent outer listeners
 | 
					        // Prevent outer listeners
 | 
				
			||||||
        event.stopPropagation()
 | 
					        event.stopPropagation()
 | 
				
			||||||
        return false
 | 
					        return false
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const editor = ref(
 | 
					const editor = ref(
 | 
				
			||||||
  useEditor({
 | 
					  useEditor({
 | 
				
			||||||
    ...editorConfig,
 | 
					    ...editorConfig.value,
 | 
				
			||||||
    content: htmlContent.value,
 | 
					    content: htmlContent.value,
 | 
				
			||||||
    onSelectionUpdate: ({ editor }) => {
 | 
					    onSelectionUpdate: ({ editor }) => {
 | 
				
			||||||
      const { from, to } = editor.state.selection
 | 
					      const { from, to } = editor.state.selection
 | 
				
			||||||
@@ -44,79 +44,47 @@
 | 
				
			|||||||
        <div class="flex-1">
 | 
					        <div class="flex-1">
 | 
				
			||||||
          <div v-if="modelFilter.field && modelFilter.operator">
 | 
					          <div v-if="modelFilter.field && modelFilter.operator">
 | 
				
			||||||
            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
					            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
				
			||||||
              <ComboBox
 | 
					              <SelectComboBox
 | 
				
			||||||
                v-if="getFieldOptions(modelFilter).length > 0"
 | 
					                v-if="
 | 
				
			||||||
 | 
					                  getFieldOptions(modelFilter).length > 0 &&
 | 
				
			||||||
 | 
					                  modelFilter.field === 'assigned_user_id'
 | 
				
			||||||
 | 
					                "
 | 
				
			||||||
                v-model="modelFilter.value"
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                :items="getFieldOptions(modelFilter)"
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                :placeholder="t('form.field.select')"
 | 
					                :placeholder="t('form.field.select')"
 | 
				
			||||||
              >
 | 
					                type="user"
 | 
				
			||||||
                <template #item="{ item }">
 | 
					              />
 | 
				
			||||||
                  <div v-if="modelFilter.field === 'assigned_user_id'">
 | 
					
 | 
				
			||||||
                    <div class="flex items-center gap-1">
 | 
					              <SelectComboBox
 | 
				
			||||||
                      <Avatar class="w-6 h-6">
 | 
					                v-else-if="
 | 
				
			||||||
                        <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					                  getFieldOptions(modelFilter).length > 0 &&
 | 
				
			||||||
                        <AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
 | 
					                  modelFilter.field === 'assigned_team_id'
 | 
				
			||||||
                      </Avatar>
 | 
					                "
 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                    </div>
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                  </div>
 | 
					                :placeholder="t('form.field.select')"
 | 
				
			||||||
                  <div v-else-if="modelFilter.field === 'assigned_team_id'">
 | 
					                type="team"
 | 
				
			||||||
                    <div class="flex items-center gap-2 ml-2">
 | 
					              />
 | 
				
			||||||
                      <span>{{ item.emoji }}</span>
 | 
					
 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					              <SelectComboBox
 | 
				
			||||||
                    </div>
 | 
					                v-else-if="getFieldOptions(modelFilter).length > 0"
 | 
				
			||||||
                  </div>
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                  <div v-else>
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                    {{ item.label }}
 | 
					                :placeholder="t('form.field.select')"
 | 
				
			||||||
                  </div>
 | 
					              />
 | 
				
			||||||
                </template>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <template #selected="{ selected }">
 | 
					 | 
				
			||||||
                  <div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
 | 
					 | 
				
			||||||
                  <div v-if="modelFilter.field === 'assigned_user_id'">
 | 
					 | 
				
			||||||
                    <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                      <div v-if="selected" class="flex items-center gap-1">
 | 
					 | 
				
			||||||
                        <Avatar class="w-6 h-6">
 | 
					 | 
				
			||||||
                          <AvatarImage
 | 
					 | 
				
			||||||
                            :src="selected.avatar_url || ''"
 | 
					 | 
				
			||||||
                            :alt="selected.label.slice(0, 2)"
 | 
					 | 
				
			||||||
                          />
 | 
					 | 
				
			||||||
                          <AvatarFallback>{{
 | 
					 | 
				
			||||||
                            selected.label.slice(0, 2).toUpperCase()
 | 
					 | 
				
			||||||
                          }}</AvatarFallback>
 | 
					 | 
				
			||||||
                        </Avatar>
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                  <div v-else-if="modelFilter.field === 'assigned_team_id'">
 | 
					 | 
				
			||||||
                    <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                      <span v-if="selected">
 | 
					 | 
				
			||||||
                        {{ selected.emoji }}
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                  <div v-else-if="selected">
 | 
					 | 
				
			||||||
                    {{ selected.label }}
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </template>
 | 
					 | 
				
			||||||
              </ComboBox>
 | 
					 | 
				
			||||||
              <Input
 | 
					              <Input
 | 
				
			||||||
                v-else
 | 
					                v-else
 | 
				
			||||||
                v-model="modelFilter.value"
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                class="bg-transparent hover:bg-slate-100"
 | 
					                class="bg-transparent hover:bg-slate-100"
 | 
				
			||||||
                :placeholder="t('form.field.value')"
 | 
					                :placeholder="t('globals.terms.value')"
 | 
				
			||||||
                type="text"
 | 
					                type="text"
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            </template>
 | 
					            </template>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					      <CloseButton :onClose="() => removeFilter(index)" />
 | 
				
			||||||
      <button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
 | 
					 | 
				
			||||||
        <X class="w-4 h-4 text-slate-500" />
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="flex items-center justify-between pt-3">
 | 
					    <div class="flex items-center justify-between pt-3">
 | 
				
			||||||
@@ -146,12 +114,12 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { Plus, X } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
					 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  fields: {
 | 
					  fields: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div
 | 
					  <div
 | 
				
			||||||
    class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
 | 
					    class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
 | 
				
			||||||
    @click="handleClick">
 | 
					    @click="handleClick">
 | 
				
			||||||
    <div class="flex items-center mb-2">
 | 
					    <div class="flex items-center mb-2">
 | 
				
			||||||
      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
					      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div v-if="!isHidden">
 | 
					  <div v-if="!isHidden">
 | 
				
			||||||
    <div class="flex items-center space-x-4 h-12 px-2">
 | 
					    <div class="flex items-center space-x-4 h-12 px-2">
 | 
				
			||||||
      <SidebarTrigger class="cursor-pointer w-4 h-4" />
 | 
					      <SidebarTrigger class="cursor-pointer" />
 | 
				
			||||||
      <span class="text-xl font-semibold text-gray-800">
 | 
					      <span class="text-xl font-semibold">
 | 
				
			||||||
        {{ title }}
 | 
					        {{ title }}
 | 
				
			||||||
      </span>
 | 
					      </span>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,6 @@ import {
 | 
				
			|||||||
  SidebarHeader,
 | 
					  SidebarHeader,
 | 
				
			||||||
  SidebarInset,
 | 
					  SidebarInset,
 | 
				
			||||||
  SidebarMenu,
 | 
					  SidebarMenu,
 | 
				
			||||||
  SidebarSeparator,
 | 
					 | 
				
			||||||
  SidebarMenuAction,
 | 
					  SidebarMenuAction,
 | 
				
			||||||
  SidebarMenuButton,
 | 
					  SidebarMenuButton,
 | 
				
			||||||
  SidebarMenuItem,
 | 
					  SidebarMenuItem,
 | 
				
			||||||
@@ -28,10 +27,10 @@ import {
 | 
				
			|||||||
  ChevronRight,
 | 
					  ChevronRight,
 | 
				
			||||||
  EllipsisVertical,
 | 
					  EllipsisVertical,
 | 
				
			||||||
  User,
 | 
					  User,
 | 
				
			||||||
  UserSearch,
 | 
					 | 
				
			||||||
  UsersRound,
 | 
					 | 
				
			||||||
  Search,
 | 
					  Search,
 | 
				
			||||||
  Plus
 | 
					  Plus,
 | 
				
			||||||
 | 
					  CircleDashed,
 | 
				
			||||||
 | 
					  List
 | 
				
			||||||
} from 'lucide-vue-next'
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
@@ -98,24 +97,25 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
        <SidebarHeader>
 | 
					        <SidebarHeader>
 | 
				
			||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton :isActive="isActiveParent('/contacts')" asChild>
 | 
					              <div class="px-1">
 | 
				
			||||||
                <div>
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  <span class="font-semibold text-xl">
 | 
					                  {{ t('globals.terms.contact', 2) }}
 | 
				
			||||||
                    {{ t('globals.terms.contact', 2) }}
 | 
					                </span>
 | 
				
			||||||
                  </span>
 | 
					              </div>
 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </SidebarMenuButton>
 | 
					 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
        </SidebarHeader>
 | 
					        </SidebarHeader>
 | 
				
			||||||
        <SidebarSeparator />
 | 
					 | 
				
			||||||
        <SidebarContent>
 | 
					        <SidebarContent>
 | 
				
			||||||
          <SidebarGroup>
 | 
					          <SidebarGroup>
 | 
				
			||||||
            <SidebarMenu>
 | 
					            <SidebarMenu>
 | 
				
			||||||
              <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
 | 
					              <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
 | 
				
			||||||
                <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
 | 
					                <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
 | 
				
			||||||
                  <router-link :to="item.href">
 | 
					                  <router-link :to="item.href">
 | 
				
			||||||
                    <span>{{ t(item.titleKey) }}</span>
 | 
					                    <span>{{
 | 
				
			||||||
 | 
					                      t('globals.messages.all', {
 | 
				
			||||||
 | 
					                        name: t(item.titleKey, 2).toLowerCase()
 | 
				
			||||||
 | 
					                      })
 | 
				
			||||||
 | 
					                    }}</span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </router-link>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
@@ -137,17 +137,14 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
        <SidebarHeader>
 | 
					        <SidebarHeader>
 | 
				
			||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
 | 
					              <div class="px-1">
 | 
				
			||||||
                <div>
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  <span class="font-semibold text-xl">
 | 
					                  {{ t('globals.terms.report', 2) }}
 | 
				
			||||||
                    {{ t('navigation.reports') }}
 | 
					                </span>
 | 
				
			||||||
                  </span>
 | 
					              </div>
 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </SidebarMenuButton>
 | 
					 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
        </SidebarHeader>
 | 
					        </SidebarHeader>
 | 
				
			||||||
        <SidebarSeparator />
 | 
					 | 
				
			||||||
        <SidebarContent>
 | 
					        <SidebarContent>
 | 
				
			||||||
          <SidebarGroup>
 | 
					          <SidebarGroup>
 | 
				
			||||||
            <SidebarMenu>
 | 
					            <SidebarMenu>
 | 
				
			||||||
@@ -171,21 +168,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
        <SidebarHeader>
 | 
					        <SidebarHeader>
 | 
				
			||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
 | 
					              <div class="flex flex-col items-start justify-between w-full px-1">
 | 
				
			||||||
                <div class="flex items-center justify-between w-full">
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  <span class="font-semibold text-xl">
 | 
					                  {{ t('globals.terms.admin') }}
 | 
				
			||||||
                    {{ t('navigation.admin') }}
 | 
					                </span>
 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <!-- App version -->
 | 
					                <!-- App version -->
 | 
				
			||||||
                <div class="text-xs text-muted-foreground ml-2">
 | 
					                <div class="text-xs text-muted-foreground">
 | 
				
			||||||
                  ({{ settingsStore.settings['app.version'] }})
 | 
					                  ({{ settingsStore.settings['app.version'] }})
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              </SidebarMenuButton>
 | 
					              </div>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
        </SidebarHeader>
 | 
					        </SidebarHeader>
 | 
				
			||||||
        <SidebarSeparator />
 | 
					 | 
				
			||||||
        <SidebarContent>
 | 
					        <SidebarContent>
 | 
				
			||||||
          <SidebarGroup>
 | 
					          <SidebarGroup>
 | 
				
			||||||
            <SidebarMenu>
 | 
					            <SidebarMenu>
 | 
				
			||||||
@@ -239,17 +233,14 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
        <SidebarHeader>
 | 
					        <SidebarHeader>
 | 
				
			||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
 | 
					              <div class="px-1">
 | 
				
			||||||
                <div>
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  <span class="font-semibold text-xl">
 | 
					                  {{ t('globals.terms.account') }}
 | 
				
			||||||
                    {{ t('navigation.account') }}
 | 
					                </span>
 | 
				
			||||||
                  </span>
 | 
					              </div>
 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </SidebarMenuButton>
 | 
					 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
        </SidebarHeader>
 | 
					        </SidebarHeader>
 | 
				
			||||||
        <SidebarSeparator />
 | 
					 | 
				
			||||||
        <SidebarContent>
 | 
					        <SidebarContent>
 | 
				
			||||||
          <SidebarGroup>
 | 
					          <SidebarGroup>
 | 
				
			||||||
            <SidebarMenu>
 | 
					            <SidebarMenu>
 | 
				
			||||||
@@ -276,28 +267,20 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
        <SidebarHeader>
 | 
					        <SidebarHeader>
 | 
				
			||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton asChild>
 | 
					              <div class="flex items-center justify-between w-full px-1">
 | 
				
			||||||
                <div class="flex items-center justify-between w-full">
 | 
					                <div class="font-semibold text-xl">
 | 
				
			||||||
                  <div class="font-semibold text-xl">
 | 
					                  <span>{{ t('globals.terms.inbox') }}</span>
 | 
				
			||||||
                    <span>{{ t('navigation.inbox') }}</span>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                  <div class="ml-auto">
 | 
					 | 
				
			||||||
                    <div class="flex items-center space-x-2">
 | 
					 | 
				
			||||||
                      <router-link :to="{ name: 'search' }">
 | 
					 | 
				
			||||||
                        <button
 | 
					 | 
				
			||||||
                          class="flex items-center bg-accent p-2 rounded-full hover:scale-110 transition-transform duration-100"
 | 
					 | 
				
			||||||
                        >
 | 
					 | 
				
			||||||
                          <Search size="15" stroke-width="2.5" />
 | 
					 | 
				
			||||||
                        </button>
 | 
					 | 
				
			||||||
                      </router-link>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              </SidebarMenuButton>
 | 
					                <div class="mr-1 mt-1 hover:scale-110 transition-transform">
 | 
				
			||||||
 | 
					                  <router-link :to="{ name: 'search' }">
 | 
				
			||||||
 | 
					                    <Search size="18" stroke-width="2.5" />
 | 
				
			||||||
 | 
					                  </router-link>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
        </SidebarHeader>
 | 
					        </SidebarHeader>
 | 
				
			||||||
        <SidebarSeparator />
 | 
					
 | 
				
			||||||
        <SidebarContent>
 | 
					        <SidebarContent>
 | 
				
			||||||
          <SidebarGroup>
 | 
					          <SidebarGroup>
 | 
				
			||||||
            <SidebarMenu>
 | 
					            <SidebarMenu>
 | 
				
			||||||
@@ -319,7 +302,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
 | 
					                  <router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
 | 
				
			||||||
                    <User />
 | 
					                    <User />
 | 
				
			||||||
                    <span>{{ t('navigation.myInbox') }}</span>
 | 
					                    <span>{{ t('globals.terms.myInbox') }}</span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </router-link>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
@@ -327,9 +310,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
              <SidebarMenuItem>
 | 
					              <SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
 | 
					                  <router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
 | 
				
			||||||
                    <UserSearch />
 | 
					                    <CircleDashed />
 | 
				
			||||||
                    <span>
 | 
					                    <span>
 | 
				
			||||||
                      {{ t('navigation.unassigned') }}
 | 
					                      {{ t('globals.terms.unassigned') }}
 | 
				
			||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </router-link>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
@@ -338,9 +321,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
              <SidebarMenuItem>
 | 
					              <SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'all' } }">
 | 
					                  <router-link :to="{ name: 'inbox', params: { type: 'all' } }">
 | 
				
			||||||
                    <UsersRound />
 | 
					                    <List />
 | 
				
			||||||
                    <span>
 | 
					                    <span>
 | 
				
			||||||
                      {{ t('navigation.all') }}
 | 
					                      {{ t('globals.messages.all') }}
 | 
				
			||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </router-link>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
@@ -359,7 +342,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                      <router-link to="#">
 | 
					                      <router-link to="#">
 | 
				
			||||||
                        <!-- <Users /> -->
 | 
					                        <!-- <Users /> -->
 | 
				
			||||||
                        <span>
 | 
					                        <span>
 | 
				
			||||||
                          {{ t('navigation.teamInboxes') }}
 | 
					                          {{ t('globals.terms.teamInbox', 2) }}
 | 
				
			||||||
                        </span>
 | 
					                        </span>
 | 
				
			||||||
                        <ChevronRight
 | 
					                        <ChevronRight
 | 
				
			||||||
                          class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
					                          class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
				
			||||||
@@ -388,18 +371,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
              <!-- Views -->
 | 
					              <!-- Views -->
 | 
				
			||||||
              <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
 | 
					              <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem>
 | 
				
			||||||
                  <CollapsibleTrigger as-child>
 | 
					                  <CollapsibleTrigger asChild>
 | 
				
			||||||
                    <SidebarMenuButton asChild>
 | 
					                    <SidebarMenuButton asChild>
 | 
				
			||||||
                      <router-link to="#" class="group/item">
 | 
					                      <router-link to="#" class="group/item !p-2">
 | 
				
			||||||
                        <!-- <SlidersHorizontal /> -->
 | 
					                        <!-- <SlidersHorizontal /> -->
 | 
				
			||||||
                        <span>
 | 
					                        <span>
 | 
				
			||||||
                          {{ t('navigation.views') }}
 | 
					                          {{ t('globals.terms.view', 2) }}
 | 
				
			||||||
                        </span>
 | 
					                        </span>
 | 
				
			||||||
                        <div>
 | 
					                        <div>
 | 
				
			||||||
                          <Plus
 | 
					                          <Plus
 | 
				
			||||||
                            size="18"
 | 
					                            size="18"
 | 
				
			||||||
                            @click.stop="openCreateViewDialog"
 | 
					                            @click.stop="openCreateViewDialog"
 | 
				
			||||||
                            class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
 | 
					                            class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                        <ChevronRight
 | 
					                        <ChevronRight
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,12 +2,12 @@
 | 
				
			|||||||
  <DropdownMenu>
 | 
					  <DropdownMenu>
 | 
				
			||||||
    <DropdownMenuTrigger as-child>
 | 
					    <DropdownMenuTrigger as-child>
 | 
				
			||||||
      <SidebarMenuButton
 | 
					      <SidebarMenuButton
 | 
				
			||||||
        size="lg"
 | 
					        size="md"
 | 
				
			||||||
        class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
 | 
					        class="p-0"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
 | 
					        <Avatar class="h-8 w-8 rounded relative overflow-visible">
 | 
				
			||||||
          <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
 | 
					          <AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
 | 
				
			||||||
          <AvatarFallback class="rounded-lg">
 | 
					          <AvatarFallback class="rounded">
 | 
				
			||||||
            {{ userStore.getInitials }}
 | 
					            {{ userStore.getInitials }}
 | 
				
			||||||
          </AvatarFallback>
 | 
					          </AvatarFallback>
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
@@ -30,51 +30,65 @@
 | 
				
			|||||||
      </SidebarMenuButton>
 | 
					      </SidebarMenuButton>
 | 
				
			||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent
 | 
					    <DropdownMenuContent
 | 
				
			||||||
      class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
 | 
					      class="w-[--radix-dropdown-menu-trigger-width] min-w-56"
 | 
				
			||||||
      side="bottom"
 | 
					      side="bottom"
 | 
				
			||||||
      :side-offset="4"
 | 
					      :side-offset="4"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <DropdownMenuLabel class="p-0 font-normal space-y-1">
 | 
					      <DropdownMenuLabel class="font-normal space-y-2 px-2">
 | 
				
			||||||
        <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
 | 
					        <!-- User header -->
 | 
				
			||||||
          <Avatar class="h-8 w-8 rounded-lg">
 | 
					        <div class="flex items-center gap-2 py-1.5 text-left text-sm">
 | 
				
			||||||
 | 
					          <Avatar class="h-8 w-8 rounded">
 | 
				
			||||||
            <AvatarImage :src="userStore.avatar" alt="U" />
 | 
					            <AvatarImage :src="userStore.avatar" alt="U" />
 | 
				
			||||||
            <AvatarFallback class="rounded-lg">
 | 
					            <AvatarFallback class="rounded">
 | 
				
			||||||
              {{ userStore.getInitials }}
 | 
					              {{ userStore.getInitials }}
 | 
				
			||||||
            </AvatarFallback>
 | 
					            </AvatarFallback>
 | 
				
			||||||
          </Avatar>
 | 
					          </Avatar>
 | 
				
			||||||
          <div class="grid flex-1 text-left text-sm leading-tight">
 | 
					          <div class="flex-1 flex flex-col leading-tight">
 | 
				
			||||||
            <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
					            <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
				
			||||||
            <span class="truncate text-xs">{{ userStore.email }}</span>
 | 
					            <span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="space-y-2">
 | 
					        <div class="space-y-2">
 | 
				
			||||||
          <!-- Away switch is checked with 'away_manual' or 'away_and_reassigning' -->
 | 
					          <!-- Dark-mode toggle -->
 | 
				
			||||||
          <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
 | 
					          <div class="flex items-center justify-between text-sm">
 | 
				
			||||||
            <span class="text-muted-foreground">{{ t('navigation.away') }}</span>
 | 
					            <div class="flex items-center gap-2">
 | 
				
			||||||
 | 
					              <Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
 | 
				
			||||||
 | 
					              <Sun v-else size="16" class="text-muted-foreground" />
 | 
				
			||||||
 | 
					              <span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
            <Switch
 | 
					            <Switch
 | 
				
			||||||
              :checked="
 | 
					              :checked="mode === 'dark'"
 | 
				
			||||||
                ['away_manual', 'away_and_reassigning'].includes(userStore.user.availability_status)
 | 
					              @update:checked="(val) => (mode = val ? 'dark' : 'light')"
 | 
				
			||||||
              "
 | 
					 | 
				
			||||||
              @update:checked="
 | 
					 | 
				
			||||||
                (val) => {
 | 
					 | 
				
			||||||
                  const newStatus = val ? 'away_manual' : 'online'
 | 
					 | 
				
			||||||
                  userStore.updateUserAvailability(newStatus)
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
              "
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <!-- Reassign Replies Switch is checked with 'away_and_reassigning' -->
 | 
					
 | 
				
			||||||
          <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
 | 
					          <div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3">
 | 
				
			||||||
            <span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
 | 
					            <!-- Away toggle -->
 | 
				
			||||||
            <Switch
 | 
					            <div class="flex items-center justify-between text-sm">
 | 
				
			||||||
              :checked="userStore.user.availability_status === 'away_and_reassigning'"
 | 
					              <span class="text-muted-foreground">{{ t('navigation.away') }}</span>
 | 
				
			||||||
              @update:checked="
 | 
					              <Switch
 | 
				
			||||||
                (val) => {
 | 
					                :checked="
 | 
				
			||||||
                  const newStatus = val ? 'away_and_reassigning' : 'away_manual'
 | 
					                  ['away_manual', 'away_and_reassigning'].includes(
 | 
				
			||||||
                  userStore.updateUserAvailability(newStatus)
 | 
					                    userStore.user.availability_status
 | 
				
			||||||
                }
 | 
					                  )
 | 
				
			||||||
              "
 | 
					                "
 | 
				
			||||||
            />
 | 
					                @update:checked="
 | 
				
			||||||
 | 
					                  (val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online')
 | 
				
			||||||
 | 
					                "
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <!-- Reassign toggle -->
 | 
				
			||||||
 | 
					            <div class="flex items-center justify-between text-sm">
 | 
				
			||||||
 | 
					              <span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
 | 
				
			||||||
 | 
					              <Switch
 | 
				
			||||||
 | 
					                :checked="userStore.user.availability_status === 'away_and_reassigning'"
 | 
				
			||||||
 | 
					                @update:checked="
 | 
				
			||||||
 | 
					                  (val) =>
 | 
				
			||||||
 | 
					                    userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual')
 | 
				
			||||||
 | 
					                "
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </DropdownMenuLabel>
 | 
					      </DropdownMenuLabel>
 | 
				
			||||||
@@ -82,7 +96,7 @@
 | 
				
			|||||||
      <DropdownMenuGroup>
 | 
					      <DropdownMenuGroup>
 | 
				
			||||||
        <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
 | 
					        <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
 | 
				
			||||||
          <CircleUserRound size="18" class="mr-2" />
 | 
					          <CircleUserRound size="18" class="mr-2" />
 | 
				
			||||||
          {{ t('navigation.account') }}
 | 
					          {{ t('globals.terms.account') }}
 | 
				
			||||||
        </DropdownMenuItem>
 | 
					        </DropdownMenuItem>
 | 
				
			||||||
      </DropdownMenuGroup>
 | 
					      </DropdownMenuGroup>
 | 
				
			||||||
      <DropdownMenuSeparator />
 | 
					      <DropdownMenuSeparator />
 | 
				
			||||||
@@ -108,10 +122,13 @@ import {
 | 
				
			|||||||
import { SidebarMenuButton } from '@/components/ui/sidebar'
 | 
					import { SidebarMenuButton } from '@/components/ui/sidebar'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			||||||
import { Switch } from '@/components/ui/switch'
 | 
					import { Switch } from '@/components/ui/switch'
 | 
				
			||||||
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
 | 
					import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useColorMode } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mode = useColorMode()
 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,24 +1,24 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <table class="min-w-full table-fixed divide-y divide-gray-200">
 | 
					  <table class="min-w-full table-fixed divide-y divide-border">
 | 
				
			||||||
    <thead class="bg-gray-50">
 | 
					    <thead class="bg-muted">
 | 
				
			||||||
      <tr>
 | 
					      <tr>
 | 
				
			||||||
        <th
 | 
					        <th
 | 
				
			||||||
          v-for="(header, index) in headers"
 | 
					          v-for="(header, index) in headers"
 | 
				
			||||||
          :key="index"
 | 
					          :key="index"
 | 
				
			||||||
          scope="col"
 | 
					          scope="col"
 | 
				
			||||||
          class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
 | 
					          class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          {{ header }}
 | 
					          {{ header }}
 | 
				
			||||||
        </th>
 | 
					        </th>
 | 
				
			||||||
        <th scope="col" class="relative px-6 py-3"></th>
 | 
					        <th scope="col" class="relative px-6 py-3"></th>
 | 
				
			||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody class="bg-white divide-y divide-gray-200">
 | 
					    <tbody class="bg-background divide-y divide-border">
 | 
				
			||||||
      <template v-if="data.length === 0">
 | 
					      <template v-if="data.length === 0">
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <td :colspan="headers.length + 1" class="px-6 py-12 text-center">
 | 
					          <td :colspan="headers.length + 1" class="px-6 py-12 text-center">
 | 
				
			||||||
            <div class="flex flex-col items-center space-y-4">
 | 
					            <div class="flex flex-col items-center space-y-4">
 | 
				
			||||||
              <span class="text-md text-gray-500">
 | 
					              <span class="text-md text-muted-foreground">
 | 
				
			||||||
                {{
 | 
					                {{
 | 
				
			||||||
                  $t('globals.messages.noResults', {
 | 
					                  $t('globals.messages.noResults', {
 | 
				
			||||||
                    name: $t('globals.terms.result', 2).toLowerCase()
 | 
					                    name: $t('globals.terms.result', 2).toLowerCase()
 | 
				
			||||||
@@ -30,15 +30,15 @@
 | 
				
			|||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
      <template v-else>
 | 
					      <template v-else>
 | 
				
			||||||
        <tr v-for="(item, index) in data" :key="index">
 | 
					        <tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
 | 
				
			||||||
          <td
 | 
					          <td
 | 
				
			||||||
            v-for="key in keys"
 | 
					            v-for="key in keys"
 | 
				
			||||||
            :key="key"
 | 
					            :key="key"
 | 
				
			||||||
            class="px-6 py-4 text-sm font-medium text-gray-900 whitespace-normal break-words"
 | 
					            class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words"
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {{ item[key] }}
 | 
					            {{ item[key] }}
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td class="px-6 py-4 text-sm text-gray-500" v-if="showDelete">
 | 
					          <td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
 | 
				
			||||||
            <Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
 | 
					            <Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
 | 
				
			||||||
              <Trash2 class="h-4 w-4" />
 | 
					              <Trash2 class="h-4 w-4" />
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <!-- Delete Icon -->
 | 
					    <!-- Delete Icon -->
 | 
				
			||||||
    <X
 | 
					    <X
 | 
				
			||||||
      class="absolute top-1 right-1 bg-white rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
 | 
					      class="absolute top-1 right-1 rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
 | 
				
			||||||
      size="20"
 | 
					      size="20"
 | 
				
			||||||
      @click.stop="emit('remove')"
 | 
					      @click.stop="emit('remove')"
 | 
				
			||||||
      v-if="src"
 | 
					      v-if="src"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,25 +1,16 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Primitive } from 'radix-vue'
 | 
					import { Primitive } from 'reka-ui'
 | 
				
			||||||
import { buttonVariants } from '.'
 | 
					 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils'
 | 
				
			||||||
import { ref, computed } from 'vue'
 | 
					import { buttonVariants } from '.'
 | 
				
			||||||
import { DotLoader } from '@/components/ui/loader'
 | 
					import { Loader2 } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  variant: { type: null, required: false },
 | 
					  variant: { type: null, required: false },
 | 
				
			||||||
  size: { type: null, required: false },
 | 
					  size: { type: null, required: false },
 | 
				
			||||||
  class: { type: null, required: false },
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false, default: 'button' },
 | 
					  as: { type: null, required: false, default: 'button' },
 | 
				
			||||||
  isLoading: { type: Boolean, required: false, default: false }
 | 
					  isLoading: { type: Boolean, required: false, default: false },
 | 
				
			||||||
})
 | 
					  disabled: { type: Boolean, required: false, default: false }
 | 
				
			||||||
 | 
					 | 
				
			||||||
const isDisabled = ref(false)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const computedClass = computed(() => {
 | 
					 | 
				
			||||||
  return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class, {
 | 
					 | 
				
			||||||
    'cursor-not-allowed opacity-50': props.isLoading || isDisabled.value
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -27,10 +18,22 @@ const computedClass = computed(() => {
 | 
				
			|||||||
  <Primitive
 | 
					  <Primitive
 | 
				
			||||||
    :as="as"
 | 
					    :as="as"
 | 
				
			||||||
    :as-child="asChild"
 | 
					    :as-child="asChild"
 | 
				
			||||||
    :class="computedClass"
 | 
					    :class="
 | 
				
			||||||
    :disabled="isLoading || isDisabled"
 | 
					      cn(
 | 
				
			||||||
 | 
					        buttonVariants({ variant, size }),
 | 
				
			||||||
 | 
					        'relative',
 | 
				
			||||||
 | 
					        { 'text-transparent': isLoading },
 | 
				
			||||||
 | 
					        props.class
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
 | 
					    :disabled="isLoading || disabled"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <DotLoader v-if="isLoading" />
 | 
					    <slot />
 | 
				
			||||||
    <slot v-else />
 | 
					    <span
 | 
				
			||||||
 | 
					      v-if="isLoading"
 | 
				
			||||||
 | 
					      class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Loader2 class="h-5 w-5 animate-spin" />
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
  </Primitive>
 | 
					  </Primitive>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,31 +1,34 @@
 | 
				
			|||||||
import { cva } from 'class-variance-authority'
 | 
					import { cva } from 'class-variance-authority';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { default as Button } from './Button.vue'
 | 
					export { default as Button } from './Button.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const buttonVariants = cva(
 | 
					export const buttonVariants = cva(
 | 
				
			||||||
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
 | 
					  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    variants: {
 | 
					    variants: {
 | 
				
			||||||
      variant: {
 | 
					      variant: {
 | 
				
			||||||
        default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
 | 
					        default:
 | 
				
			||||||
        destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
 | 
					          'bg-primary text-primary-foreground shadow hover:bg-primary/90',
 | 
				
			||||||
 | 
					        destructive:
 | 
				
			||||||
 | 
					          'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
 | 
				
			||||||
        outline:
 | 
					        outline:
 | 
				
			||||||
          'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
 | 
					          'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
 | 
				
			||||||
        secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
 | 
					        secondary:
 | 
				
			||||||
 | 
					          'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
 | 
				
			||||||
        ghost: 'hover:bg-accent hover:text-accent-foreground',
 | 
					        ghost: 'hover:bg-accent hover:text-accent-foreground',
 | 
				
			||||||
        link: 'text-primary underline-offset-4 hover:underline'
 | 
					        link: 'text-primary underline-offset-4 hover:underline',
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      size: {
 | 
					      size: {
 | 
				
			||||||
        default: 'h-9 px-4 py-2',
 | 
					        default: 'h-9 px-4 py-2',
 | 
				
			||||||
        xs: 'h-7 rounded px-2',
 | 
					        xs: 'h-7 rounded px-2',
 | 
				
			||||||
        sm: 'h-8 rounded-md px-3 text-xs',
 | 
					        sm: 'h-8 rounded-md px-3 text-xs',
 | 
				
			||||||
        lg: 'h-10 rounded-md px-8',
 | 
					        lg: 'h-10 rounded-md px-8',
 | 
				
			||||||
        icon: 'h-9 w-9'
 | 
					        icon: 'h-9 w-9',
 | 
				
			||||||
      }
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    defaultVariants: {
 | 
					    defaultVariants: {
 | 
				
			||||||
      variant: 'default',
 | 
					      variant: 'default',
 | 
				
			||||||
      size: 'default'
 | 
					      size: 'default',
 | 
				
			||||||
    }
 | 
					    },
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
)
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,19 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { useVModel } from '@vueuse/core'
 | 
					import { useVModel } from '@vueuse/core';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  defaultValue: { type: [String, Number], required: false },
 | 
					  defaultValue: { type: [String, Number], required: false },
 | 
				
			||||||
  modelValue: { type: [String, Number], required: false },
 | 
					  modelValue: { type: [String, Number], required: false },
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emits = defineEmits(['update:modelValue'])
 | 
					const emits = defineEmits(['update:modelValue']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const modelValue = useVModel(props, 'modelValue', emits, {
 | 
					const modelValue = useVModel(props, 'modelValue', emits, {
 | 
				
			||||||
  passive: true,
 | 
					  passive: true,
 | 
				
			||||||
  defaultValue: props.defaultValue
 | 
					  defaultValue: props.defaultValue,
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
 | 
				
			|||||||
    :class="
 | 
					    :class="
 | 
				
			||||||
      cn(
 | 
					      cn(
 | 
				
			||||||
        'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
 | 
					        'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
 | 
				
			||||||
        props.class
 | 
					        props.class,
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    "
 | 
					    "
 | 
				
			||||||
  />
 | 
					  />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +1 @@
 | 
				
			|||||||
export { default as Input } from './Input.vue'
 | 
					export { default as Input } from './Input.vue';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <span class="dot-loader">
 | 
					  <span class="inline-flex items-center">
 | 
				
			||||||
    <span class="dot"></span>
 | 
					    <span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span>
 | 
				
			||||||
    <span class="dot"></span>
 | 
					    <span
 | 
				
			||||||
    <span class="dot"></span>
 | 
					      class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]"
 | 
				
			||||||
 | 
					    ></span>
 | 
				
			||||||
 | 
					    <span
 | 
				
			||||||
 | 
					      class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
 | 
				
			||||||
 | 
					    ></span>
 | 
				
			||||||
  </span>
 | 
					  </span>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,21 +1,17 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { reactiveOmit } from '@vueuse/core';
 | 
				
			||||||
import { Separator } from 'radix-vue'
 | 
					import { Separator } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  orientation: { type: String, required: false },
 | 
					  orientation: { type: String, required: false, default: 'horizontal' },
 | 
				
			||||||
  decorative: { type: Boolean, required: false },
 | 
					  decorative: { type: Boolean, required: false, default: true },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false },
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = reactiveOmit(props, 'class');
 | 
				
			||||||
  const { class: _, ...delegated } = props
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return delegated
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -24,8 +20,8 @@ const delegatedProps = computed(() => {
 | 
				
			|||||||
    :class="
 | 
					    :class="
 | 
				
			||||||
      cn(
 | 
					      cn(
 | 
				
			||||||
        'shrink-0 bg-border',
 | 
					        'shrink-0 bg-border',
 | 
				
			||||||
        props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
 | 
					        props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
 | 
				
			||||||
        props.class
 | 
					        props.class,
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    "
 | 
					    "
 | 
				
			||||||
  />
 | 
					  />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +1 @@
 | 
				
			|||||||
export { default as Separator } from './Separator.vue'
 | 
					export { default as Separator } from './Separator.vue';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,14 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { DialogRoot, useForwardPropsEmits } from 'radix-vue'
 | 
					import { DialogRoot, useForwardPropsEmits } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  open: { type: Boolean, required: false },
 | 
					  open: { type: Boolean, required: false },
 | 
				
			||||||
  defaultOpen: { type: Boolean, required: false },
 | 
					  defaultOpen: { type: Boolean, required: false },
 | 
				
			||||||
  modal: { type: Boolean, required: false }
 | 
					  modal: { type: Boolean, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
const emits = defineEmits(['update:open'])
 | 
					const emits = defineEmits(['update:open']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const forwarded = useForwardPropsEmits(props, emits)
 | 
					const forwarded = useForwardPropsEmits(props, emits);
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { DialogClose } from 'radix-vue'
 | 
					import { DialogClose } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false }
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,19 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { reactiveOmit } from '@vueuse/core';
 | 
				
			||||||
 | 
					import { Cross2Icon } from '@radix-icons/vue';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DialogClose,
 | 
					  DialogClose,
 | 
				
			||||||
  DialogContent,
 | 
					  DialogContent,
 | 
				
			||||||
  DialogOverlay,
 | 
					  DialogOverlay,
 | 
				
			||||||
  DialogPortal,
 | 
					  DialogPortal,
 | 
				
			||||||
  useForwardPropsEmits
 | 
					  useForwardPropsEmits,
 | 
				
			||||||
} from 'radix-vue'
 | 
					} from 'reka-ui';
 | 
				
			||||||
import { Cross2Icon } from '@radix-icons/vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { sheetVariants } from '.'
 | 
					import { sheetVariants } from '.';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineOptions({
 | 
					defineOptions({
 | 
				
			||||||
  inheritAttrs: false
 | 
					  inheritAttrs: false,
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  class: { type: null, required: false },
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
@@ -22,8 +22,8 @@ const props = defineProps({
 | 
				
			|||||||
  trapFocus: { type: Boolean, required: false },
 | 
					  trapFocus: { type: Boolean, required: false },
 | 
				
			||||||
  disableOutsidePointerEvents: { type: Boolean, required: false },
 | 
					  disableOutsidePointerEvents: { type: Boolean, required: false },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false }
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emits = defineEmits([
 | 
					const emits = defineEmits([
 | 
				
			||||||
  'escapeKeyDown',
 | 
					  'escapeKeyDown',
 | 
				
			||||||
@@ -31,16 +31,12 @@ const emits = defineEmits([
 | 
				
			|||||||
  'focusOutside',
 | 
					  'focusOutside',
 | 
				
			||||||
  'interactOutside',
 | 
					  'interactOutside',
 | 
				
			||||||
  'openAutoFocus',
 | 
					  'openAutoFocus',
 | 
				
			||||||
  'closeAutoFocus'
 | 
					  'closeAutoFocus',
 | 
				
			||||||
])
 | 
					]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = reactiveOmit(props, 'class', 'side');
 | 
				
			||||||
  const { class: _, side, ...delegated } = props
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return delegated
 | 
					const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,15 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { reactiveOmit } from '@vueuse/core';
 | 
				
			||||||
import { DialogDescription } from 'radix-vue'
 | 
					import { DialogDescription } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false },
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = reactiveOmit(props, 'class');
 | 
				
			||||||
  const { class: _, ...delegated } = props
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return delegated
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,20 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
 | 
					  <div
 | 
				
			||||||
 | 
					    :class="
 | 
				
			||||||
 | 
					      cn(
 | 
				
			||||||
 | 
					        'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
 | 
				
			||||||
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,15 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
 | 
					  <div
 | 
				
			||||||
 | 
					    :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,15 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { reactiveOmit } from '@vueuse/core';
 | 
				
			||||||
import { DialogTitle } from 'radix-vue'
 | 
					import { DialogTitle } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false },
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = reactiveOmit(props, 'class');
 | 
				
			||||||
  const { class: _, ...delegated } = props
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return delegated
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { DialogTrigger } from 'radix-vue'
 | 
					import { DialogTrigger } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false }
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,13 @@
 | 
				
			|||||||
import { cva } from 'class-variance-authority'
 | 
					import { cva } from 'class-variance-authority';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export { default as Sheet } from './Sheet.vue'
 | 
					export { default as Sheet } from './Sheet.vue';
 | 
				
			||||||
export { default as SheetTrigger } from './SheetTrigger.vue'
 | 
					export { default as SheetClose } from './SheetClose.vue';
 | 
				
			||||||
export { default as SheetClose } from './SheetClose.vue'
 | 
					export { default as SheetContent } from './SheetContent.vue';
 | 
				
			||||||
export { default as SheetContent } from './SheetContent.vue'
 | 
					export { default as SheetDescription } from './SheetDescription.vue';
 | 
				
			||||||
export { default as SheetHeader } from './SheetHeader.vue'
 | 
					export { default as SheetFooter } from './SheetFooter.vue';
 | 
				
			||||||
export { default as SheetTitle } from './SheetTitle.vue'
 | 
					export { default as SheetHeader } from './SheetHeader.vue';
 | 
				
			||||||
export { default as SheetDescription } from './SheetDescription.vue'
 | 
					export { default as SheetTitle } from './SheetTitle.vue';
 | 
				
			||||||
export { default as SheetFooter } from './SheetFooter.vue'
 | 
					export { default as SheetTrigger } from './SheetTrigger.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const sheetVariants = cva(
 | 
					export const sheetVariants = cva(
 | 
				
			||||||
  'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
 | 
					  'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
 | 
				
			||||||
@@ -19,11 +19,11 @@ export const sheetVariants = cva(
 | 
				
			|||||||
          'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
 | 
					          'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
 | 
				
			||||||
        left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
 | 
					        left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
 | 
				
			||||||
        right:
 | 
					        right:
 | 
				
			||||||
          'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
 | 
					          'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
 | 
				
			||||||
      }
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    defaultVariants: {
 | 
					    defaultVariants: {
 | 
				
			||||||
      side: 'right'
 | 
					      side: 'right',
 | 
				
			||||||
    }
 | 
					    },
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
)
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
 | 
					 | 
				
			||||||
import { cn } from '@/lib/utils';
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					import { Sheet, SheetContent } from '@/components/ui/sheet';
 | 
				
			||||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils';
 | 
					import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineOptions({
 | 
					defineOptions({
 | 
				
			||||||
@@ -12,7 +12,6 @@ const props = defineProps({
 | 
				
			|||||||
  variant: { type: String, required: false, default: 'sidebar' },
 | 
					  variant: { type: String, required: false, default: 'sidebar' },
 | 
				
			||||||
  collapsible: { type: String, required: false, default: 'offcanvas' },
 | 
					  collapsible: { type: String, required: false, default: 'offcanvas' },
 | 
				
			||||||
  class: { type: null, required: false },
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
  collapseOnMobile: { type: Boolean, required: false, default: true },
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
 | 
					const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
 | 
				
			||||||
@@ -33,7 +32,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <Sheet
 | 
					  <Sheet
 | 
				
			||||||
    v-else-if="isMobile && collapseOnMobile"
 | 
					    v-else-if="isMobile"
 | 
				
			||||||
    :open="openMobile"
 | 
					    :open="openMobile"
 | 
				
			||||||
    v-bind="$attrs"
 | 
					    v-bind="$attrs"
 | 
				
			||||||
    @update:open="setOpenMobile"
 | 
					    @update:open="setOpenMobile"
 | 
				
			||||||
@@ -55,7 +54,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  <div
 | 
					  <div
 | 
				
			||||||
    v-else
 | 
					    v-else
 | 
				
			||||||
    :class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
 | 
					    class="group peer hidden md:block"
 | 
				
			||||||
    :data-state="state"
 | 
					    :data-state="state"
 | 
				
			||||||
    :data-collapsible="state === 'collapsed' ? collapsible : ''"
 | 
					    :data-collapsible="state === 'collapsed' ? collapsible : ''"
 | 
				
			||||||
    :data-variant="variant"
 | 
					    :data-variant="variant"
 | 
				
			||||||
@@ -77,8 +76,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
 | 
				
			|||||||
    <div
 | 
					    <div
 | 
				
			||||||
      :class="
 | 
					      :class="
 | 
				
			||||||
        cn(
 | 
					        cn(
 | 
				
			||||||
          'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
 | 
					          'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
 | 
				
			||||||
          collapseOnMobile ? 'hidden' : '',
 | 
					 | 
				
			||||||
          side === 'left'
 | 
					          side === 'left'
 | 
				
			||||||
            ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
 | 
					            ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
 | 
				
			||||||
            : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
 | 
					            : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,12 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { PrimitiveProps } from 'radix-vue'
 | 
					import { Primitive } from 'reka-ui';
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
import { Primitive } from 'radix-vue'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<PrimitiveProps & {
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
}>()
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -14,12 +14,14 @@ const props = defineProps<PrimitiveProps & {
 | 
				
			|||||||
    data-sidebar="group-action"
 | 
					    data-sidebar="group-action"
 | 
				
			||||||
    :as="as"
 | 
					    :as="as"
 | 
				
			||||||
    :as-child="asChild"
 | 
					    :as-child="asChild"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
 | 
					      cn(
 | 
				
			||||||
      'after:absolute after:-inset-2 after:md:hidden',
 | 
					        'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
 | 
				
			||||||
      'group-data-[collapsible=icon]:hidden',
 | 
					        'after:absolute after:-inset-2 after:md:hidden',
 | 
				
			||||||
      props.class,
 | 
					        'group-data-[collapsible=icon]:hidden',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </Primitive>
 | 
					  </Primitive>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,13 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div
 | 
					  <div data-sidebar="group-content" :class="cn('w-full text-sm', props.class)">
 | 
				
			||||||
    data-sidebar="group-content"
 | 
					 | 
				
			||||||
    :class="cn('w-full text-sm', props.class)"
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,12 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { PrimitiveProps } from 'radix-vue'
 | 
					import { Primitive } from 'reka-ui';
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
import { Primitive } from 'radix-vue'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<PrimitiveProps & {
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
}>()
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -14,10 +14,13 @@ const props = defineProps<PrimitiveProps & {
 | 
				
			|||||||
    data-sidebar="group-label"
 | 
					    data-sidebar="group-label"
 | 
				
			||||||
    :as="as"
 | 
					    :as="as"
 | 
				
			||||||
    :as-child="asChild"
 | 
					    :as-child="asChild"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
 | 
					      cn(
 | 
				
			||||||
      'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
 | 
					        'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
 | 
				
			||||||
      props.class)"
 | 
					        'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
 | 
				
			||||||
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </Primitive>
 | 
					  </Primitive>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,9 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,20 +1,21 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Input
 | 
					  <Input
 | 
				
			||||||
    data-sidebar="input"
 | 
					    data-sidebar="input"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
 | 
					      cn(
 | 
				
			||||||
      props.class,
 | 
					        'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </Input>
 | 
					  </Input>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,20 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <main
 | 
					  <main
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'relative flex min-h-svh flex-1 flex-col bg-background',
 | 
					      cn(
 | 
				
			||||||
      'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
 | 
					        'relative flex min-h-svh flex-1 flex-col bg-background',
 | 
				
			||||||
      props.class,
 | 
					        'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </main>
 | 
					  </main>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,9 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,30 +1,31 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { Primitive } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { Primitive, type PrimitiveProps } from 'radix-vue'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<PrimitiveProps & {
 | 
					const props = defineProps({
 | 
				
			||||||
  showOnHover?: boolean
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  as: { type: null, required: false, default: 'button' },
 | 
				
			||||||
}>(), {
 | 
					  showOnHover: { type: Boolean, required: false },
 | 
				
			||||||
  as: 'button',
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Primitive
 | 
					  <Primitive
 | 
				
			||||||
    data-sidebar="menu-action"
 | 
					    data-sidebar="menu-action"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
 | 
					      cn(
 | 
				
			||||||
      'after:absolute after:-inset-2 after:md:hidden',
 | 
					        'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
 | 
				
			||||||
      'peer-data-[size=sm]/menu-button:top-1',
 | 
					        'after:absolute after:-inset-2 after:md:hidden',
 | 
				
			||||||
      'peer-data-[size=default]/menu-button:top-1.5',
 | 
					        'peer-data-[size=sm]/menu-button:top-1',
 | 
				
			||||||
      'peer-data-[size=lg]/menu-button:top-2.5',
 | 
					        'peer-data-[size=default]/menu-button:top-1.5',
 | 
				
			||||||
      'group-data-[collapsible=icon]:hidden',
 | 
					        'peer-data-[size=lg]/menu-button:top-2.5',
 | 
				
			||||||
      showOnHover
 | 
					        'group-data-[collapsible=icon]:hidden',
 | 
				
			||||||
        && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
 | 
					        showOnHover &&
 | 
				
			||||||
      props.class,
 | 
					          'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
    :as="as"
 | 
					    :as="as"
 | 
				
			||||||
    :as-child="asChild"
 | 
					    :as-child="asChild"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,24 +1,25 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div
 | 
					  <div
 | 
				
			||||||
    data-sidebar="menu-badge"
 | 
					    data-sidebar="menu-badge"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
 | 
					      cn(
 | 
				
			||||||
      'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
 | 
					        'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
 | 
				
			||||||
      'peer-data-[size=sm]/menu-button:top-1',
 | 
					        'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
 | 
				
			||||||
      'peer-data-[size=default]/menu-button:top-1.5',
 | 
					        'peer-data-[size=sm]/menu-button:top-1',
 | 
				
			||||||
      'peer-data-[size=lg]/menu-button:top-2.5',
 | 
					        'peer-data-[size=default]/menu-button:top-1.5',
 | 
				
			||||||
      'group-data-[collapsible=icon]:hidden',
 | 
					        'peer-data-[size=lg]/menu-button:top-2.5',
 | 
				
			||||||
      props.class,
 | 
					        'group-data-[collapsible=icon]:hidden',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,31 +1,40 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
					import { computed } from 'vue';
 | 
				
			||||||
import { type Component, computed } from 'vue'
 | 
					import {
 | 
				
			||||||
import SidebarMenuButtonChild, { type SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
 | 
					  Tooltip,
 | 
				
			||||||
import { useSidebar } from './utils'
 | 
					  TooltipContent,
 | 
				
			||||||
 | 
					  TooltipTrigger,
 | 
				
			||||||
 | 
					} from '@/components/ui/tooltip';
 | 
				
			||||||
 | 
					import SidebarMenuButtonChild from './SidebarMenuButtonChild.vue';
 | 
				
			||||||
 | 
					import { useSidebar } from './utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineOptions({
 | 
					defineOptions({
 | 
				
			||||||
  inheritAttrs: false,
 | 
					  inheritAttrs: false,
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
 | 
					const props = defineProps({
 | 
				
			||||||
  tooltip?: string | Component
 | 
					  variant: { type: null, required: false, default: 'default' },
 | 
				
			||||||
}>(), {
 | 
					  size: { type: null, required: false, default: 'default' },
 | 
				
			||||||
  as: 'button',
 | 
					  isActive: { type: Boolean, required: false },
 | 
				
			||||||
  variant: 'default',
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
  size: 'default',
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
})
 | 
					  as: { type: null, required: false, default: 'button' },
 | 
				
			||||||
 | 
					  tooltip: { type: null, required: false },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { isMobile, state } = useSidebar()
 | 
					const { isMobile, state } = useSidebar();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = computed(() => {
 | 
				
			||||||
  const { tooltip, ...delegated } = props
 | 
					  const { tooltip, ...delegated } = props;
 | 
				
			||||||
  return delegated
 | 
					  return delegated;
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
 | 
					  <SidebarMenuButtonChild
 | 
				
			||||||
 | 
					    v-if="!tooltip"
 | 
				
			||||||
 | 
					    v-bind="{ ...delegatedProps, ...$attrs }"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </SidebarMenuButtonChild>
 | 
					  </SidebarMenuButtonChild>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,21 +1,16 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { Primitive } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { Primitive, type PrimitiveProps } from 'radix-vue'
 | 
					import { sidebarMenuButtonVariants } from '.';
 | 
				
			||||||
import { type SidebarMenuButtonVariants, sidebarMenuButtonVariants } from '.'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface SidebarMenuButtonProps extends PrimitiveProps {
 | 
					const props = defineProps({
 | 
				
			||||||
  variant?: SidebarMenuButtonVariants['variant']
 | 
					  variant: { type: null, required: false, default: 'default' },
 | 
				
			||||||
  size?: SidebarMenuButtonVariants['size']
 | 
					  size: { type: null, required: false, default: 'default' },
 | 
				
			||||||
  isActive?: boolean
 | 
					  isActive: { type: Boolean, required: false },
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
 | 
					  as: { type: null, required: false, default: 'button' },
 | 
				
			||||||
const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
 | 
					});
 | 
				
			||||||
  as: 'button',
 | 
					 | 
				
			||||||
  variant: 'default',
 | 
					 | 
				
			||||||
  size: 'default',
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,9 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,16 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import { Skeleton } from '@/components/ui/skeleton'
 | 
					import { computed } from 'vue';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { computed, type HTMLAttributes } from 'vue'
 | 
					import { Skeleton } from '@/components/ui/skeleton';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  showIcon?: boolean
 | 
					  showIcon: { type: Boolean, required: false },
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const width = computed(() => {
 | 
					const width = computed(() => {
 | 
				
			||||||
  return `${Math.floor(Math.random() * 40) + 50}%`
 | 
					  return `${Math.floor(Math.random() * 40) + 50}%`;
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,20 +1,21 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <ul
 | 
					  <ul
 | 
				
			||||||
    data-sidebar="menu-badge"
 | 
					    data-sidebar="menu-badge"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
 | 
					      cn(
 | 
				
			||||||
      'group-data-[collapsible=icon]:hidden',
 | 
					        'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
 | 
				
			||||||
      props.class,
 | 
					        'group-data-[collapsible=icon]:hidden',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </ul>
 | 
					  </ul>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,14 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { PrimitiveProps } from 'radix-vue'
 | 
					import { Primitive } from 'reka-ui';
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
import { Primitive } from 'radix-vue'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<PrimitiveProps & {
 | 
					const props = defineProps({
 | 
				
			||||||
  size?: 'sm' | 'md'
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  isActive?: boolean
 | 
					  as: { type: null, required: false, default: 'a' },
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  size: { type: String, required: false, default: 'md' },
 | 
				
			||||||
}>(), {
 | 
					  isActive: { type: Boolean, required: false },
 | 
				
			||||||
  as: 'a',
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
  size: 'md',
 | 
					});
 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -21,14 +18,16 @@ const props = withDefaults(defineProps<PrimitiveProps & {
 | 
				
			|||||||
    :as-child="asChild"
 | 
					    :as-child="asChild"
 | 
				
			||||||
    :data-size="size"
 | 
					    :data-size="size"
 | 
				
			||||||
    :data-active="isActive"
 | 
					    :data-active="isActive"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
 | 
					      cn(
 | 
				
			||||||
      'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
 | 
					        'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
 | 
				
			||||||
      size === 'sm' && 'text-xs',
 | 
					        'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
 | 
				
			||||||
      size === 'md' && 'text-sm',
 | 
					        size === 'sm' && 'text-xs',
 | 
				
			||||||
      'group-data-[collapsible=icon]:hidden',
 | 
					        size === 'md' && 'text-sm',
 | 
				
			||||||
      props.class,
 | 
					        'group-data-[collapsible=icon]:hidden',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </Primitive>
 | 
					  </Primitive>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,4 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup lang="ts"></script>
 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <li>
 | 
					  <li>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,57 +1,64 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core';
 | 
				
			||||||
import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
 | 
					import { TooltipProvider } from 'reka-ui';
 | 
				
			||||||
import { TooltipProvider } from 'radix-vue'
 | 
					import { computed, ref } from 'vue';
 | 
				
			||||||
import { computed, type HTMLAttributes, type Ref, ref } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
 | 
					import {
 | 
				
			||||||
 | 
					  provideSidebarContext,
 | 
				
			||||||
 | 
					  SIDEBAR_COOKIE_MAX_AGE,
 | 
				
			||||||
 | 
					  SIDEBAR_COOKIE_NAME,
 | 
				
			||||||
 | 
					  SIDEBAR_KEYBOARD_SHORTCUT,
 | 
				
			||||||
 | 
					  SIDEBAR_WIDTH,
 | 
				
			||||||
 | 
					  SIDEBAR_WIDTH_ICON,
 | 
				
			||||||
 | 
					} from './utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  defaultOpen?: boolean
 | 
					  defaultOpen: { type: Boolean, required: false, default: true },
 | 
				
			||||||
  open?: boolean
 | 
					  open: { type: Boolean, required: false, default: undefined },
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>(), {
 | 
					});
 | 
				
			||||||
  defaultOpen: true,
 | 
					 | 
				
			||||||
  open: undefined,
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emits = defineEmits<{
 | 
					const emits = defineEmits(['update:open']);
 | 
				
			||||||
  'update:open': [open: boolean]
 | 
					 | 
				
			||||||
}>()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const isMobile = useMediaQuery('(max-width: 768px)')
 | 
					const isMobile = useMediaQuery('(max-width: 768px)');
 | 
				
			||||||
const openMobile = ref(false)
 | 
					const openMobile = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const open = useVModel(props, 'open', emits, {
 | 
					const open = useVModel(props, 'open', emits, {
 | 
				
			||||||
  defaultValue: props.defaultOpen ?? false,
 | 
					  defaultValue: props.defaultOpen ?? false,
 | 
				
			||||||
  passive: (props.open === undefined) as false,
 | 
					  passive: props.open === undefined,
 | 
				
			||||||
}) as Ref<boolean>
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function setOpen(value: boolean) {
 | 
					function setOpen(value) {
 | 
				
			||||||
  open.value = value // emits('update:open', value)
 | 
					  open.value = value; // emits('update:open', value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // This sets the cookie to keep the sidebar state.
 | 
					  // This sets the cookie to keep the sidebar state.
 | 
				
			||||||
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
 | 
					  document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function setOpenMobile(value: boolean) {
 | 
					function setOpenMobile(value) {
 | 
				
			||||||
  openMobile.value = value
 | 
					  openMobile.value = value;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Helper to toggle the sidebar.
 | 
					// Helper to toggle the sidebar.
 | 
				
			||||||
function toggleSidebar() {
 | 
					function toggleSidebar() {
 | 
				
			||||||
  return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
 | 
					  return isMobile.value
 | 
				
			||||||
 | 
					    ? setOpenMobile(!openMobile.value)
 | 
				
			||||||
 | 
					    : setOpen(!open.value);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
useEventListener('keydown', (event: KeyboardEvent) => {
 | 
					useEventListener('keydown', (event) => {
 | 
				
			||||||
  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
 | 
					  if (
 | 
				
			||||||
    event.preventDefault()
 | 
					    event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
 | 
				
			||||||
    toggleSidebar()
 | 
					    (event.metaKey || event.ctrlKey)
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    event.preventDefault();
 | 
				
			||||||
 | 
					    toggleSidebar();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
 | 
					// We add a state so that we can do data-state="expanded" or "collapsed".
 | 
				
			||||||
// This makes it easier to style the sidebar with Tailwind classes.
 | 
					// This makes it easier to style the sidebar with Tailwind classes.
 | 
				
			||||||
const state = computed(() => open.value ? 'expanded' : 'collapsed')
 | 
					const state = computed(() => (open.value ? 'expanded' : 'collapsed'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
provideSidebarContext({
 | 
					provideSidebarContext({
 | 
				
			||||||
  state,
 | 
					  state,
 | 
				
			||||||
@@ -61,7 +68,7 @@ provideSidebarContext({
 | 
				
			|||||||
  openMobile,
 | 
					  openMobile,
 | 
				
			||||||
  setOpenMobile,
 | 
					  setOpenMobile,
 | 
				
			||||||
  toggleSidebar,
 | 
					  toggleSidebar,
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -71,7 +78,12 @@ provideSidebarContext({
 | 
				
			|||||||
        '--sidebar-width': SIDEBAR_WIDTH,
 | 
					        '--sidebar-width': SIDEBAR_WIDTH,
 | 
				
			||||||
        '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
 | 
					        '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
 | 
				
			||||||
      }"
 | 
					      }"
 | 
				
			||||||
      :class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
 | 
					      :class="
 | 
				
			||||||
 | 
					        cn(
 | 
				
			||||||
 | 
					          'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
 | 
				
			||||||
 | 
					          props.class,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      "
 | 
				
			||||||
      v-bind="$attrs"
 | 
					      v-bind="$attrs"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <slot />
 | 
					      <slot />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,12 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { useSidebar } from './utils';
 | 
				
			||||||
import { useSidebar } from './utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { toggleSidebar } = useSidebar()
 | 
					const { toggleSidebar } = useSidebar();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -16,15 +15,17 @@ const { toggleSidebar } = useSidebar()
 | 
				
			|||||||
    aria-label="Toggle Sidebar"
 | 
					    aria-label="Toggle Sidebar"
 | 
				
			||||||
    :tabindex="-1"
 | 
					    :tabindex="-1"
 | 
				
			||||||
    title="Toggle Sidebar"
 | 
					    title="Toggle Sidebar"
 | 
				
			||||||
    :class="cn(
 | 
					    :class="
 | 
				
			||||||
      'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
 | 
					      cn(
 | 
				
			||||||
      '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
 | 
					        'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
 | 
				
			||||||
      '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
 | 
					        '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
 | 
				
			||||||
      'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
 | 
					        '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
 | 
				
			||||||
      '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
 | 
					        'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
 | 
				
			||||||
      '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
 | 
					        '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
 | 
				
			||||||
      props.class,
 | 
					        '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
 | 
				
			||||||
    )"
 | 
					        props.class,
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    "
 | 
				
			||||||
    @click="toggleSidebar"
 | 
					    @click="toggleSidebar"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,10 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { Separator } from '@/components/ui/separator'
 | 
					import { Separator } from '@/components/ui/separator';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,14 @@
 | 
				
			|||||||
<script setup lang="ts">
 | 
					<script setup>
 | 
				
			||||||
import type { HTMLAttributes } from 'vue'
 | 
					import { ViewVerticalIcon } from '@radix-icons/vue';
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { Button } from '@/components/ui/button';
 | 
				
			||||||
import { PanelLeft } from 'lucide-vue-next'
 | 
					import { useSidebar } from './utils';
 | 
				
			||||||
import { useSidebar } from './utils'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{
 | 
					const props = defineProps({
 | 
				
			||||||
  class?: HTMLAttributes['class']
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
}>()
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { toggleSidebar } = useSidebar()
 | 
					const { toggleSidebar } = useSidebar();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -20,7 +19,7 @@ const { toggleSidebar } = useSidebar()
 | 
				
			|||||||
    :class="cn('h-7 w-7', props.class)"
 | 
					    :class="cn('h-7 w-7', props.class)"
 | 
				
			||||||
    @click="toggleSidebar"
 | 
					    @click="toggleSidebar"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <PanelLeft />
 | 
					    <ViewVerticalIcon />
 | 
				
			||||||
    <span class="sr-only">Toggle Sidebar</span>
 | 
					    <span class="sr-only">Toggle Sidebar</span>
 | 
				
			||||||
  </Button>
 | 
					  </Button>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										49
									
								
								frontend/src/components/ui/sidebar/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								frontend/src/components/ui/sidebar/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					import { cva } from 'class-variance-authority';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export { default as Sidebar } from './Sidebar.vue';
 | 
				
			||||||
 | 
					export { default as SidebarContent } from './SidebarContent.vue';
 | 
				
			||||||
 | 
					export { default as SidebarFooter } from './SidebarFooter.vue';
 | 
				
			||||||
 | 
					export { default as SidebarGroup } from './SidebarGroup.vue';
 | 
				
			||||||
 | 
					export { default as SidebarGroupAction } from './SidebarGroupAction.vue';
 | 
				
			||||||
 | 
					export { default as SidebarGroupContent } from './SidebarGroupContent.vue';
 | 
				
			||||||
 | 
					export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue';
 | 
				
			||||||
 | 
					export { default as SidebarHeader } from './SidebarHeader.vue';
 | 
				
			||||||
 | 
					export { default as SidebarInput } from './SidebarInput.vue';
 | 
				
			||||||
 | 
					export { default as SidebarInset } from './SidebarInset.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenu } from './SidebarMenu.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuAction } from './SidebarMenuAction.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuButton } from './SidebarMenuButton.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuItem } from './SidebarMenuItem.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuSub } from './SidebarMenuSub.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue';
 | 
				
			||||||
 | 
					export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue';
 | 
				
			||||||
 | 
					export { default as SidebarProvider } from './SidebarProvider.vue';
 | 
				
			||||||
 | 
					export { default as SidebarRail } from './SidebarRail.vue';
 | 
				
			||||||
 | 
					export { default as SidebarSeparator } from './SidebarSeparator.vue';
 | 
				
			||||||
 | 
					export { default as SidebarTrigger } from './SidebarTrigger.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export { useSidebar } from './utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const sidebarMenuButtonVariants = cva(
 | 
				
			||||||
 | 
					  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    variants: {
 | 
				
			||||||
 | 
					      variant: {
 | 
				
			||||||
 | 
					        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
 | 
				
			||||||
 | 
					        outline:
 | 
				
			||||||
 | 
					          'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      size: {
 | 
				
			||||||
 | 
					        default: 'h-8 text-sm',
 | 
				
			||||||
 | 
					        sm: 'h-7 text-xs',
 | 
				
			||||||
 | 
					        lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    defaultVariants: {
 | 
				
			||||||
 | 
					      variant: 'default',
 | 
				
			||||||
 | 
					      size: 'default',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										10
									
								
								frontend/src/components/ui/sidebar/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								frontend/src/components/ui/sidebar/utils.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import { createContext } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
 | 
				
			||||||
 | 
					export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
 | 
				
			||||||
 | 
					export const SIDEBAR_WIDTH = '16rem';
 | 
				
			||||||
 | 
					export const SIDEBAR_WIDTH_MOBILE = '18rem';
 | 
				
			||||||
 | 
					export const SIDEBAR_WIDTH_ICON = '3rem';
 | 
				
			||||||
 | 
					export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const [useSidebar, provideSidebarContext] = createContext('Sidebar');
 | 
				
			||||||
@@ -1,9 +1,9 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +1 @@
 | 
				
			|||||||
export { default as Skeleton } from './Skeleton.vue'
 | 
					export { default as Skeleton } from './Skeleton.vue';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { TooltipRoot, useForwardPropsEmits } from 'radix-vue'
 | 
					import { TooltipRoot, useForwardPropsEmits } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  defaultOpen: { type: Boolean, required: false },
 | 
					  defaultOpen: { type: Boolean, required: false },
 | 
				
			||||||
@@ -8,11 +8,11 @@ const props = defineProps({
 | 
				
			|||||||
  disableHoverableContent: { type: Boolean, required: false },
 | 
					  disableHoverableContent: { type: Boolean, required: false },
 | 
				
			||||||
  disableClosingTrigger: { type: Boolean, required: false },
 | 
					  disableClosingTrigger: { type: Boolean, required: false },
 | 
				
			||||||
  disabled: { type: Boolean, required: false },
 | 
					  disabled: { type: Boolean, required: false },
 | 
				
			||||||
  ignoreNonKeyboardFocus: { type: Boolean, required: false }
 | 
					  ignoreNonKeyboardFocus: { type: Boolean, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
const emits = defineEmits(['update:open'])
 | 
					const emits = defineEmits(['update:open']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const forwarded = useForwardPropsEmits(props, emits)
 | 
					const forwarded = useForwardPropsEmits(props, emits);
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,14 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { reactiveOmit } from '@vueuse/core';
 | 
				
			||||||
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'radix-vue'
 | 
					import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineOptions({
 | 
					defineOptions({
 | 
				
			||||||
  inheritAttrs: false
 | 
					  inheritAttrs: false,
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  forceMount: { type: Boolean, required: false },
 | 
				
			||||||
  ariaLabel: { type: String, required: false },
 | 
					  ariaLabel: { type: String, required: false },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false },
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
@@ -21,18 +22,16 @@ const props = defineProps({
 | 
				
			|||||||
  arrowPadding: { type: Number, required: false },
 | 
					  arrowPadding: { type: Number, required: false },
 | 
				
			||||||
  sticky: { type: String, required: false },
 | 
					  sticky: { type: String, required: false },
 | 
				
			||||||
  hideWhenDetached: { type: Boolean, required: false },
 | 
					  hideWhenDetached: { type: Boolean, required: false },
 | 
				
			||||||
  class: { type: null, required: false }
 | 
					  positionStrategy: { type: String, required: false },
 | 
				
			||||||
})
 | 
					  updatePositionStrategy: { type: String, required: false },
 | 
				
			||||||
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside'])
 | 
					const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const delegatedProps = computed(() => {
 | 
					const delegatedProps = reactiveOmit(props, 'class');
 | 
				
			||||||
  const { class: _, ...delegated } = props
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return delegated
 | 
					const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -42,7 +41,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
 | 
				
			|||||||
      :class="
 | 
					      :class="
 | 
				
			||||||
        cn(
 | 
					        cn(
 | 
				
			||||||
          'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
 | 
					          'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
 | 
				
			||||||
          props.class
 | 
					          props.class,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
      "
 | 
					      "
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { TooltipProvider } from 'radix-vue'
 | 
					import { TooltipProvider } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  delayDuration: { type: Number, required: false },
 | 
					  delayDuration: { type: Number, required: false },
 | 
				
			||||||
@@ -7,8 +7,8 @@ const props = defineProps({
 | 
				
			|||||||
  disableHoverableContent: { type: Boolean, required: false },
 | 
					  disableHoverableContent: { type: Boolean, required: false },
 | 
				
			||||||
  disableClosingTrigger: { type: Boolean, required: false },
 | 
					  disableClosingTrigger: { type: Boolean, required: false },
 | 
				
			||||||
  disabled: { type: Boolean, required: false },
 | 
					  disabled: { type: Boolean, required: false },
 | 
				
			||||||
  ignoreNonKeyboardFocus: { type: Boolean, required: false }
 | 
					  ignoreNonKeyboardFocus: { type: Boolean, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,11 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { TooltipTrigger } from 'radix-vue'
 | 
					import { TooltipTrigger } from 'reka-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  reference: { type: null, required: false },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false }
 | 
					  as: { type: null, required: false },
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,4 @@
 | 
				
			|||||||
export { default as Tooltip } from './Tooltip.vue'
 | 
					export { default as Tooltip } from './Tooltip.vue';
 | 
				
			||||||
export { default as TooltipContent } from './TooltipContent.vue'
 | 
					export { default as TooltipContent } from './TooltipContent.vue';
 | 
				
			||||||
export { default as TooltipTrigger } from './TooltipTrigger.vue'
 | 
					export { default as TooltipProvider } from './TooltipProvider.vue';
 | 
				
			||||||
export { default as TooltipProvider } from './TooltipProvider.vue'
 | 
					export { default as TooltipTrigger } from './TooltipTrigger.vue';
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										142
									
								
								frontend/src/composables/useFileUpload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								frontend/src/composables/useFileUpload.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					import { ref, readonly } from 'vue'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Composable for handling file uploads
 | 
				
			||||||
 | 
					 * @param {Object} options - Configuration options
 | 
				
			||||||
 | 
					 * @param {Function} options.onFileUploadSuccess - Callback when file upload succeeds (uploadedFile)
 | 
				
			||||||
 | 
					 * @param {Function} options.onUploadError - Optional callback when file upload fails (file, error)
 | 
				
			||||||
 | 
					 * @param {string} options.linkedModel - The linked model for the upload
 | 
				
			||||||
 | 
					 * @param {Array} options.mediaFiles - Optional external array to manage files (if not provided, internal array is used)
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function useFileUpload (options = {}) {
 | 
				
			||||||
 | 
					    const {
 | 
				
			||||||
 | 
					        onFileUploadSuccess,
 | 
				
			||||||
 | 
					        onUploadError,
 | 
				
			||||||
 | 
					        linkedModel,
 | 
				
			||||||
 | 
					        mediaFiles: externalMediaFiles
 | 
				
			||||||
 | 
					    } = options
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const emitter = useEmitter()
 | 
				
			||||||
 | 
					    const uploadingFiles = ref([])
 | 
				
			||||||
 | 
					    const isUploading = ref(false)
 | 
				
			||||||
 | 
					    const internalMediaFiles = ref([])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Use external mediaFiles if provided, otherwise use internal
 | 
				
			||||||
 | 
					    const mediaFiles = externalMediaFiles || internalMediaFiles
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Handles the file upload process when files are selected.
 | 
				
			||||||
 | 
					     * Uploads each file to the server and adds them to the mediaFiles array.
 | 
				
			||||||
 | 
					     * @param {Event} event - The file input change event containing selected files
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    const handleFileUpload = (event) => {
 | 
				
			||||||
 | 
					        const files = Array.from(event.target.files)
 | 
				
			||||||
 | 
					        uploadingFiles.value = files
 | 
				
			||||||
 | 
					        isUploading.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (const file of files) {
 | 
				
			||||||
 | 
					            api
 | 
				
			||||||
 | 
					                .uploadMedia({
 | 
				
			||||||
 | 
					                    files: file,
 | 
				
			||||||
 | 
					                    inline: false,
 | 
				
			||||||
 | 
					                    linked_model: linkedModel
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .then((resp) => {
 | 
				
			||||||
 | 
					                    const uploadedFile = resp.data.data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Add to media files array
 | 
				
			||||||
 | 
					                    if (Array.isArray(mediaFiles.value)) {
 | 
				
			||||||
 | 
					                        mediaFiles.value.push(uploadedFile)
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        mediaFiles.push(uploadedFile)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Remove from uploading list
 | 
				
			||||||
 | 
					                    uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Call success callback
 | 
				
			||||||
 | 
					                    if (onFileUploadSuccess) {
 | 
				
			||||||
 | 
					                        onFileUploadSuccess(uploadedFile)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Update uploading state
 | 
				
			||||||
 | 
					                    if (uploadingFiles.value.length === 0) {
 | 
				
			||||||
 | 
					                        isUploading.value = false
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .catch((error) => {
 | 
				
			||||||
 | 
					                    uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Call error callback or show default toast
 | 
				
			||||||
 | 
					                    if (onUploadError) {
 | 
				
			||||||
 | 
					                        onUploadError(file, error)
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                            variant: 'destructive',
 | 
				
			||||||
 | 
					                            description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					                        })
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Update uploading state
 | 
				
			||||||
 | 
					                    if (uploadingFiles.value.length === 0) {
 | 
				
			||||||
 | 
					                        isUploading.value = false
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Handles the file delete event.
 | 
				
			||||||
 | 
					     * Removes the file from the mediaFiles array.
 | 
				
			||||||
 | 
					     * @param {String} uuid - The UUID of the file to delete
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    const handleFileDelete = (uuid) => {
 | 
				
			||||||
 | 
					        if (Array.isArray(mediaFiles.value)) {
 | 
				
			||||||
 | 
					            mediaFiles.value = [
 | 
				
			||||||
 | 
					                ...mediaFiles.value.filter((item) => item.uuid !== uuid)
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            const index = mediaFiles.findIndex((item) => item.uuid === uuid)
 | 
				
			||||||
 | 
					            if (index > -1) {
 | 
				
			||||||
 | 
					                mediaFiles.splice(index, 1)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Upload files programmatically (without event)
 | 
				
			||||||
 | 
					     * @param {File[]} files - Array of files to upload
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    const uploadFiles = (files) => {
 | 
				
			||||||
 | 
					        const mockEvent = { target: { files } }
 | 
				
			||||||
 | 
					        handleFileUpload(mockEvent)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Clear all media files
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    const clearMediaFiles = () => {
 | 
				
			||||||
 | 
					        if (Array.isArray(mediaFiles.value)) {
 | 
				
			||||||
 | 
					            mediaFiles.value = []
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            mediaFiles.length = 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        // State
 | 
				
			||||||
 | 
					        uploadingFiles: readonly(uploadingFiles),
 | 
				
			||||||
 | 
					        isUploading: readonly(isUploading),
 | 
				
			||||||
 | 
					        mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Methods
 | 
				
			||||||
 | 
					        handleFileUpload,
 | 
				
			||||||
 | 
					        handleFileDelete,
 | 
				
			||||||
 | 
					        uploadFiles,
 | 
				
			||||||
 | 
					        clearMediaFiles
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
export const reportsNavItems = [
 | 
					export const reportsNavItems = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.overview',
 | 
					        titleKey: 'globals.terms.overview',
 | 
				
			||||||
        href: '/reports/overview',
 | 
					        href: '/reports/overview',
 | 
				
			||||||
        permission: 'reports:manage'
 | 
					        permission: 'reports:manage'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -8,125 +8,125 @@ export const reportsNavItems = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const adminNavItems = [
 | 
					export const adminNavItems = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.workspace',
 | 
					        titleKey: 'globals.terms.workspace',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.generalSettings',
 | 
					                titleKey: 'globals.terms.general',
 | 
				
			||||||
                href: '/admin/general',
 | 
					                href: '/admin/general',
 | 
				
			||||||
                permission: 'general_settings:manage'
 | 
					                permission: 'general_settings:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.businessHours',
 | 
					                titleKey: 'globals.terms.businessHour',
 | 
				
			||||||
                href: '/admin/business-hours',
 | 
					                href: '/admin/business-hours',
 | 
				
			||||||
                permission: 'business_hours:manage'
 | 
					                permission: 'business_hours:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.slaPolicies',
 | 
					                titleKey: 'globals.terms.slaPolicy',
 | 
				
			||||||
                href: '/admin/sla',
 | 
					                href: '/admin/sla',
 | 
				
			||||||
                permission: 'sla:manage'
 | 
					                permission: 'sla:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.conversations',
 | 
					        titleKey: 'globals.terms.conversation',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.tags',
 | 
					                titleKey: 'globals.terms.tag',
 | 
				
			||||||
                href: '/admin/conversations/tags',
 | 
					                href: '/admin/conversations/tags',
 | 
				
			||||||
                permission: 'tags:manage'
 | 
					                permission: 'tags:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.macros',
 | 
					                titleKey: 'globals.terms.macro',
 | 
				
			||||||
                href: '/admin/conversations/macros',
 | 
					                href: '/admin/conversations/macros',
 | 
				
			||||||
                permission: 'macros:manage'
 | 
					                permission: 'macros:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.statuses',
 | 
					                titleKey: 'globals.terms.status',
 | 
				
			||||||
                href: '/admin/conversations/statuses',
 | 
					                href: '/admin/conversations/statuses',
 | 
				
			||||||
                permission: 'status:manage'
 | 
					                permission: 'status:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.inboxes',
 | 
					        titleKey: 'globals.terms.inbox',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.inboxes',
 | 
					                titleKey: 'globals.terms.inbox',
 | 
				
			||||||
                href: '/admin/inboxes',
 | 
					                href: '/admin/inboxes',
 | 
				
			||||||
                permission: 'inboxes:manage'
 | 
					                permission: 'inboxes:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.teammates',
 | 
					        titleKey: 'globals.terms.teammate',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.agents',
 | 
					                titleKey: 'globals.terms.agent',
 | 
				
			||||||
                href: '/admin/teams/agents',
 | 
					                href: '/admin/teams/agents',
 | 
				
			||||||
                permission: 'users:manage'
 | 
					                permission: 'users:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.teams',
 | 
					                titleKey: 'globals.terms.team',
 | 
				
			||||||
                href: '/admin/teams/teams',
 | 
					                href: '/admin/teams/teams',
 | 
				
			||||||
                permission: 'teams:manage'
 | 
					                permission: 'teams:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.roles',
 | 
					                titleKey: 'globals.terms.role',
 | 
				
			||||||
                href: '/admin/teams/roles',
 | 
					                href: '/admin/teams/roles',
 | 
				
			||||||
                permission: 'roles:manage'
 | 
					                permission: 'roles:manage'
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.activityLog',
 | 
					                titleKey: 'globals.terms.activityLog',
 | 
				
			||||||
                href: '/admin/teams/activity-log',
 | 
					                href: '/admin/teams/activity-log',
 | 
				
			||||||
                permission: 'activity_logs:manage'
 | 
					                permission: 'activity_logs:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.automations',
 | 
					        titleKey: 'globals.terms.automation',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.automations',
 | 
					                titleKey: 'globals.terms.automation',
 | 
				
			||||||
                href: '/admin/automations',
 | 
					                href: '/admin/automations',
 | 
				
			||||||
                permission: 'automations:manage'
 | 
					                permission: 'automations:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.customAttributes',
 | 
					        titleKey: 'globals.terms.customAttribute',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.customAttributes',
 | 
					                titleKey: 'globals.terms.customAttribute',
 | 
				
			||||||
                href: '/admin/custom-attributes',
 | 
					                href: '/admin/custom-attributes',
 | 
				
			||||||
                permission: 'custom_attributes:manage'
 | 
					                permission: 'custom_attributes:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.notifications',
 | 
					        titleKey: 'globals.terms.notification',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.email',
 | 
					                titleKey: 'globals.terms.email',
 | 
				
			||||||
                href: '/admin/notification',
 | 
					                href: '/admin/notification',
 | 
				
			||||||
                permission: 'notification_settings:manage'
 | 
					                permission: 'notification_settings:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.templates',
 | 
					        titleKey: 'globals.terms.template',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.templates',
 | 
					                titleKey: 'globals.terms.template',
 | 
				
			||||||
                href: '/admin/templates',
 | 
					                href: '/admin/templates',
 | 
				
			||||||
                permission: 'templates:manage'
 | 
					                permission: 'templates:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.security',
 | 
					        titleKey: 'globals.terms.security',
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                titleKey: 'navigation.sso',
 | 
					                titleKey: 'globals.terms.sso',
 | 
				
			||||||
                href: '/admin/sso',
 | 
					                href: '/admin/sso',
 | 
				
			||||||
                permission: 'oidc:manage'
 | 
					                permission: 'oidc:manage'
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@@ -136,7 +136,7 @@ export const adminNavItems = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const accountNavItems = [
 | 
					export const accountNavItems = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.profile',
 | 
					        titleKey: 'globals.terms.profile',
 | 
				
			||||||
        href: '/account/profile',
 | 
					        href: '/account/profile',
 | 
				
			||||||
        description: 'Update your profile'
 | 
					        description: 'Update your profile'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -144,7 +144,7 @@ export const accountNavItems = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const contactNavItems = [
 | 
					export const contactNavItems = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        titleKey: 'navigation.allContacts',
 | 
					        titleKey: 'globals.terms.contact',
 | 
				
			||||||
        href: '/contacts',
 | 
					        href: '/contacts',
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        <div class="space-y-4 flex-2">
 | 
					        <div class="space-y-4 flex-2">
 | 
				
			||||||
          <div class="flex items-center gap-3">
 | 
					          <div class="flex items-center gap-3">
 | 
				
			||||||
            <h3 class="text-lg font-semibold text-gray-900">
 | 
					            <h3 class="text-lg font-semibold text-gray-900 dark:text-foreground">
 | 
				
			||||||
              {{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
 | 
					              {{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
 | 
				
			||||||
            </h3>
 | 
					            </h3>
 | 
				
			||||||
            <Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
 | 
					            <Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
 | 
				
			||||||
@@ -25,7 +25,7 @@
 | 
				
			|||||||
              <Clock class="w-5 h-5 text-gray-400" />
 | 
					              <Clock class="w-5 h-5 text-gray-400" />
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <p class="text-sm text-gray-500">{{ $t('form.field.lastActive') }}</p>
 | 
					                <p class="text-sm text-gray-500">{{ $t('form.field.lastActive') }}</p>
 | 
				
			||||||
                <p class="text-sm font-medium text-gray-700">
 | 
					                <p class="text-sm font-medium text-gray-700 dark:text-foreground">
 | 
				
			||||||
                  {{
 | 
					                  {{
 | 
				
			||||||
                    props.initialValues.last_active_at
 | 
					                    props.initialValues.last_active_at
 | 
				
			||||||
                      ? format(new Date(props.initialValues.last_active_at), 'PPpp')
 | 
					                      ? format(new Date(props.initialValues.last_active_at), 'PPpp')
 | 
				
			||||||
@@ -38,7 +38,7 @@
 | 
				
			|||||||
              <LogIn class="w-5 h-5 text-gray-400" />
 | 
					              <LogIn class="w-5 h-5 text-gray-400" />
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <p class="text-sm text-gray-500">{{ $t('form.field.lastLogin') }}</p>
 | 
					                <p class="text-sm text-gray-500">{{ $t('form.field.lastLogin') }}</p>
 | 
				
			||||||
                <p class="text-sm font-medium text-gray-700">
 | 
					                <p class="text-sm font-medium text-gray-700 dark:text-foreground">
 | 
				
			||||||
                  {{
 | 
					                  {{
 | 
				
			||||||
                    props.initialValues.last_login_at
 | 
					                    props.initialValues.last_login_at
 | 
				
			||||||
                      ? format(new Date(props.initialValues.last_login_at), 'PPpp')
 | 
					                      ? format(new Date(props.initialValues.last_login_at), 'PPpp')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="space-y-5 rounded-lg" :class="{ 'box p-5': actions.length > 0 }">
 | 
					  <div class="space-y-5 rounded" :class="{ 'box p-5': actions.length > 0 }">
 | 
				
			||||||
    <div class="space-y-5">
 | 
					    <div class="space-y-5">
 | 
				
			||||||
      <div v-for="(action, index) in actions" :key="index" class="space-y-5">
 | 
					      <div v-for="(action, index) in actions" :key="index" class="space-y-5">
 | 
				
			||||||
        <div v-if="index > 0">
 | 
					        <div v-if="index > 0">
 | 
				
			||||||
@@ -48,63 +48,17 @@
 | 
				
			|||||||
                class="w-48"
 | 
					                class="w-48"
 | 
				
			||||||
                v-if="action.type && conversationActions[action.type]?.type === 'select'"
 | 
					                v-if="action.type && conversationActions[action.type]?.type === 'select'"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <ComboBox
 | 
					                <SelectComboBox
 | 
				
			||||||
                  v-model="action.value[0]"
 | 
					                  v-model="action.value[0]"
 | 
				
			||||||
                  :items="conversationActions[action.type]?.options"
 | 
					                  :items="conversationActions[action.type]?.options"
 | 
				
			||||||
                  :placeholder="t('form.field.select')"
 | 
					                  :placeholder="t('form.field.select')"
 | 
				
			||||||
                  @select="handleValueChange($event, index)"
 | 
					                  @select="handleValueChange($event, index)"
 | 
				
			||||||
                >
 | 
					                  :type="action.type === 'assign_team' ? 'team' : 'user'"
 | 
				
			||||||
                  <template #item="{ item }">
 | 
					                />
 | 
				
			||||||
                    <div class="flex items-center gap-2 ml-2">
 | 
					 | 
				
			||||||
                      <Avatar v-if="action.type === 'assign_user'" class="w-7 h-7">
 | 
					 | 
				
			||||||
                        <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					 | 
				
			||||||
                        <AvatarFallback>
 | 
					 | 
				
			||||||
                          {{ item.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                        </AvatarFallback>
 | 
					 | 
				
			||||||
                      </Avatar>
 | 
					 | 
				
			||||||
                      <span v-if="action.type === 'assign_team'">
 | 
					 | 
				
			||||||
                        {{ item.emoji }}
 | 
					 | 
				
			||||||
                      </span>
 | 
					 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <template #selected="{ selected }">
 | 
					 | 
				
			||||||
                    <div v-if="action.type === 'assign_team'">
 | 
					 | 
				
			||||||
                      <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        {{ selected.emoji }}
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                      <span v-else>{{ $t('form.field.selectTeam') }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    <div v-else-if="action.type === 'assign_user'" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                      <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                          <AvatarImage
 | 
					 | 
				
			||||||
                            :src="selected.avatar_url ?? ''"
 | 
					 | 
				
			||||||
                            :alt="selected.label.slice(0, 2)"
 | 
					 | 
				
			||||||
                          />
 | 
					 | 
				
			||||||
                          <AvatarFallback>
 | 
					 | 
				
			||||||
                            {{ selected.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                          </AvatarFallback>
 | 
					 | 
				
			||||||
                        </Avatar>
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                      <span v-else>{{ $t('form.field.selectUser') }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <span v-else>
 | 
					 | 
				
			||||||
                      <span v-if="!selected"> {{ $t('form.field.select') }}</span>
 | 
					 | 
				
			||||||
                      <span v-else>{{ selected.label }} </span>
 | 
					 | 
				
			||||||
                    </span>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
                </ComboBox>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="cursor-pointer" @click.prevent="removeAction(index)">
 | 
					            <CloseButton :onClose="() => removeAction(index)" />
 | 
				
			||||||
              <X size="16" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
@@ -114,7 +68,7 @@
 | 
				
			|||||||
            <Editor
 | 
					            <Editor
 | 
				
			||||||
              v-model:htmlContent="action.value[0]"
 | 
					              v-model:htmlContent="action.value[0]"
 | 
				
			||||||
              @update:htmlContent="(value) => handleEditorChange(value, index)"
 | 
					              @update:htmlContent="(value) => handleEditorChange(value, index)"
 | 
				
			||||||
              :placeholder="t('editor.placeholder')"
 | 
					              :placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
@@ -133,7 +87,7 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { toRefs } from 'vue'
 | 
					import { toRefs } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
@@ -143,13 +97,12 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  actions: {
 | 
					  actions: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@
 | 
				
			|||||||
      </RadioGroup>
 | 
					      </RadioGroup>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="space-y-5 rounded-lg" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
 | 
					    <div class="space-y-5 rounded" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
 | 
				
			||||||
      <div class="space-y-5">
 | 
					      <div class="space-y-5">
 | 
				
			||||||
        <div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
 | 
					        <div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
 | 
				
			||||||
          <div v-if="index > 0">
 | 
					          <div v-if="index > 0">
 | 
				
			||||||
@@ -102,59 +102,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
              <!-- Select input -->
 | 
					              <!-- Select input -->
 | 
				
			||||||
              <div v-if="inputType(index) === 'select'">
 | 
					              <div v-if="inputType(index) === 'select'">
 | 
				
			||||||
                <ComboBox
 | 
					                <SelectComboBox
 | 
				
			||||||
                  v-model="rule.value"
 | 
					                  v-model="rule.value"
 | 
				
			||||||
                  :items="getFieldOptions(rule.field, rule.field_type)"
 | 
					                  :items="getFieldOptions(rule.field, rule.field_type)"
 | 
				
			||||||
                  @select="handleValueChange($event, index)"
 | 
					                  @select="handleValueChange($event, index)"
 | 
				
			||||||
                >
 | 
					                  :type="rule.field === 'assigned_user' ? 'user' : 'team'"
 | 
				
			||||||
                  <template #item="{ item }">
 | 
					                />
 | 
				
			||||||
                    <div class="flex items-center gap-2 ml-2">
 | 
					 | 
				
			||||||
                      <Avatar v-if="rule.field === 'assigned_user'" class="w-7 h-7">
 | 
					 | 
				
			||||||
                        <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					 | 
				
			||||||
                        <AvatarFallback>
 | 
					 | 
				
			||||||
                          {{ item.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                        </AvatarFallback>
 | 
					 | 
				
			||||||
                      </Avatar>
 | 
					 | 
				
			||||||
                      <span v-if="rule.field === 'assigned_team'">
 | 
					 | 
				
			||||||
                        {{ item.emoji }}
 | 
					 | 
				
			||||||
                      </span>
 | 
					 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <template #selected="{ selected }">
 | 
					 | 
				
			||||||
                    <div v-if="rule?.field === 'assigned_team'">
 | 
					 | 
				
			||||||
                      <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        {{ selected.emoji }}
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                      <span v-else>{{ $t('form.field.selectTeam') }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    <div
 | 
					 | 
				
			||||||
                      v-else-if="rule?.field === 'assigned_user'"
 | 
					 | 
				
			||||||
                      class="flex items-center gap-2"
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                      <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                          <AvatarImage
 | 
					 | 
				
			||||||
                            :src="selected.avatar_url || ''"
 | 
					 | 
				
			||||||
                            :alt="selected.label.slice(0, 2)"
 | 
					 | 
				
			||||||
                          />
 | 
					 | 
				
			||||||
                          <AvatarFallback>
 | 
					 | 
				
			||||||
                            {{ selected.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                          </AvatarFallback>
 | 
					 | 
				
			||||||
                        </Avatar>
 | 
					 | 
				
			||||||
                        <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                      <span v-else>{{ $t('form.field.selectUser') }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <span v-else>
 | 
					 | 
				
			||||||
                      <span v-if="!selected"> {{ $t('form.field.select') }}</span>
 | 
					 | 
				
			||||||
                      <span v-else>{{ selected.label }} </span>
 | 
					 | 
				
			||||||
                    </span>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
                </ComboBox>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <!-- Tag input -->
 | 
					              <!-- Tag input -->
 | 
				
			||||||
@@ -209,9 +162,7 @@
 | 
				
			|||||||
            <div v-else class="flex-1"></div>
 | 
					            <div v-else class="flex-1"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <!-- Remove condition -->
 | 
					            <!-- Remove condition -->
 | 
				
			||||||
            <div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
 | 
					            <CloseButton :onClose="() => removeCondition(index)" />
 | 
				
			||||||
              <X size="16" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div class="flex items-center space-x-2">
 | 
					          <div class="flex items-center space-x-2">
 | 
				
			||||||
@@ -242,6 +193,7 @@ import { toRefs, computed, watch } from 'vue'
 | 
				
			|||||||
import { Checkbox } from '@/components/ui/checkbox'
 | 
					import { Checkbox } from '@/components/ui/checkbox'
 | 
				
			||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
					import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -258,13 +210,11 @@ import {
 | 
				
			|||||||
  TagsInputItemDelete,
 | 
					  TagsInputItemDelete,
 | 
				
			||||||
  TagsInputItemText
 | 
					  TagsInputItemText
 | 
				
			||||||
} from '@/components/ui/tags-input'
 | 
					} from '@/components/ui/tags-input'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@/components/ui/label'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  ruleGroup: {
 | 
					  ruleGroup: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,21 @@
 | 
				
			|||||||
      </Select>
 | 
					      </Select>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      v-if="!isLoading && rules.length === 0"
 | 
				
			||||||
 | 
					      class="flex flex-col items-center justify-center py-12 px-4"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div class="text-center space-y-2">
 | 
				
			||||||
 | 
					        <p class="text-muted-foreground">
 | 
				
			||||||
 | 
					          {{
 | 
				
			||||||
 | 
					            $t('globals.messages.noResults', {
 | 
				
			||||||
 | 
					              name: $t('globals.terms.rule', 2).toLowerCase()
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="space-y-4">
 | 
					    <div class="space-y-4">
 | 
				
			||||||
      <div v-if="type === 'new_conversation'">
 | 
					      <div v-if="type === 'new_conversation'">
 | 
				
			||||||
        <draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
 | 
					        <draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,125 +1,136 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="space-y-5 rounded-lg">
 | 
					  <div class="space-y-6">
 | 
				
			||||||
    <div class="space-y-5">
 | 
					    <!-- Empty State -->
 | 
				
			||||||
      <div v-for="(action, index) in model" :key="index" class="space-y-5">
 | 
					    <div
 | 
				
			||||||
        <hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
 | 
					      v-if="!model.length"
 | 
				
			||||||
 | 
					      class="text-center py-12 px-6 border-2 border-dashed border-muted rounded-lg"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-3">
 | 
				
			||||||
 | 
					        <Plus class="w-6 h-6 text-muted-foreground" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <h3 class="text-sm font-medium text-foreground mb-2">
 | 
				
			||||||
 | 
					        {{ $t('globals.messages.no', { name: $t('globals.terms.action', 2).toLowerCase() }) }}
 | 
				
			||||||
 | 
					      </h3>
 | 
				
			||||||
 | 
					      <Button
 | 
				
			||||||
 | 
					        @click.prevent="add"
 | 
				
			||||||
 | 
					        variant="outline"
 | 
				
			||||||
 | 
					        size="sm"
 | 
				
			||||||
 | 
					        class="inline-flex items-center gap-2"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Plus class="w-4 h-4" />
 | 
				
			||||||
 | 
					        {{ config.addButtonText }}
 | 
				
			||||||
 | 
					      </Button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="space-y-3">
 | 
					    <!-- Actions List -->
 | 
				
			||||||
          <div class="flex items-center justify-between">
 | 
					    <div v-else class="space-y-6">
 | 
				
			||||||
            <div class="flex gap-5">
 | 
					      <div v-for="(action, index) in model" :key="index" class="relative">
 | 
				
			||||||
              <div class="w-48">
 | 
					        <!-- Action Card -->
 | 
				
			||||||
                <Select
 | 
					        <div class="border rounded p-6 shadow-sm hover:shadow-md transition-shadow">
 | 
				
			||||||
                  v-model="action.type"
 | 
					          <div class="flex items-start justify-between gap-4">
 | 
				
			||||||
                  @update:modelValue="(value) => updateField(value, index)"
 | 
					            <div class="flex-1 space-y-4">
 | 
				
			||||||
 | 
					              <!-- Action Type Selection -->
 | 
				
			||||||
 | 
					              <div class="flex flex-col sm:flex-row gap-4">
 | 
				
			||||||
 | 
					                <div class="flex-1 max-w-xs">
 | 
				
			||||||
 | 
					                  <label class="block text-sm font-medium mb-2">{{
 | 
				
			||||||
 | 
					                    $t('globals.messages.type', {
 | 
				
			||||||
 | 
					                      name: $t('globals.terms.action')
 | 
				
			||||||
 | 
					                    })
 | 
				
			||||||
 | 
					                  }}</label>
 | 
				
			||||||
 | 
					                  <Select
 | 
				
			||||||
 | 
					                    v-model="action.type"
 | 
				
			||||||
 | 
					                    @update:modelValue="(value) => updateField(value, index)"
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    <SelectTrigger class="w-full">
 | 
				
			||||||
 | 
					                      <SelectValue :placeholder="config.typePlaceholder" />
 | 
				
			||||||
 | 
					                    </SelectTrigger>
 | 
				
			||||||
 | 
					                    <SelectContent>
 | 
				
			||||||
 | 
					                      <SelectGroup>
 | 
				
			||||||
 | 
					                        <SelectItem
 | 
				
			||||||
 | 
					                          v-for="(actionConfig, key) in config.actions"
 | 
				
			||||||
 | 
					                          :key="key"
 | 
				
			||||||
 | 
					                          :value="key"
 | 
				
			||||||
 | 
					                        >
 | 
				
			||||||
 | 
					                          {{ actionConfig.label }}
 | 
				
			||||||
 | 
					                        </SelectItem>
 | 
				
			||||||
 | 
					                      </SelectGroup>
 | 
				
			||||||
 | 
					                    </SelectContent>
 | 
				
			||||||
 | 
					                  </Select>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Value Selection -->
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  v-if="action.type && config.actions[action.type]?.type === 'select'"
 | 
				
			||||||
 | 
					                  class="flex-1 max-w-xs"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <SelectTrigger>
 | 
					                  <label class="block text-sm font-medium mb-2">Value</label>
 | 
				
			||||||
                    <SelectValue :placeholder="config.typePlaceholder" />
 | 
					
 | 
				
			||||||
                  </SelectTrigger>
 | 
					                  <SelectComboBox
 | 
				
			||||||
                  <SelectContent>
 | 
					                    v-if="action.type === 'assign_user'"
 | 
				
			||||||
                    <SelectGroup>
 | 
					                    v-model="action.value[0]"
 | 
				
			||||||
                      <SelectItem
 | 
					                    :items="config.actions[action.type].options"
 | 
				
			||||||
                        v-for="(actionConfig, key) in config.actions"
 | 
					                    :placeholder="config.valuePlaceholder"
 | 
				
			||||||
                        :key="key"
 | 
					                    @update:modelValue="(value) => updateValue(value, index)"
 | 
				
			||||||
                        :value="key"
 | 
					                    type="user"
 | 
				
			||||||
                      >
 | 
					                  />
 | 
				
			||||||
                        {{ actionConfig.label }}
 | 
					
 | 
				
			||||||
                      </SelectItem>
 | 
					                  <SelectComboBox
 | 
				
			||||||
                    </SelectGroup>
 | 
					                    v-else-if="action.type === 'assign_team'"
 | 
				
			||||||
                  </SelectContent>
 | 
					                    v-model="action.value[0]"
 | 
				
			||||||
                </Select>
 | 
					                    :items="config.actions[action.type].options"
 | 
				
			||||||
 | 
					                    :placeholder="config.valuePlaceholder"
 | 
				
			||||||
 | 
					                    @update:modelValue="(value) => updateValue(value, index)"
 | 
				
			||||||
 | 
					                    type="team"
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                  <SelectComboBox
 | 
				
			||||||
 | 
					                    v-else
 | 
				
			||||||
 | 
					                    v-model="action.value[0]"
 | 
				
			||||||
 | 
					                    :items="config.actions[action.type].options"
 | 
				
			||||||
 | 
					                    :placeholder="config.valuePlaceholder"
 | 
				
			||||||
 | 
					                    @update:modelValue="(value) => updateValue(value, index)"
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Tag Selection -->
 | 
				
			||||||
              <div
 | 
					              <div
 | 
				
			||||||
                v-if="action.type && config.actions[action.type]?.type === 'select'"
 | 
					                v-if="action.type && config.actions[action.type]?.type === 'tag'"
 | 
				
			||||||
                class="w-48"
 | 
					                class="max-w-md"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <ComboBox
 | 
					                <label class="block text-sm font-medium mb-2">{{ $t('globals.terms.tag') }}</label>
 | 
				
			||||||
                  v-model="action.value[0]"
 | 
					                <SelectTag
 | 
				
			||||||
                  :items="config.actions[action.type].options"
 | 
					                  v-model="action.value"
 | 
				
			||||||
                  :placeholder="config.valuePlaceholder"
 | 
					                  :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
 | 
				
			||||||
                  @update:modelValue="(value) => updateValue(value, index)"
 | 
					                  placeholder="Select tags"
 | 
				
			||||||
                >
 | 
					                />
 | 
				
			||||||
                  <template #item="{ item }">
 | 
					 | 
				
			||||||
                    <div v-if="action.type === 'assign_user'">
 | 
					 | 
				
			||||||
                      <div class="flex items-center flex-1 gap-2 ml-2">
 | 
					 | 
				
			||||||
                        <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                          <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					 | 
				
			||||||
                          <AvatarFallback
 | 
					 | 
				
			||||||
                            >{{ item.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                          </AvatarFallback>
 | 
					 | 
				
			||||||
                        </Avatar>
 | 
					 | 
				
			||||||
                        <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div v-else-if="action.type === 'assign_team'">
 | 
					 | 
				
			||||||
                      <div class="flex items-center gap-2 ml-2">
 | 
					 | 
				
			||||||
                        <span>{{ item.emoji }}</span>
 | 
					 | 
				
			||||||
                        <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div v-else>
 | 
					 | 
				
			||||||
                      {{ item.label }}
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <template #selected="{ selected }">
 | 
					 | 
				
			||||||
                    <div v-if="action.type === 'assign_user'">
 | 
					 | 
				
			||||||
                      <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                          <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                            <AvatarImage
 | 
					 | 
				
			||||||
                              :src="selected.avatar_url || ''"
 | 
					 | 
				
			||||||
                              :alt="selected.label.slice(0, 2)"
 | 
					 | 
				
			||||||
                            />
 | 
					 | 
				
			||||||
                            <AvatarFallback>{{
 | 
					 | 
				
			||||||
                              selected.label.slice(0, 2).toUpperCase()
 | 
					 | 
				
			||||||
                            }}</AvatarFallback>
 | 
					 | 
				
			||||||
                          </Avatar>
 | 
					 | 
				
			||||||
                          <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                        </div>
 | 
					 | 
				
			||||||
                        <span v-else>{{ $t('form.field.selectUser') }}</span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div v-else-if="action.type === 'assign_team'">
 | 
					 | 
				
			||||||
                      <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                        <span v-if="selected">
 | 
					 | 
				
			||||||
                          {{ selected.emoji }}
 | 
					 | 
				
			||||||
                          <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                        </span>
 | 
					 | 
				
			||||||
                        <span v-else>
 | 
					 | 
				
			||||||
                          {{ $t('form.field.selectTeam') }}
 | 
					 | 
				
			||||||
                        </span>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div v-else-if="selected">
 | 
					 | 
				
			||||||
                      {{ selected.label }}
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div v-else>{{ $t('form.field.select') }}</div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
                </ComboBox>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <X class="cursor-pointer w-4" @click="remove(index)" />
 | 
					            <!-- Remove Button -->
 | 
				
			||||||
          </div>
 | 
					            <CloseButton :onClose="() => remove(index)" />
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <div v-if="action.type && config.actions[action.type]?.type === 'tag'">
 | 
					 | 
				
			||||||
            <SelectTag
 | 
					 | 
				
			||||||
              v-model="action.value"
 | 
					 | 
				
			||||||
              :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
 | 
					 | 
				
			||||||
              placeholder="Select tag"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- Add Action Button -->
 | 
				
			||||||
 | 
					      <div class="flex justify-center pt-2">
 | 
				
			||||||
 | 
					        <Button
 | 
				
			||||||
 | 
					          type="button"
 | 
				
			||||||
 | 
					          variant="outline"
 | 
				
			||||||
 | 
					          @click="add"
 | 
				
			||||||
 | 
					          class="inline-flex items-center gap-2 border-dashed hover:border-solid"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Plus class="w-4 h-4" />
 | 
				
			||||||
 | 
					          {{ config.addButtonText }}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
 | 
					 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -128,10 +139,10 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const model = defineModel('actions', {
 | 
					const model = defineModel('actions', {
 | 
				
			||||||
  type: Array,
 | 
					  type: Array,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@
 | 
				
			|||||||
            <Editor
 | 
					            <Editor
 | 
				
			||||||
              v-model:htmlContent="componentField.modelValue"
 | 
					              v-model:htmlContent="componentField.modelValue"
 | 
				
			||||||
              @update:htmlContent="(value) => componentField.onChange(value)"
 | 
					              @update:htmlContent="(value) => componentField.onChange(value)"
 | 
				
			||||||
              :placeholder="t('editor.placeholder')"
 | 
					              :placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -27,9 +27,16 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="actions">
 | 
					    <FormField
 | 
				
			||||||
 | 
					      v-slot="{ componentField }"
 | 
				
			||||||
 | 
					      name="actions"
 | 
				
			||||||
 | 
					      :validate-on-blur="false"
 | 
				
			||||||
 | 
					      :validate-on-change="false"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel> {{ t('admin.macro.actions') }}</FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
 | 
					          {{ t('globals.terms.action', 2) }} ({{ t('globals.terms.optional', 1).toLowerCase() }})
 | 
				
			||||||
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ActionBuilder
 | 
					          <ActionBuilder
 | 
				
			||||||
            v-model:actions="componentField.modelValue"
 | 
					            v-model:actions="componentField.modelValue"
 | 
				
			||||||
@@ -41,17 +48,57 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="visibility">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="visible_when">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('admin.macro.visibility') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <SelectTag
 | 
				
			||||||
 | 
					            :items="[
 | 
				
			||||||
 | 
					              { label: t('globals.messages.replying'), value: 'replying' },
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                label: t('globals.messages.starting', {
 | 
				
			||||||
 | 
					                  name: t('globals.terms.conversation').toLowerCase()
 | 
				
			||||||
 | 
					                }),
 | 
				
			||||||
 | 
					                value: 'starting_conversation'
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                label: t('globals.messages.adding', {
 | 
				
			||||||
 | 
					                  name: t('globals.terms.privateNote', 2).toLowerCase()
 | 
				
			||||||
 | 
					                }),
 | 
				
			||||||
 | 
					                value: 'adding_private_note'
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            ]"
 | 
				
			||||||
 | 
					            v-model="componentField.modelValue"
 | 
				
			||||||
 | 
					            @update:modelValue="handleChange"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <FormField
 | 
				
			||||||
 | 
					      v-slot="{ componentField }"
 | 
				
			||||||
 | 
					      name="visibility"
 | 
				
			||||||
 | 
					      :validate-on-blur="false"
 | 
				
			||||||
 | 
					      :validate-on-change="false"
 | 
				
			||||||
 | 
					      :validate-on-input="false"
 | 
				
			||||||
 | 
					      :validate-on-mount="false"
 | 
				
			||||||
 | 
					      :validate-on-model-update="false"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>{{ t('globals.terms.visibility') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField">
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue placeholder="Select visibility" />
 | 
					              <SelectValue />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
            <SelectContent>
 | 
					            <SelectContent>
 | 
				
			||||||
              <SelectGroup>
 | 
					              <SelectGroup>
 | 
				
			||||||
                <SelectItem value="all">{{ t('admin.macro.visibility.all') }}</SelectItem>
 | 
					                <SelectItem value="all">{{
 | 
				
			||||||
 | 
					                  t('globals.messages.all', {
 | 
				
			||||||
 | 
					                    name: t('globals.terms.user', 2).toLowerCase()
 | 
				
			||||||
 | 
					                  })
 | 
				
			||||||
 | 
					                }}</SelectItem>
 | 
				
			||||||
                <SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
 | 
					                <SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
 | 
				
			||||||
                <SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
 | 
					                <SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
 | 
				
			||||||
              </SelectGroup>
 | 
					              </SelectGroup>
 | 
				
			||||||
@@ -64,29 +111,14 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
 | 
					    <FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('globals.terms.user') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.team') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ComboBox
 | 
					          <SelectComboBox
 | 
				
			||||||
            v-bind="componentField"
 | 
					            v-bind="componentField"
 | 
				
			||||||
            :items="tStore.options"
 | 
					            :items="tStore.options"
 | 
				
			||||||
            :placeholder="t('form.field.selectTeam')"
 | 
					            :placeholder="t('form.field.selectTeam')"
 | 
				
			||||||
          >
 | 
					            type="team"
 | 
				
			||||||
            <template #item="{ item }">
 | 
					          />
 | 
				
			||||||
              <div class="flex items-center gap-2 ml-2">
 | 
					 | 
				
			||||||
                <span>{{ item.emoji }}</span>
 | 
					 | 
				
			||||||
                <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </template>
 | 
					 | 
				
			||||||
            <template #selected="{ selected }">
 | 
					 | 
				
			||||||
              <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                <span v-if="selected">
 | 
					 | 
				
			||||||
                  {{ selected.emoji }}
 | 
					 | 
				
			||||||
                  <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
                <span v-else>{{ t('form.field.selectTeam') }}</span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </template>
 | 
					 | 
				
			||||||
          </ComboBox>
 | 
					 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -94,35 +126,14 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
 | 
					    <FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('globals.terms.user') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.agent') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ComboBox
 | 
					          <SelectComboBox
 | 
				
			||||||
            v-bind="componentField"
 | 
					            v-bind="componentField"
 | 
				
			||||||
            :items="uStore.options"
 | 
					            :items="uStore.options"
 | 
				
			||||||
            :placeholder="t('form.field.selectUser')"
 | 
					            :placeholder="t('form.field.selectAgent')"
 | 
				
			||||||
          >
 | 
					            type="user"
 | 
				
			||||||
            <template #item="{ item }">
 | 
					          />
 | 
				
			||||||
              <div class="flex items-center gap-2 ml-2">
 | 
					 | 
				
			||||||
                <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                  <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					 | 
				
			||||||
                  <AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
 | 
					 | 
				
			||||||
                </Avatar>
 | 
					 | 
				
			||||||
                <span>{{ item.label }}</span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </template>
 | 
					 | 
				
			||||||
            <template #selected="{ selected }">
 | 
					 | 
				
			||||||
              <div class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                <div v-if="selected" class="flex items-center gap-2">
 | 
					 | 
				
			||||||
                  <Avatar class="w-7 h-7">
 | 
					 | 
				
			||||||
                    <AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
 | 
					 | 
				
			||||||
                    <AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
 | 
					 | 
				
			||||||
                  </Avatar>
 | 
					 | 
				
			||||||
                  <span>{{ selected.label }}</span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <span v-else>{{ t('form.field.selectUser') }}</span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </template>
 | 
					 | 
				
			||||||
          </ComboBox>
 | 
					 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -139,24 +150,24 @@ import { Button } from '@/components/ui/button'
 | 
				
			|||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
				
			||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
					 | 
				
			||||||
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
					import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '@/stores/team'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
  SelectGroup,
 | 
					  SelectGroup,
 | 
				
			||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue,
 | 
				
			||||||
 | 
					  SelectTag
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { macroActions } = useConversationFilters()
 | 
					const { macroActions } = useConversationFilters()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
@@ -189,7 +200,15 @@ const submitLabel = computed(() => {
 | 
				
			|||||||
  )
 | 
					  )
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
const form = useForm({
 | 
					const form = useForm({
 | 
				
			||||||
  validationSchema: toTypedSchema(createFormSchema(t))
 | 
					  validationSchema: toTypedSchema(createFormSchema(t)),
 | 
				
			||||||
 | 
					  initialValues: {
 | 
				
			||||||
 | 
					    visible_when: props.initialValues.visible_when || [
 | 
				
			||||||
 | 
					      'replying',
 | 
				
			||||||
 | 
					      'starting_conversation',
 | 
				
			||||||
 | 
					      'adding_private_note'
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    visibility: props.initialValues.visibility || 'all'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const actionConfig = ref({
 | 
					const actionConfig = ref({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'visibility',
 | 
					    accessorKey: 'visibility',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, t('admin.macro.visibility'))
 | 
					      return h('div', { class: 'text-center' }, t('globals.terms.visibility'))
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, row.getValue('visibility'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('visibility'))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@
 | 
				
			|||||||
      <AlertDialogHeader>
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
					        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
          {{ $t('admin.macro.deleteConfirmation') }}
 | 
					          {{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.macro') }) }}
 | 
				
			||||||
        </AlertDialogDescription>
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const actionSchema = (t) => z.array(
 | 
					const actionSchema = () => z.array(
 | 
				
			||||||
  z.object({
 | 
					  z.object({
 | 
				
			||||||
    type: z.string().min(1, t('admin.macro.actionTypeRequired')),
 | 
					    type: z.string().optional(),
 | 
				
			||||||
    value: z.array(z.string().min(1, t('admin.macro.actionValueRequired'))),
 | 
					    value: z.array(z.string()).optional(),
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -13,6 +13,7 @@ export const createFormSchema = (t) => z.object({
 | 
				
			|||||||
  message_content: z.string().optional(),
 | 
					  message_content: z.string().optional(),
 | 
				
			||||||
  actions: actionSchema(t).optional().default([]),
 | 
					  actions: actionSchema(t).optional().default([]),
 | 
				
			||||||
  visibility: z.enum(['all', 'team', 'user']),
 | 
					  visibility: z.enum(['all', 'team', 'user']),
 | 
				
			||||||
 | 
					  visible_when: z.array(z.enum(['replying', 'starting_conversation', 'adding_private_note'])),
 | 
				
			||||||
  team_id: z.string().nullable().optional(),
 | 
					  team_id: z.string().nullable().optional(),
 | 
				
			||||||
  user_id: z.string().nullable().optional(),
 | 
					  user_id: z.string().nullable().optional(),
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -34,19 +35,39 @@ export const createFormSchema = (t) => z.object({
 | 
				
			|||||||
  .refine(
 | 
					  .refine(
 | 
				
			||||||
    (data) => {
 | 
					    (data) => {
 | 
				
			||||||
      // If visibility is 'team', team_id is required
 | 
					      // If visibility is 'team', team_id is required
 | 
				
			||||||
      if (data.visibility === 'team' && !data.team_id) {
 | 
					      if (data.visibility === 'team') {
 | 
				
			||||||
        return false
 | 
					        return !!data.team_id
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      // If visibility is 'user', user_id is required
 | 
					 | 
				
			||||||
      if (data.visibility === 'user' && !data.user_id) {
 | 
					 | 
				
			||||||
        return false
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      // Otherwise, validation passes
 | 
					 | 
				
			||||||
      return true
 | 
					      return true
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      message: t('admin.macro.teamOrUserRequired'),
 | 
					      message: t('globals.messages.required'),
 | 
				
			||||||
 | 
					      path: ['team_id'],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  .refine(
 | 
				
			||||||
 | 
					    (data) => {
 | 
				
			||||||
 | 
					      // If visibility is 'user', user_id is required
 | 
				
			||||||
 | 
					      if (data.visibility === 'user') {
 | 
				
			||||||
 | 
					        return !!data.user_id
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return true
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      message: t('globals.messages.required'),
 | 
				
			||||||
 | 
					      path: ['user_id'],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  ).refine(
 | 
				
			||||||
 | 
					    (data) => {
 | 
				
			||||||
 | 
					      // if actions are present, all actions should have type and value defined.
 | 
				
			||||||
 | 
					      if (data.actions && data.actions.length > 0) {
 | 
				
			||||||
 | 
					        return data.actions.every(action => action.type?.length > 0 && action.value?.length > 0)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return true
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      message: t('admin.macro.actionInvalid'),
 | 
				
			||||||
      // Field path to highlight
 | 
					      // Field path to highlight
 | 
				
			||||||
      path: ['visibility'],
 | 
					      path: ['actions'],
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
@@ -30,7 +30,7 @@
 | 
				
			|||||||
        <div
 | 
					        <div
 | 
				
			||||||
          v-for="entity in permissions"
 | 
					          v-for="entity in permissions"
 | 
				
			||||||
          :key="entity.name"
 | 
					          :key="entity.name"
 | 
				
			||||||
          class="rounded-lg border border-border bg-card"
 | 
					          class="rounded border border-border bg-card"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <div class="border-b border-border bg-muted/30 px-5 py-3">
 | 
					          <div class="border-b border-border bg-muted/30 px-5 py-3">
 | 
				
			||||||
            <h4 class="font-medium text-card-foreground">{{ entity.name }}</h4>
 | 
					            <h4 class="font-medium text-card-foreground">{{ entity.name }}</h4>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
          <Input type="text" placeholder="6h" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="6h" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormDescription>
 | 
					        <FormDescription>
 | 
				
			||||||
          {{ t('admin.sla.firstResponseTime.description') }}
 | 
					          {{ t('globals.messages.golangDurationHoursMinutes') }}
 | 
				
			||||||
        </FormDescription>
 | 
					        </FormDescription>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -39,7 +39,20 @@
 | 
				
			|||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="24h" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="24h" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormDescription>{{ t('admin.sla.resolutionTime.description') }} </FormDescription>
 | 
					        <FormDescription>{{ t('globals.messages.golangDurationHoursMinutes') }} </FormDescription>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <FormField v-slot="{ componentField }" name="next_response_time">
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>{{ t('admin.sla.nextResponseTime') }}</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <Input type="text" placeholder="30m" v-bind="componentField" />
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormDescription>
 | 
				
			||||||
 | 
					          {{ t('globals.messages.golangDurationHoursMinutes') }}
 | 
				
			||||||
 | 
					        </FormDescription>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
@@ -82,7 +95,7 @@
 | 
				
			|||||||
          <div class="flex items-center justify-between mb-5">
 | 
					          <div class="flex items-center justify-between mb-5">
 | 
				
			||||||
            <div class="flex items-center gap-3">
 | 
					            <div class="flex items-center gap-3">
 | 
				
			||||||
              <span
 | 
					              <span
 | 
				
			||||||
                class="flex items-center justify-center w-8 h-8 rounded-lg"
 | 
					                class="flex items-center justify-center w-8 h-8 rounded"
 | 
				
			||||||
                :class="{
 | 
					                :class="{
 | 
				
			||||||
                  'bg-red-100/80 text-red-600': notification.type === 'breach',
 | 
					                  'bg-red-100/80 text-red-600': notification.type === 'breach',
 | 
				
			||||||
                  'bg-amber-100/80 text-amber-600': notification.type === 'warning'
 | 
					                  'bg-amber-100/80 text-amber-600': notification.type === 'warning'
 | 
				
			||||||
@@ -93,16 +106,19 @@
 | 
				
			|||||||
              </span>
 | 
					              </span>
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <div class="font-medium text-foreground">
 | 
					                <div class="font-medium text-foreground">
 | 
				
			||||||
                  {{ notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach') }} {{ t('admin.sla.notification') }}
 | 
					                  {{
 | 
				
			||||||
 | 
					                    notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach')
 | 
				
			||||||
 | 
					                  }}
 | 
				
			||||||
 | 
					                  {{ t('globals.terms.alert').toLowerCase() }}
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <p class="text-xs text-muted-foreground">
 | 
					                <p class="text-xs text-muted-foreground">
 | 
				
			||||||
                  {{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }}
 | 
					                  {{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach alert' }}
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              variant="ghost"
 | 
					              variant="ghost"
 | 
				
			||||||
              size="sm"
 | 
					              size="xs"
 | 
				
			||||||
              @click.prevent="removeNotification(index)"
 | 
					              @click.prevent="removeNotification(index)"
 | 
				
			||||||
              class="opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground"
 | 
					              class="opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
@@ -126,16 +142,16 @@
 | 
				
			|||||||
                      {{ t('admin.sla.triggerTiming') }}
 | 
					                      {{ t('admin.sla.triggerTiming') }}
 | 
				
			||||||
                    </FormLabel>
 | 
					                    </FormLabel>
 | 
				
			||||||
                    <FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
                      <Select v-bind="componentField" class="hover:border-foreground/30">
 | 
					                      <Select v-bind="componentField">
 | 
				
			||||||
                        <SelectTrigger class="w-full">
 | 
					                        <SelectTrigger class="w-full">
 | 
				
			||||||
                          <SelectValue />
 | 
					                          <SelectValue />
 | 
				
			||||||
                        </SelectTrigger>
 | 
					                        </SelectTrigger>
 | 
				
			||||||
                        <SelectContent>
 | 
					                        <SelectContent>
 | 
				
			||||||
                          <SelectGroup>
 | 
					                          <SelectGroup>
 | 
				
			||||||
                            <SelectItem value="immediately" class="focus:bg-accent">
 | 
					                            <SelectItem value="immediately">
 | 
				
			||||||
                              {{ t('admin.sla.immediatelyOnBreach') }}
 | 
					                              {{ t('admin.sla.immediatelyOnBreach') }}
 | 
				
			||||||
                            </SelectItem>
 | 
					                            </SelectItem>
 | 
				
			||||||
                            <SelectItem value="after" class="focus:bg-accent">
 | 
					                            <SelectItem value="after">
 | 
				
			||||||
                              {{ t('admin.sla.afterSpecificDuration') }}
 | 
					                              {{ t('admin.sla.afterSpecificDuration') }}
 | 
				
			||||||
                            </SelectItem>
 | 
					                            </SelectItem>
 | 
				
			||||||
                          </SelectGroup>
 | 
					                          </SelectGroup>
 | 
				
			||||||
@@ -149,26 +165,23 @@
 | 
				
			|||||||
                  <FormItem v-if="shouldShowTimeDelay(index)">
 | 
					                  <FormItem v-if="shouldShowTimeDelay(index)">
 | 
				
			||||||
                    <FormLabel class="flex items-center gap-1.5 text-sm font-medium">
 | 
					                    <FormLabel class="flex items-center gap-1.5 text-sm font-medium">
 | 
				
			||||||
                      <Hourglass class="w-4 h-4 text-muted-foreground" />
 | 
					                      <Hourglass class="w-4 h-4 text-muted-foreground" />
 | 
				
			||||||
                      {{ notification.type === 'warning' ? t('admin.sla.advanceWarning') : t('admin.sla.followUpDelay') }}
 | 
					                      {{
 | 
				
			||||||
 | 
					                        notification.type === 'warning'
 | 
				
			||||||
 | 
					                          ? t('admin.sla.advanceWarning')
 | 
				
			||||||
 | 
					                          : t('admin.sla.followUpDelay')
 | 
				
			||||||
 | 
					                      }}
 | 
				
			||||||
                    </FormLabel>
 | 
					                    </FormLabel>
 | 
				
			||||||
                    <FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
                      <Select v-bind="componentField" class="hover:border-foreground/30">
 | 
					                      <Input
 | 
				
			||||||
                        <SelectTrigger class="w-full">
 | 
					                        type="text"
 | 
				
			||||||
                          <SelectValue :placeholder="t('admin.sla.selectDuration')" />
 | 
					                        :placeholder="
 | 
				
			||||||
                        </SelectTrigger>
 | 
					                          t('globals.messages.enter', {
 | 
				
			||||||
                        <SelectContent>
 | 
					                            name: t('globals.terms.duration').toLowerCase()
 | 
				
			||||||
                          <SelectGroup>
 | 
					                          })
 | 
				
			||||||
                            <SelectItem
 | 
					                        "
 | 
				
			||||||
                              v-for="duration in delayDurations"
 | 
					                        v-bind="componentField"
 | 
				
			||||||
                              :key="duration"
 | 
					                        @keydown.enter.prevent
 | 
				
			||||||
                              :value="duration"
 | 
					                      />
 | 
				
			||||||
                              class="focus:bg-accent"
 | 
					 | 
				
			||||||
                            >
 | 
					 | 
				
			||||||
                              {{ duration }}
 | 
					 | 
				
			||||||
                            </SelectItem>
 | 
					 | 
				
			||||||
                          </SelectGroup>
 | 
					 | 
				
			||||||
                        </SelectContent>
 | 
					 | 
				
			||||||
                      </Select>
 | 
					 | 
				
			||||||
                    </FormControl>
 | 
					                    </FormControl>
 | 
				
			||||||
                    <FormMessage />
 | 
					                    <FormMessage />
 | 
				
			||||||
                  </FormItem>
 | 
					                  </FormItem>
 | 
				
			||||||
@@ -185,7 +198,7 @@
 | 
				
			|||||||
                <FormItem>
 | 
					                <FormItem>
 | 
				
			||||||
                  <FormLabel class="flex items-center gap-1.5 text-sm font-medium">
 | 
					                  <FormLabel class="flex items-center gap-1.5 text-sm font-medium">
 | 
				
			||||||
                    <Users class="w-4 h-4 text-muted-foreground" />
 | 
					                    <Users class="w-4 h-4 text-muted-foreground" />
 | 
				
			||||||
                    {{ t('admin.sla.notificationRecipients') }}
 | 
					                    {{ t('admin.sla.alertRecipients') }}
 | 
				
			||||||
                  </FormLabel>
 | 
					                  </FormLabel>
 | 
				
			||||||
                  <FormControl>
 | 
					                  <FormControl>
 | 
				
			||||||
                    <SelectTag
 | 
					                    <SelectTag
 | 
				
			||||||
@@ -205,6 +218,45 @@
 | 
				
			|||||||
                </FormItem>
 | 
					                </FormItem>
 | 
				
			||||||
              </FormField>
 | 
					              </FormField>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <FormField :name="`notifications.${index}.metric`" v-slot="{ componentField }">
 | 
				
			||||||
 | 
					              <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel class="flex items-center gap-1.5 text-sm font-medium">
 | 
				
			||||||
 | 
					                  <SlidersHorizontal class="w-4 h-4 text-muted-foreground" />
 | 
				
			||||||
 | 
					                  {{ t('globals.terms.slaMetric') }}
 | 
				
			||||||
 | 
					                </FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                  <Select v-bind="componentField">
 | 
				
			||||||
 | 
					                    <SelectTrigger class="w-full">
 | 
				
			||||||
 | 
					                      <SelectValue
 | 
				
			||||||
 | 
					                        :placeholder="
 | 
				
			||||||
 | 
					                          t('form.field.select', {
 | 
				
			||||||
 | 
					                            name: t('globals.terms.slaMetric').toLowerCase()
 | 
				
			||||||
 | 
					                          })
 | 
				
			||||||
 | 
					                        "
 | 
				
			||||||
 | 
					                      />
 | 
				
			||||||
 | 
					                    </SelectTrigger>
 | 
				
			||||||
 | 
					                    <SelectContent>
 | 
				
			||||||
 | 
					                      <SelectGroup>
 | 
				
			||||||
 | 
					                        <SelectItem value="all">
 | 
				
			||||||
 | 
					                          {{ t('globals.messages.all') }}
 | 
				
			||||||
 | 
					                        </SelectItem>
 | 
				
			||||||
 | 
					                        <SelectItem value="first_response">
 | 
				
			||||||
 | 
					                          {{ t('admin.sla.firstResponseTime') }}
 | 
				
			||||||
 | 
					                        </SelectItem>
 | 
				
			||||||
 | 
					                        <SelectItem value="next_response">
 | 
				
			||||||
 | 
					                          {{ t('admin.sla.nextResponseTime') }}
 | 
				
			||||||
 | 
					                        </SelectItem>
 | 
				
			||||||
 | 
					                        <SelectItem value="resolution">
 | 
				
			||||||
 | 
					                          {{ t('admin.sla.resolutionTime') }}
 | 
				
			||||||
 | 
					                        </SelectItem>
 | 
				
			||||||
 | 
					                      </SelectGroup>
 | 
				
			||||||
 | 
					                    </SelectContent>
 | 
				
			||||||
 | 
					                  </Select>
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					              </FormItem>
 | 
				
			||||||
 | 
					            </FormField>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
@@ -212,10 +264,10 @@
 | 
				
			|||||||
      <!-- Empty State -->
 | 
					      <!-- Empty State -->
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        v-else
 | 
					        v-else
 | 
				
			||||||
        class="flex flex-col items-center justify-center p-8 space-y-3 rounded-xl bg-muted/30 border border-dashed"
 | 
					        class="flex flex-col items-center justify-center p-8 space-y-3 rounded bg-muted/30 border border-dashed"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <Bell class="w-8 h-8 text-muted-foreground" />
 | 
					        <Bell class="w-8 h-8 text-muted-foreground" />
 | 
				
			||||||
        <p class="text-sm text-muted-foreground">{{ t('admin.sla.noNotificationsConfigured') }}</p>
 | 
					        <p class="text-sm text-muted-foreground">{{ t('admin.sla.noAlertsConfigured') }}</p>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -231,7 +283,17 @@ import { useForm } from 'vee-validate'
 | 
				
			|||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { createFormSchema } from './formSchema'
 | 
					import { createFormSchema } from './formSchema'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { X, Plus, Timer, CircleAlert, Users, Clock, Hourglass, Bell } from 'lucide-vue-next'
 | 
					import {
 | 
				
			||||||
 | 
					  X,
 | 
				
			||||||
 | 
					  Plus,
 | 
				
			||||||
 | 
					  Timer,
 | 
				
			||||||
 | 
					  CircleAlert,
 | 
				
			||||||
 | 
					  Users,
 | 
				
			||||||
 | 
					  Clock,
 | 
				
			||||||
 | 
					  Hourglass,
 | 
				
			||||||
 | 
					  Bell,
 | 
				
			||||||
 | 
					  SlidersHorizontal
 | 
				
			||||||
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  FormControl,
 | 
					  FormControl,
 | 
				
			||||||
@@ -274,27 +336,11 @@ const props = defineProps({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const usersStore = useUsersStore()
 | 
					const usersStore = useUsersStore()
 | 
				
			||||||
const submitLabel = computed(() => {
 | 
					const submitLabel = computed(() => {
 | 
				
			||||||
  return props.submitLabel || (props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
 | 
					  return (
 | 
				
			||||||
 | 
					    props.submitLabel ||
 | 
				
			||||||
 | 
					    (props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
const delayDurations = [
 | 
					 | 
				
			||||||
  '5m',
 | 
					 | 
				
			||||||
  '10m',
 | 
					 | 
				
			||||||
  '15m',
 | 
					 | 
				
			||||||
  '30m',
 | 
					 | 
				
			||||||
  '45m',
 | 
					 | 
				
			||||||
  '1h',
 | 
					 | 
				
			||||||
  '2h',
 | 
					 | 
				
			||||||
  '3h',
 | 
					 | 
				
			||||||
  '4h',
 | 
					 | 
				
			||||||
  '5h',
 | 
					 | 
				
			||||||
  '6h',
 | 
					 | 
				
			||||||
  '7h',
 | 
					 | 
				
			||||||
  '8h',
 | 
					 | 
				
			||||||
  '9h',
 | 
					 | 
				
			||||||
  '10h',
 | 
					 | 
				
			||||||
  '11h',
 | 
					 | 
				
			||||||
  '12h'
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
const form = useForm({
 | 
					const form = useForm({
 | 
				
			||||||
@@ -320,7 +366,8 @@ const addNotification = (type) => {
 | 
				
			|||||||
    type: type,
 | 
					    type: type,
 | 
				
			||||||
    time_delay_type: type === 'warning' ? 'before' : 'immediately',
 | 
					    time_delay_type: type === 'warning' ? 'before' : 'immediately',
 | 
				
			||||||
    time_delay: type === 'warning' ? '10m' : '',
 | 
					    time_delay: type === 'warning' ? '10m' : '',
 | 
				
			||||||
    recipients: []
 | 
					    recipients: [],
 | 
				
			||||||
 | 
					    metric: 'all'
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
  form.setFieldValue('notifications', notifications)
 | 
					  form.setFieldValue('notifications', notifications)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -341,6 +388,8 @@ watch(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const transformedNotifications = (newValues.notifications || []).map((notification) => ({
 | 
					    const transformedNotifications = (newValues.notifications || []).map((notification) => ({
 | 
				
			||||||
      ...notification,
 | 
					      ...notification,
 | 
				
			||||||
 | 
					      // Default value, notification applies to all metrics unless specified.
 | 
				
			||||||
 | 
					      metric: notification.metric || 'all',
 | 
				
			||||||
      time_delay_type:
 | 
					      time_delay_type:
 | 
				
			||||||
        notification.type === 'warning'
 | 
					        notification.type === 'warning'
 | 
				
			||||||
          ? 'before'
 | 
					          ? 'before'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,54 +1,79 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod'
 | 
				
			||||||
import { isGoHourMinuteDuration } from '@/utils/strings'
 | 
					import { isGoHourMinuteDuration } from '@/utils/strings'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createFormSchema = (t) => z.object({
 | 
					export const createFormSchema = (t) =>
 | 
				
			||||||
    name: z
 | 
					    z
 | 
				
			||||||
        .string()
 | 
					        .object({
 | 
				
			||||||
        .min(1, { message: t('admin.sla.name.valid') })
 | 
					            name: z
 | 
				
			||||||
        .max(255, { message: t('admin.sla.name.valid') }),
 | 
					                .string()
 | 
				
			||||||
    description: z
 | 
					                .min(1, { message: t('admin.sla.name.valid') })
 | 
				
			||||||
        .string()
 | 
					                .max(255, { message: t('admin.sla.name.valid') }),
 | 
				
			||||||
        .min(1, { message: t('admin.sla.description.valid') })
 | 
					            description: z
 | 
				
			||||||
        .max(255, { message: t('admin.sla.description.valid') }),
 | 
					                .string()
 | 
				
			||||||
    first_response_time: z.string().refine(isGoHourMinuteDuration, {
 | 
					                .min(1, { message: t('admin.sla.description.valid') })
 | 
				
			||||||
        message:
 | 
					                .max(255, { message: t('admin.sla.description.valid') }),
 | 
				
			||||||
            t('globals.messages.goHourMinuteDuration'),
 | 
					            first_response_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), {
 | 
				
			||||||
    }),
 | 
					                message: t('globals.messages.goHourMinuteDuration'),
 | 
				
			||||||
    resolution_time: z.string().refine(isGoHourMinuteDuration, {
 | 
					            }),
 | 
				
			||||||
        message:
 | 
					            resolution_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), {
 | 
				
			||||||
            t('globals.messages.goHourMinuteDuration'),
 | 
					                message: t('globals.messages.goHourMinuteDuration'),
 | 
				
			||||||
    }),
 | 
					            }),
 | 
				
			||||||
    notifications: z
 | 
					            next_response_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), {
 | 
				
			||||||
        .array(
 | 
					                message: t('globals.messages.goHourMinuteDuration'),
 | 
				
			||||||
            z
 | 
					            }),
 | 
				
			||||||
                .object({
 | 
					            notifications: z
 | 
				
			||||||
                    type: z.enum(['breach', 'warning']),
 | 
					                .array(
 | 
				
			||||||
                    time_delay_type: z.enum(['immediately', 'after', 'before']),
 | 
					                    z
 | 
				
			||||||
                    time_delay: z.string().optional(),
 | 
					                        .object({
 | 
				
			||||||
                    recipients: z
 | 
					                            type: z.enum(['breach', 'warning']),
 | 
				
			||||||
                        .array(z.string())
 | 
					                            time_delay_type: z.enum(['immediately', 'after', 'before']),
 | 
				
			||||||
                        .min(1, { message: t('globals.messages.atleastOneRecipient') })
 | 
					                            time_delay: z.string().optional(),
 | 
				
			||||||
 | 
					                            metric: z.enum(['first_response', 'resolution', 'next_response', 'all']),
 | 
				
			||||||
 | 
					                            recipients: z
 | 
				
			||||||
 | 
					                                .array(z.string())
 | 
				
			||||||
 | 
					                                .min(1, { message: t('globals.messages.atleastOneRecipient') }),
 | 
				
			||||||
 | 
					                        })
 | 
				
			||||||
 | 
					                        .superRefine((obj, ctx) => {
 | 
				
			||||||
 | 
					                            if (obj.time_delay_type !== 'immediately') {
 | 
				
			||||||
 | 
					                                if (!obj.time_delay || obj.time_delay === '') {
 | 
				
			||||||
 | 
					                                    ctx.addIssue({
 | 
				
			||||||
 | 
					                                        code: z.ZodIssueCode.custom,
 | 
				
			||||||
 | 
					                                        message: t('globals.messages.required'),
 | 
				
			||||||
 | 
					                                        path: ['time_delay'],
 | 
				
			||||||
 | 
					                                    });
 | 
				
			||||||
 | 
					                                } else if (!isGoHourMinuteDuration(obj.time_delay)) {
 | 
				
			||||||
 | 
					                                    ctx.addIssue({
 | 
				
			||||||
 | 
					                                        code: z.ZodIssueCode.custom,
 | 
				
			||||||
 | 
					                                        message: t('globals.messages.goHourMinuteDuration'),
 | 
				
			||||||
 | 
					                                        path: ['time_delay'],
 | 
				
			||||||
 | 
					                                    });
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        })
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .optional()
 | 
				
			||||||
 | 
					                .default([]),
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .superRefine((data, ctx) => {
 | 
				
			||||||
 | 
					            const { first_response_time, resolution_time, next_response_time } = data
 | 
				
			||||||
 | 
					            const isEmpty = !first_response_time && !resolution_time && !next_response_time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (isEmpty) {
 | 
				
			||||||
 | 
					                const msg = t('admin.sla.atleastOneSLATimeRequired')
 | 
				
			||||||
 | 
					                ctx.addIssue({
 | 
				
			||||||
 | 
					                    code: z.ZodIssueCode.custom,
 | 
				
			||||||
 | 
					                    path: ['first_response_time'],
 | 
				
			||||||
 | 
					                    message: msg,
 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
                .superRefine((obj, ctx) => {
 | 
					                ctx.addIssue({
 | 
				
			||||||
                    if (obj.time_delay_type !== 'immediately') {
 | 
					                    code: z.ZodIssueCode.custom,
 | 
				
			||||||
                        if (!obj.time_delay || obj.time_delay === '') {
 | 
					                    path: ['resolution_time'],
 | 
				
			||||||
                            ctx.addIssue({
 | 
					                    message: msg,
 | 
				
			||||||
                                code: z.ZodIssueCode.custom,
 | 
					 | 
				
			||||||
                                message:
 | 
					 | 
				
			||||||
                                    t('admin.sla.delay.required'),
 | 
					 | 
				
			||||||
                                path: ['time_delay']
 | 
					 | 
				
			||||||
                            })
 | 
					 | 
				
			||||||
                        } else if (!isGoHourMinuteDuration(obj.time_delay)) {
 | 
					 | 
				
			||||||
                            ctx.addIssue({
 | 
					 | 
				
			||||||
                                code: z.ZodIssueCode.custom,
 | 
					 | 
				
			||||||
                                message:
 | 
					 | 
				
			||||||
                                    t('globals.messages.goHourMinuteDuration'),
 | 
					 | 
				
			||||||
                                path: ['time_delay']
 | 
					 | 
				
			||||||
                            })
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
        )
 | 
					                ctx.addIssue({
 | 
				
			||||||
        .optional()
 | 
					                    code: z.ZodIssueCode.custom,
 | 
				
			||||||
        .default([])
 | 
					                    path: ['next_response_time'],
 | 
				
			||||||
})
 | 
					                    message: msg,
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="subject" v-if="!isOutgoingTemplate">
 | 
					    <FormField v-slot="{ componentField }" name="subject" v-if="!isOutgoingTemplate">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.subject') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.subject') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,32 +1,15 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <CommandDialog
 | 
					  <CommandDialog :open="open" @update:open="toggleOpen" class="transform-gpu z-[51] !min-w-[50vw]">
 | 
				
			||||||
    :open="open"
 | 
					 | 
				
			||||||
    @update:open="handleOpenChange"
 | 
					 | 
				
			||||||
    class="transform-gpu z-[51] !min-w-[50vw] !min-h-[60vh]"
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    <CommandInput :placeholder="t('command.typeCmdOrSearch')" @keydown="onInputKeydown" />
 | 
					    <CommandInput :placeholder="t('command.typeCmdOrSearch')" @keydown="onInputKeydown" />
 | 
				
			||||||
    <CommandList class="!min-h-[60vh] !min-w-[50vw]">
 | 
					    <CommandList
 | 
				
			||||||
 | 
					      class="!min-h-[60vh] h-[60vh] !min-w-[50vw]"
 | 
				
			||||||
 | 
					      :class="{ 'overflow-hidden': nestedCommand === 'apply-macro' }"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <CommandEmpty>
 | 
					      <CommandEmpty>
 | 
				
			||||||
        <p class="text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
 | 
					        <p class="text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
 | 
				
			||||||
      </CommandEmpty>
 | 
					      </CommandEmpty>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Commands requiring a conversation to be open -->
 | 
					      <!-- Snooze Options -->
 | 
				
			||||||
      <CommandGroup
 | 
					 | 
				
			||||||
        :heading="t('globals.terms.conversation', 2)"
 | 
					 | 
				
			||||||
        value="conversations"
 | 
					 | 
				
			||||||
        v-if="nestedCommand === null && conversationStore.hasConversationOpen"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
 | 
					 | 
				
			||||||
          {{ $t('globals.messages.snooze') }}
 | 
					 | 
				
			||||||
        </CommandItem>
 | 
					 | 
				
			||||||
        <CommandItem value="conv-resolve" @select="resolveConversation">
 | 
					 | 
				
			||||||
          {{ $t('globals.messages.resolve') }}
 | 
					 | 
				
			||||||
        </CommandItem>
 | 
					 | 
				
			||||||
        <CommandItem value="apply-macro" @select="setNestedCommand('apply-macro')">
 | 
					 | 
				
			||||||
          {{ $t('globals.messages.applyMacro') }}
 | 
					 | 
				
			||||||
        </CommandItem>
 | 
					 | 
				
			||||||
      </CommandGroup>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
 | 
					      <CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
 | 
				
			||||||
        <CommandItem value="1 hour" @select="handleSnooze(60)">
 | 
					        <CommandItem value="1 hour" @select="handleSnooze(60)">
 | 
				
			||||||
          1 {{ $t('globals.terms.hour') }}
 | 
					          1 {{ $t('globals.terms.hour') }}
 | 
				
			||||||
@@ -52,22 +35,27 @@
 | 
				
			|||||||
      </CommandGroup>
 | 
					      </CommandGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Macros -->
 | 
					      <!-- Macros -->
 | 
				
			||||||
      <div v-if="nestedCommand === 'apply-macro'" class="bg-background">
 | 
					      <div
 | 
				
			||||||
        <CommandGroup heading="Apply macro" class="pb-2">
 | 
					        v-if="
 | 
				
			||||||
          <div class="min-h-[400px] overflow-auto">
 | 
					          nestedCommand === 'apply-macro-to-existing-conversation' ||
 | 
				
			||||||
            <div class="grid grid-cols-12 gap-3">
 | 
					          nestedCommand === 'apply-macro-to-new-conversation'
 | 
				
			||||||
 | 
					        "
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <CommandGroup heading="Apply macro">
 | 
				
			||||||
 | 
					          <div class="min-h-[400px]">
 | 
				
			||||||
 | 
					            <div class="h-[60vh] grid grid-cols-12">
 | 
				
			||||||
              <!-- Left Column: Macro List (30%) -->
 | 
					              <!-- Left Column: Macro List (30%) -->
 | 
				
			||||||
              <div class="col-span-4 pr-2 border-r">
 | 
					              <div class="col-span-4 pr-4 border-r overflow-y-auto h-full">
 | 
				
			||||||
                <CommandItem
 | 
					                <CommandItem
 | 
				
			||||||
                  v-for="(macro, index) in macroStore.macroOptions"
 | 
					                  v-for="(macro, index) in macroStore.macroOptions"
 | 
				
			||||||
                  :key="macro.value"
 | 
					                  :key="macro.value"
 | 
				
			||||||
                  :value="macro.label"
 | 
					                  :value="macro.label + '|' + index"
 | 
				
			||||||
                  :data-index="index"
 | 
					                  :data-index="index"
 | 
				
			||||||
                  @select="handleApplyMacro(macro)"
 | 
					                  @select="handleApplyMacro(macro)"
 | 
				
			||||||
                  class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
 | 
					                  class="px-3 py-2 rounded cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <div class="flex items-center gap-2">
 | 
					                  <div class="flex items-center gap-2">
 | 
				
			||||||
                    <Zap size="14" class="text-primary shrink-0" />
 | 
					                    <Zap size="14" class="shrink-0" />
 | 
				
			||||||
                    <span class="text-sm truncate w-full break-words whitespace-normal">{{
 | 
					                    <span class="text-sm truncate w-full break-words whitespace-normal">{{
 | 
				
			||||||
                      macro.label
 | 
					                      macro.label
 | 
				
			||||||
                    }}</span>
 | 
					                    }}</span>
 | 
				
			||||||
@@ -76,67 +64,63 @@
 | 
				
			|||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <!-- Right Column: Macro Details (70%) -->
 | 
					              <!-- Right Column: Macro Details (70%) -->
 | 
				
			||||||
              <div class="col-span-8 pl-2">
 | 
					              <div class="col-span-8 px-4 overflow-y-auto h-full pb-12">
 | 
				
			||||||
                <div class="space-y-3 text-xs">
 | 
					                <div class="space-y-3 text-xs">
 | 
				
			||||||
                  <!-- Reply Preview -->
 | 
					                  <!-- Reply Preview -->
 | 
				
			||||||
                  <div v-if="replyContent" class="space-y-1">
 | 
					                  <div v-if="replyContent" class="space-y-2">
 | 
				
			||||||
                    <p class="text-xs font-semibold text-primary">
 | 
					                    <p class="text-xs font-semibold text-foreground">
 | 
				
			||||||
                      {{ $t('command.replyPreview') }}
 | 
					                      {{ $t('command.replyPreview') }}
 | 
				
			||||||
                    </p>
 | 
					                    </p>
 | 
				
			||||||
                    <div
 | 
					                    <div
 | 
				
			||||||
                      class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html"
 | 
					                      class="w-full min-h-200 p-2 bg-muted/50 rounded overflow-auto shadow native-html"
 | 
				
			||||||
                      v-dompurify-html="replyContent"
 | 
					                      v-dompurify-html="replyContent"
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  <!-- Actions -->
 | 
					                  <!-- Actions -->
 | 
				
			||||||
                  <div v-if="otherActions.length > 0" class="space-y-1">
 | 
					                  <div v-if="otherActions.length > 0" class="space-y-2">
 | 
				
			||||||
                    <p class="text-xs font-semibold text-primary">
 | 
					                    <p class="text-xs font-semibold">
 | 
				
			||||||
                      {{ $t('globals.terms.action', 2) }}
 | 
					                      {{ $t('globals.terms.action', 2) }}
 | 
				
			||||||
                    </p>
 | 
					                    </p>
 | 
				
			||||||
                    <div class="space-y-1.5 max-w-sm">
 | 
					                    <div class="space-y-1.5 max-w-sm">
 | 
				
			||||||
                      <div
 | 
					                      <div
 | 
				
			||||||
                        v-for="action in otherActions"
 | 
					                        v-for="action in otherActions"
 | 
				
			||||||
                        :key="action.type"
 | 
					                        :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"
 | 
					                        class="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-muted/50 hover:bg-accent hover:text-accent-foreground transition duration-200 group"
 | 
				
			||||||
                      >
 | 
					                      >
 | 
				
			||||||
                        <div
 | 
					                        <div
 | 
				
			||||||
                          class="p-1 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-colors duration-200"
 | 
					                          class="p-1 rounded-full group-hover:bg-muted/20 transition duration-200"
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                          <User
 | 
					                          <User v-if="action.type === 'assign_user'" :size="10" class="shrink-0" />
 | 
				
			||||||
                            v-if="action.type === 'assign_user'"
 | 
					 | 
				
			||||||
                            :size="10"
 | 
					 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					 | 
				
			||||||
                          />
 | 
					 | 
				
			||||||
                          <Users
 | 
					                          <Users
 | 
				
			||||||
                            v-else-if="action.type === 'assign_team'"
 | 
					                            v-else-if="action.type === 'assign_team'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                          <Pin
 | 
					                          <Pin
 | 
				
			||||||
                            v-else-if="action.type === 'set_status'"
 | 
					                            v-else-if="action.type === 'set_status'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                          <Rocket
 | 
					                          <Rocket
 | 
				
			||||||
                            v-else-if="action.type === 'set_priority'"
 | 
					                            v-else-if="action.type === 'set_priority'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                          <Tags
 | 
					                          <Tags
 | 
				
			||||||
                            v-else-if="action.type === 'add_tags'"
 | 
					                            v-else-if="action.type === 'add_tags'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                          <Tags
 | 
					                          <Tags
 | 
				
			||||||
                            v-else-if="action.type === 'set_tags'"
 | 
					                            v-else-if="action.type === 'set_tags'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                          <Tags
 | 
					                          <Tags
 | 
				
			||||||
                            v-else-if="action.type === 'remove_tags'"
 | 
					                            v-else-if="action.type === 'remove_tags'"
 | 
				
			||||||
                            :size="10"
 | 
					                            :size="10"
 | 
				
			||||||
                            class="shrink-0 text-primary"
 | 
					                            class="shrink-0"
 | 
				
			||||||
                          />
 | 
					                          />
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                        <span class="truncate">{{ getActionLabel(action) }}</span>
 | 
					                        <span class="truncate">{{ getActionLabel(action) }}</span>
 | 
				
			||||||
@@ -159,6 +143,26 @@
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </CommandGroup>
 | 
					        </CommandGroup>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- Commands requiring a conversation to be open -->
 | 
				
			||||||
 | 
					      <CommandGroup
 | 
				
			||||||
 | 
					        :heading="t('globals.terms.conversation', 2)"
 | 
				
			||||||
 | 
					        value="conversations"
 | 
				
			||||||
 | 
					        v-else-if="conversationStore.hasConversationOpen && !nestedCommand"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
 | 
				
			||||||
 | 
					          {{ $t('globals.messages.snooze') }}
 | 
				
			||||||
 | 
					        </CommandItem>
 | 
				
			||||||
 | 
					        <CommandItem value="conv-resolve" @select="resolveConversation">
 | 
				
			||||||
 | 
					          {{ $t('globals.messages.resolve') }}
 | 
				
			||||||
 | 
					        </CommandItem>
 | 
				
			||||||
 | 
					        <CommandItem
 | 
				
			||||||
 | 
					          value="apply-macro"
 | 
				
			||||||
 | 
					          @select="setNestedCommand('apply-macro-to-existing-conversation')"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {{ $t('globals.messages.applyMacro') }}
 | 
				
			||||||
 | 
					        </CommandItem>
 | 
				
			||||||
 | 
					      </CommandGroup>
 | 
				
			||||||
    </CommandList>
 | 
					    </CommandList>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Navigation -->
 | 
					    <!-- Navigation -->
 | 
				
			||||||
@@ -252,7 +256,7 @@ const { Meta_K, Ctrl_K } = useMagicKeys({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
watch([Meta_K, Ctrl_K], ([mac, win]) => {
 | 
					watch([Meta_K, Ctrl_K], ([mac, win]) => {
 | 
				
			||||||
  if (mac || win) handleOpenChange()
 | 
					  if (mac || win) toggleOpen()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const highlightedMacro = ref(null)
 | 
					const highlightedMacro = ref(null)
 | 
				
			||||||
@@ -260,8 +264,12 @@ const highlightedMacro = ref(null)
 | 
				
			|||||||
function handleApplyMacro(macro) {
 | 
					function handleApplyMacro(macro) {
 | 
				
			||||||
  // Create a deep copy.
 | 
					  // Create a deep copy.
 | 
				
			||||||
  const plainMacro = JSON.parse(JSON.stringify(macro))
 | 
					  const plainMacro = JSON.parse(JSON.stringify(macro))
 | 
				
			||||||
  conversationStore.setMacro(plainMacro)
 | 
					  if (nestedCommand.value === 'apply-macro-to-new-conversation') {
 | 
				
			||||||
  handleOpenChange()
 | 
					    conversationStore.setMacro(plainMacro, 'new-conversation')
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    conversationStore.setMacro(plainMacro, 'reply')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  toggleOpen()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getActionLabel = computed(() => (action) => {
 | 
					const getActionLabel = computed(() => (action) => {
 | 
				
			||||||
@@ -286,8 +294,10 @@ const otherActions = computed(
 | 
				
			|||||||
    ) || []
 | 
					    ) || []
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function handleOpenChange() {
 | 
					function toggleOpen() {
 | 
				
			||||||
  if (!open.value) nestedCommand.value = null
 | 
					  if (nestedCommand.value != 'apply-macro-to-new-conversation' && !open.value) {
 | 
				
			||||||
 | 
					    nestedCommand.value = null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  open.value = !open.value
 | 
					  open.value = !open.value
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -301,16 +311,16 @@ function formatDuration(minutes) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
async function handleSnooze(minutes) {
 | 
					async function handleSnooze(minutes) {
 | 
				
			||||||
  await conversationStore.snoozeConversation(formatDuration(minutes))
 | 
					  await conversationStore.snoozeConversation(formatDuration(minutes))
 | 
				
			||||||
  handleOpenChange()
 | 
					  toggleOpen()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function resolveConversation() {
 | 
					async function resolveConversation() {
 | 
				
			||||||
  await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
 | 
					  await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
 | 
				
			||||||
  handleOpenChange()
 | 
					  toggleOpen()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function showCustomDialog() {
 | 
					function showCustomDialog() {
 | 
				
			||||||
  handleOpenChange()
 | 
					  toggleOpen()
 | 
				
			||||||
  showDatePicker.value = true
 | 
					  showDatePicker.value = true
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -330,7 +340,7 @@ function handleCustomSnooze() {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
  handleSnooze(diffMinutes)
 | 
					  handleSnooze(diffMinutes)
 | 
				
			||||||
  closeDatePicker()
 | 
					  closeDatePicker()
 | 
				
			||||||
  handleOpenChange()
 | 
					  toggleOpen()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function onInputKeydown(e) {
 | 
					function onInputKeydown(e) {
 | 
				
			||||||
@@ -344,9 +354,9 @@ function onInputKeydown(e) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
 | 
					  emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (data) => {
 | 
				
			||||||
    setNestedCommand(command)
 | 
					    setNestedCommand(data.command)
 | 
				
			||||||
    open.value = true
 | 
					    open.value = data.open
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
  watchHighlightedMacro()
 | 
					  watchHighlightedMacro()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@
 | 
				
			|||||||
  <div class="w-full space-y-6 pb-8 relative">
 | 
					  <div class="w-full space-y-6 pb-8 relative">
 | 
				
			||||||
    <!-- Header -->
 | 
					    <!-- Header -->
 | 
				
			||||||
    <div class="flex items-center justify-between mb-4">
 | 
					    <div class="flex items-center justify-between mb-4">
 | 
				
			||||||
      <span class="text-xl font-semibold text-gray-900">{{ $t('globals.terms.note', 2) }}</span>
 | 
					      <span class="text-xl font-semibold text-gray-900 dark:text-foreground">{{ $t('globals.terms.note', 2) }}</span>
 | 
				
			||||||
      <Button
 | 
					      <Button
 | 
				
			||||||
        variant="outline"
 | 
					        variant="outline"
 | 
				
			||||||
        size="sm"
 | 
					        size="sm"
 | 
				
			||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
            <Editor
 | 
					            <Editor
 | 
				
			||||||
              v-model:htmlContent="newNote"
 | 
					              v-model:htmlContent="newNote"
 | 
				
			||||||
              @update:htmlContent="(value) => (newNote = value)"
 | 
					              @update:htmlContent="(value) => (newNote = value)"
 | 
				
			||||||
              :placeholder="t('editor.placeholder')"
 | 
					              :placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="flex justify-end space-x-3 pt-2">
 | 
					          <div class="flex justify-end space-x-3 pt-2">
 | 
				
			||||||
@@ -54,7 +54,7 @@
 | 
				
			|||||||
        class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
 | 
					        class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <!-- Header -->
 | 
					        <!-- Header -->
 | 
				
			||||||
        <CardHeader class="bg-gray-50/50 border-b p-2">
 | 
					        <CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2">
 | 
				
			||||||
          <div class="flex items-center justify-between">
 | 
					          <div class="flex items-center justify-between">
 | 
				
			||||||
            <div class="flex items-center space-x-3">
 | 
					            <div class="flex items-center space-x-3">
 | 
				
			||||||
              <Avatar class="border border-gray-200 shadow-sm">
 | 
					              <Avatar class="border border-gray-200 shadow-sm">
 | 
				
			||||||
@@ -64,7 +64,7 @@
 | 
				
			|||||||
                </AvatarFallback>
 | 
					                </AvatarFallback>
 | 
				
			||||||
              </Avatar>
 | 
					              </Avatar>
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <p class="text-sm font-medium text-gray-900">{{ note.first_name }} {{ note.last_name }}</p>
 | 
					                <p class="text-sm font-medium text-gray-900 dark:text-foreground">{{ note.first_name }} {{ note.last_name }}</p>
 | 
				
			||||||
                <p class="text-xs text-muted-foreground flex items-center">
 | 
					                <p class="text-xs text-muted-foreground flex items-center">
 | 
				
			||||||
                  <ClockIcon class="h-3 w-3 mr-1 inline-block opacity-70" />
 | 
					                  <ClockIcon class="h-3 w-3 mr-1 inline-block opacity-70" />
 | 
				
			||||||
                  {{ formatDate(note.created_at) }}
 | 
					                  {{ formatDate(note.created_at) }}
 | 
				
			||||||
@@ -109,13 +109,13 @@
 | 
				
			|||||||
    <!-- No notes message -->
 | 
					    <!-- No notes message -->
 | 
				
			||||||
    <div
 | 
					    <div
 | 
				
			||||||
      v-if="notes.length === 0 && !isAddingNote && !isLoading"
 | 
					      v-if="notes.length === 0 && !isAddingNote && !isLoading"
 | 
				
			||||||
      class="box border-dashed p-10 text-center bg-gray-50/50 mt-6"
 | 
					      class="box border-dashed p-10 text-center bg-gray-50/50 mt-6 dark:bg-background"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div class="flex flex-col items-center">
 | 
					      <div class="flex flex-col items-center">
 | 
				
			||||||
        <div class="rounded-full bg-gray-100 p-4 mb-2">
 | 
					        <div class="rounded-full bg-gray-100 dark:bg-foreground p-4 mb-2">
 | 
				
			||||||
          <MessageSquareIcon class="text-gray-400" size="25" />
 | 
					          <MessageSquareIcon class="text-gray-400 dark:text-background" size="25" />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <h3 class="mt-2 text-base font-medium text-gray-900">{{ $t('contact.notes.empty') }}</h3>
 | 
					        <h3 class="mt-2 text-base font-medium text-gray-900 dark:text-foreground">{{ $t('contact.notes.empty') }}</h3>
 | 
				
			||||||
        <p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
 | 
					        <p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
 | 
				
			||||||
          {{ $t('contact.notes.help') }}
 | 
					          {{ $t('contact.notes.help') }}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
@@ -148,7 +148,7 @@ import {
 | 
				
			|||||||
  ClockIcon,
 | 
					  ClockIcon,
 | 
				
			||||||
  MessageSquareIcon
 | 
					  MessageSquareIcon
 | 
				
			||||||
} from 'lucide-vue-next'
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
        <DropdownMenu>
 | 
					        <DropdownMenu>
 | 
				
			||||||
          <DropdownMenuTrigger>
 | 
					          <DropdownMenuTrigger>
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
              class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
 | 
					              class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded text-sm"
 | 
				
			||||||
              v-if="!conversationStore.conversation.loading"
 | 
					              v-if="!conversationStore.conversation.loading"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <span class="text-secondary font-medium inline-block">
 | 
					              <span class="text-secondary font-medium inline-block">
 | 
				
			||||||
@@ -63,7 +63,10 @@ const emitter = useEmitter()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const handleUpdateStatus = (status) => {
 | 
					const handleUpdateStatus = (status) => {
 | 
				
			||||||
  if (status === CONVERSATION_DEFAULT_STATUSES.SNOOZED) {
 | 
					  if (status === CONVERSATION_DEFAULT_STATUSES.SNOOZED) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, 'snooze')
 | 
					    emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
 | 
				
			||||||
 | 
					      command: 'snooze',
 | 
				
			||||||
 | 
					      open: true
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
    return
 | 
					    return
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  conversationStore.updateStatus(status)
 | 
					  conversationStore.updateStatus(status)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,228 +1,213 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Dialog :open="dialogOpen" @update:open="dialogOpen = false">
 | 
					  <div>
 | 
				
			||||||
    <DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
 | 
					    <Dialog v-model:open="dialogOpen">
 | 
				
			||||||
      <DialogHeader>
 | 
					      <DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
 | 
				
			||||||
        <DialogTitle>
 | 
					        <DialogHeader>
 | 
				
			||||||
          {{
 | 
					          <DialogTitle>
 | 
				
			||||||
            $t('globals.messages.new', {
 | 
					            {{
 | 
				
			||||||
              name: $t('globals.terms.conversation')
 | 
					              $t('globals.messages.new', {
 | 
				
			||||||
            })
 | 
					                name: $t('globals.terms.conversation').toLowerCase()
 | 
				
			||||||
          }}
 | 
					              })
 | 
				
			||||||
        </DialogTitle>
 | 
					            }}
 | 
				
			||||||
      </DialogHeader>
 | 
					          </DialogTitle>
 | 
				
			||||||
      <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
 | 
					        </DialogHeader>
 | 
				
			||||||
        <div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
 | 
					        <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
 | 
				
			||||||
          <FormField name="contact_email">
 | 
					          <!-- Form Fields Section -->
 | 
				
			||||||
            <FormItem class="relative">
 | 
					          <div class="space-y-4 pb-2 flex-shrink-0">
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.email') }}</FormLabel>
 | 
					            <div class="space-y-2">
 | 
				
			||||||
              <FormControl>
 | 
					              <FormField name="contact_email">
 | 
				
			||||||
                <Input
 | 
					                <FormItem class="relative">
 | 
				
			||||||
                  type="email"
 | 
					                  <FormLabel>{{ $t('form.field.email') }}</FormLabel>
 | 
				
			||||||
                  :placeholder="t('conversation.searchContact')"
 | 
					                  <FormControl>
 | 
				
			||||||
                  v-model="emailQuery"
 | 
					                    <Input
 | 
				
			||||||
                  @input="handleSearchContacts"
 | 
					                      type="email"
 | 
				
			||||||
                  autocomplete="off"
 | 
					                      :placeholder="t('conversation.searchContact')"
 | 
				
			||||||
                />
 | 
					                      v-model="emailQuery"
 | 
				
			||||||
              </FormControl>
 | 
					                      @input="handleSearchContacts"
 | 
				
			||||||
              <FormMessage />
 | 
					                      autocomplete="off"
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                  </FormControl>
 | 
				
			||||||
 | 
					                  <FormMessage />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <ul
 | 
					                  <ul
 | 
				
			||||||
                v-if="searchResults.length"
 | 
					                    v-if="searchResults.length"
 | 
				
			||||||
                class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg"
 | 
					                    class="border rounded p-2 max-h-60 overflow-y-auto absolute w-full z-50 shadow bg-background"
 | 
				
			||||||
              >
 | 
					                  >
 | 
				
			||||||
                <li
 | 
					                    <li
 | 
				
			||||||
                  v-for="contact in searchResults"
 | 
					                      v-for="contact in searchResults"
 | 
				
			||||||
                  :key="contact.email"
 | 
					                      :key="contact.email"
 | 
				
			||||||
                  @click="selectContact(contact)"
 | 
					                      @click="selectContact(contact)"
 | 
				
			||||||
                  class="cursor-pointer p-2 hover:bg-gray-100 rounded"
 | 
					                      class="cursor-pointer p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
 | 
				
			||||||
                >
 | 
					                    >
 | 
				
			||||||
                  {{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
 | 
					                      {{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
 | 
				
			||||||
                </li>
 | 
					                    </li>
 | 
				
			||||||
              </ul>
 | 
					                  </ul>
 | 
				
			||||||
            </FormItem>
 | 
					                </FormItem>
 | 
				
			||||||
          </FormField>
 | 
					              </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="first_name">
 | 
					              <!-- Name Group -->
 | 
				
			||||||
            <FormItem>
 | 
					              <div class="grid grid-cols-2 gap-4">
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
 | 
					                <FormField v-slot="{ componentField }" name="first_name">
 | 
				
			||||||
              <FormControl>
 | 
					                  <FormItem>
 | 
				
			||||||
                <Input type="text" placeholder="" v-bind="componentField" required />
 | 
					                    <FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
 | 
				
			||||||
              </FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
              <FormMessage />
 | 
					                      <Input type="text" placeholder="" v-bind="componentField" required />
 | 
				
			||||||
            </FormItem>
 | 
					                    </FormControl>
 | 
				
			||||||
          </FormField>
 | 
					                    <FormMessage />
 | 
				
			||||||
 | 
					                  </FormItem>
 | 
				
			||||||
 | 
					                </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="last_name">
 | 
					                <FormField v-slot="{ componentField }" name="last_name">
 | 
				
			||||||
            <FormItem>
 | 
					                  <FormItem>
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
 | 
					                    <FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
 | 
				
			||||||
              <FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
                <Input type="text" placeholder="" v-bind="componentField" required />
 | 
					                      <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
              </FormControl>
 | 
					                    </FormControl>
 | 
				
			||||||
              <FormMessage />
 | 
					                    <FormMessage />
 | 
				
			||||||
            </FormItem>
 | 
					                  </FormItem>
 | 
				
			||||||
          </FormField>
 | 
					                </FormField>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="subject">
 | 
					              <!-- Subject and Inbox Group -->
 | 
				
			||||||
            <FormItem>
 | 
					              <div class="grid grid-cols-2 gap-4">
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.subject') }}</FormLabel>
 | 
					                <FormField v-slot="{ componentField }" name="subject">
 | 
				
			||||||
              <FormControl>
 | 
					                  <FormItem>
 | 
				
			||||||
                <Input type="text" placeholder="" v-bind="componentField" required />
 | 
					                    <FormLabel>{{ $t('globals.terms.subject') }}</FormLabel>
 | 
				
			||||||
              </FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
              <FormMessage />
 | 
					                      <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
            </FormItem>
 | 
					                    </FormControl>
 | 
				
			||||||
          </FormField>
 | 
					                    <FormMessage />
 | 
				
			||||||
 | 
					                  </FormItem>
 | 
				
			||||||
 | 
					                </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="inbox_id">
 | 
					                <FormField v-slot="{ componentField }" name="inbox_id">
 | 
				
			||||||
            <FormItem>
 | 
					                  <FormItem>
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.inbox') }}</FormLabel>
 | 
					                    <FormLabel>{{ $t('form.field.inbox') }}</FormLabel>
 | 
				
			||||||
              <FormControl>
 | 
					                    <FormControl>
 | 
				
			||||||
                <Select v-bind="componentField">
 | 
					                      <Select v-bind="componentField">
 | 
				
			||||||
                  <SelectTrigger>
 | 
					                        <SelectTrigger>
 | 
				
			||||||
                    <SelectValue :placeholder="t('form.field.selectInbox')" />
 | 
					                          <SelectValue :placeholder="t('form.field.selectInbox')" />
 | 
				
			||||||
                  </SelectTrigger>
 | 
					                        </SelectTrigger>
 | 
				
			||||||
                  <SelectContent>
 | 
					                        <SelectContent>
 | 
				
			||||||
                    <SelectGroup>
 | 
					                          <SelectGroup>
 | 
				
			||||||
                      <SelectItem
 | 
					                            <SelectItem
 | 
				
			||||||
                        v-for="option in inboxStore.options"
 | 
					                              v-for="option in inboxStore.options"
 | 
				
			||||||
                        :key="option.value"
 | 
					                              :key="option.value"
 | 
				
			||||||
                        :value="option.value"
 | 
					                              :value="option.value"
 | 
				
			||||||
                      >
 | 
					                            >
 | 
				
			||||||
                        {{ option.label }}
 | 
					                              {{ option.label }}
 | 
				
			||||||
                      </SelectItem>
 | 
					                            </SelectItem>
 | 
				
			||||||
                    </SelectGroup>
 | 
					                          </SelectGroup>
 | 
				
			||||||
                  </SelectContent>
 | 
					                        </SelectContent>
 | 
				
			||||||
                </Select>
 | 
					                      </Select>
 | 
				
			||||||
              </FormControl>
 | 
					                    </FormControl>
 | 
				
			||||||
              <FormMessage />
 | 
					                    <FormMessage />
 | 
				
			||||||
            </FormItem>
 | 
					                  </FormItem>
 | 
				
			||||||
          </FormField>
 | 
					                </FormField>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <!-- Set assigned team -->
 | 
					              <!-- Assignment Group -->
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="team_id">
 | 
					              <div class="grid grid-cols-2 gap-4">
 | 
				
			||||||
            <FormItem>
 | 
					                <!-- Set assigned team -->
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.assignTeamOptional') }}</FormLabel>
 | 
					                <FormField v-slot="{ componentField }" name="team_id">
 | 
				
			||||||
              <FormControl>
 | 
					                  <FormItem>
 | 
				
			||||||
                <ComboBox
 | 
					                    <FormLabel
 | 
				
			||||||
                  v-bind="componentField"
 | 
					                      >{{ $t('form.field.assignTeam') }} ({{
 | 
				
			||||||
                  :items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
 | 
					                        $t('globals.terms.optional').toLowerCase()
 | 
				
			||||||
                  :placeholder="t('form.field.selectTeam')"
 | 
					                      }})</FormLabel
 | 
				
			||||||
                >
 | 
					                    >
 | 
				
			||||||
                  <template #item="{ item }">
 | 
					                    <FormControl>
 | 
				
			||||||
                    <div class="flex items-center gap-3 py-2">
 | 
					                      <SelectComboBox
 | 
				
			||||||
                      <div class="w-7 h-7 flex items-center justify-center">
 | 
					                        v-bind="componentField"
 | 
				
			||||||
                        <span v-if="item.emoji">{{ item.emoji }}</span>
 | 
					                        :items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
 | 
				
			||||||
                        <div
 | 
					                        :placeholder="t('form.field.selectTeam')"
 | 
				
			||||||
                          v-else
 | 
					                        type="team"
 | 
				
			||||||
                          class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
 | 
					                      />
 | 
				
			||||||
                        >
 | 
					                    </FormControl>
 | 
				
			||||||
                          <Users size="14" />
 | 
					                    <FormMessage />
 | 
				
			||||||
                        </div>
 | 
					                  </FormItem>
 | 
				
			||||||
                      </div>
 | 
					                </FormField>
 | 
				
			||||||
                      <span class="text-sm">{{ item.label }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  <template #selected="{ selected }">
 | 
					                <!-- Set assigned agent -->
 | 
				
			||||||
                    <div class="flex items-center gap-3">
 | 
					                <FormField v-slot="{ componentField }" name="agent_id">
 | 
				
			||||||
                      <div class="w-7 h-7 flex items-center justify-center" v-if="selected">
 | 
					                  <FormItem>
 | 
				
			||||||
                        {{ selected?.emoji }}
 | 
					                    <FormLabel
 | 
				
			||||||
                      </div>
 | 
					                      >{{ $t('form.field.assignAgent') }} ({{
 | 
				
			||||||
                      <span class="text-sm">{{
 | 
					                        $t('globals.terms.optional').toLowerCase()
 | 
				
			||||||
                        selected?.label || t('form.field.selectTeam')
 | 
					                      }})</FormLabel
 | 
				
			||||||
                      }}</span>
 | 
					                    >
 | 
				
			||||||
                    </div>
 | 
					                    <FormControl>
 | 
				
			||||||
                  </template>
 | 
					                      <SelectComboBox
 | 
				
			||||||
                </ComboBox>
 | 
					                        v-bind="componentField"
 | 
				
			||||||
              </FormControl>
 | 
					                        :items="[{ value: 'none', label: 'None' }, ...uStore.options]"
 | 
				
			||||||
              <FormMessage />
 | 
					                        :placeholder="t('form.field.selectAgent')"
 | 
				
			||||||
            </FormItem>
 | 
					                        type="user"
 | 
				
			||||||
          </FormField>
 | 
					                      />
 | 
				
			||||||
 | 
					                    </FormControl>
 | 
				
			||||||
 | 
					                    <FormMessage />
 | 
				
			||||||
 | 
					                  </FormItem>
 | 
				
			||||||
 | 
					                </FormField>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <!-- Set assigned agent -->
 | 
					          <!-- Message Editor Section -->
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="agent_id">
 | 
					          <div class="flex-1 flex flex-col min-h-0 mt-4">
 | 
				
			||||||
            <FormItem>
 | 
					            <FormField v-slot="{ componentField }" name="content">
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.assignAgentOptional') }}</FormLabel>
 | 
					              <FormItem class="flex flex-col h-full">
 | 
				
			||||||
              <FormControl>
 | 
					                <FormLabel>{{ $t('form.field.message') }}</FormLabel>
 | 
				
			||||||
                <ComboBox
 | 
					                <FormControl class="flex-1 flex flex-col min-h-0">
 | 
				
			||||||
                  v-bind="componentField"
 | 
					                  <div class="flex flex-col h-full">
 | 
				
			||||||
                  :items="[{ value: 'none', label: 'None' }, ...uStore.options]"
 | 
					                    <Editor
 | 
				
			||||||
                  :placeholder="t('form.field.selectAgent')"
 | 
					                      v-model:htmlContent="componentField.modelValue"
 | 
				
			||||||
                >
 | 
					                      @update:htmlContent="(value) => componentField.onChange(value)"
 | 
				
			||||||
                  <template #item="{ item }">
 | 
					                      :contentToSet="contentToSet"
 | 
				
			||||||
                    <div class="flex items-center gap-3 py-2">
 | 
					                      :placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
 | 
				
			||||||
                      <Avatar class="w-8 h-8">
 | 
					                      :clearContent="clearEditorContent"
 | 
				
			||||||
                        <AvatarImage
 | 
					                      :insertContent="insertContent"
 | 
				
			||||||
                          :src="item.value === 'none' ? '' : item.avatar_url || ''"
 | 
					                      :autoFocus="false"
 | 
				
			||||||
                          :alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
 | 
					                      class="w-full flex-1 overflow-y-auto p-2 box min-h-0"
 | 
				
			||||||
                        />
 | 
					                      @send="createConversation"
 | 
				
			||||||
                        <AvatarFallback>
 | 
					                    />
 | 
				
			||||||
                          {{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
 | 
					 | 
				
			||||||
                        </AvatarFallback>
 | 
					 | 
				
			||||||
                      </Avatar>
 | 
					 | 
				
			||||||
                      <span class="text-sm">{{ item.label }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  <template #selected="{ selected }">
 | 
					                    <!-- Macro preview -->
 | 
				
			||||||
                    <div class="flex items-center gap-3">
 | 
					                    <MacroActionsPreview
 | 
				
			||||||
                      <Avatar class="w-7 h-7" v-if="selected">
 | 
					                      v-if="conversationStore.getMacro('new-conversation').actions?.length > 0"
 | 
				
			||||||
                        <AvatarImage
 | 
					                      :actions="conversationStore.getMacro('new-conversation')?.actions || []"
 | 
				
			||||||
                          :src="
 | 
					                      :onRemove="
 | 
				
			||||||
                            selected?.value === 'none'
 | 
					                        (action) => conversationStore.removeMacroAction(action, 'new-conversation')
 | 
				
			||||||
                              ? ''
 | 
					                      "
 | 
				
			||||||
                              : selected?.avatar_url || ''
 | 
					                      class="mt-2 flex-shrink-0"
 | 
				
			||||||
                          "
 | 
					                    />
 | 
				
			||||||
                          :alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
 | 
					 | 
				
			||||||
                        />
 | 
					 | 
				
			||||||
                        <AvatarFallback>
 | 
					 | 
				
			||||||
                          {{
 | 
					 | 
				
			||||||
                            selected?.value === 'none'
 | 
					 | 
				
			||||||
                              ? 'N'
 | 
					 | 
				
			||||||
                              : selected?.label?.slice(0, 2)?.toUpperCase()
 | 
					 | 
				
			||||||
                          }}
 | 
					 | 
				
			||||||
                        </AvatarFallback>
 | 
					 | 
				
			||||||
                      </Avatar>
 | 
					 | 
				
			||||||
                      <span class="text-sm">{{
 | 
					 | 
				
			||||||
                        selected?.label || t('form.field.selectAgent')
 | 
					 | 
				
			||||||
                      }}</span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </template>
 | 
					 | 
				
			||||||
                </ComboBox>
 | 
					 | 
				
			||||||
              </FormControl>
 | 
					 | 
				
			||||||
              <FormMessage />
 | 
					 | 
				
			||||||
            </FormItem>
 | 
					 | 
				
			||||||
          </FormField>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField
 | 
					                    <!-- Attachments preview -->
 | 
				
			||||||
            v-slot="{ componentField }"
 | 
					                    <AttachmentsPreview
 | 
				
			||||||
            name="content"
 | 
					                      :attachments="mediaFiles"
 | 
				
			||||||
            class="flex-1 min-h-0 flex flex-col"
 | 
					                      :uploadingFiles="uploadingFiles"
 | 
				
			||||||
          >
 | 
					                      :onDelete="handleFileDelete"
 | 
				
			||||||
            <FormItem class="flex flex-col flex-1">
 | 
					                      v-if="mediaFiles.length > 0 || uploadingFiles.length > 0"
 | 
				
			||||||
              <FormLabel>{{ $t('form.field.message') }}</FormLabel>
 | 
					                      class="mt-2 flex-shrink-0"
 | 
				
			||||||
              <FormControl class="flex-1 min-h-0 flex flex-col">
 | 
					                    />
 | 
				
			||||||
                <div class="flex-1 min-h-0 flex flex-col">
 | 
					                  </div>
 | 
				
			||||||
                  <Editor
 | 
					                </FormControl>
 | 
				
			||||||
                    v-model:htmlContent="componentField.modelValue"
 | 
					                <FormMessage />
 | 
				
			||||||
                    @update:htmlContent="(value) => componentField.onChange(value)"
 | 
					              </FormItem>
 | 
				
			||||||
                    :placeholder="t('editor.placeholder')"
 | 
					            </FormField>
 | 
				
			||||||
                    class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
 | 
					          </div>
 | 
				
			||||||
                  />
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </FormControl>
 | 
					 | 
				
			||||||
              <FormMessage />
 | 
					 | 
				
			||||||
            </FormItem>
 | 
					 | 
				
			||||||
          </FormField>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <DialogFooter class="mt-4 pt-2 border-t shrink-0">
 | 
					          <DialogFooter class="mt-4 pt-2 flex items-center !justify-between w-full flex-shrink-0">
 | 
				
			||||||
          <Button type="submit" :disabled="loading" :isLoading="loading">
 | 
					            <ReplyBoxMenuBar
 | 
				
			||||||
            {{ $t('globals.buttons.submit') }}
 | 
					              :handleFileUpload="handleFileUpload"
 | 
				
			||||||
          </Button>
 | 
					              @emojiSelect="handleEmojiSelect"
 | 
				
			||||||
        </DialogFooter>
 | 
					            />
 | 
				
			||||||
      </form>
 | 
					            <Button type="submit" :disabled="isDisabled" :isLoading="loading">
 | 
				
			||||||
    </DialogContent>
 | 
					              {{ $t('globals.buttons.submit') }}
 | 
				
			||||||
  </Dialog>
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </DialogFooter>
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					      </DialogContent>
 | 
				
			||||||
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -239,12 +224,13 @@ import { useForm } from 'vee-validate'
 | 
				
			|||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
				
			||||||
import { z } from 'zod'
 | 
					import { z } from 'zod'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { ref, watch, onUnmounted, nextTick, onMounted, computed } from 'vue'
 | 
				
			||||||
import { ref, watch } from 'vue'
 | 
					import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
 | 
				
			||||||
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
 | 
					import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
 | 
				
			||||||
 | 
					import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					 | 
				
			||||||
import { Users } from 'lucide-vue-next'
 | 
					 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { useInboxStore } from '@/stores/inbox'
 | 
					import { useInboxStore } from '@/stores/inbox'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
@@ -258,7 +244,10 @@ import {
 | 
				
			|||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
					import { useFileUpload } from '@/composables/useFileUpload'
 | 
				
			||||||
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
 | 
					import { useMacroStore } from '@/stores/macro'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const dialogOpen = defineModel({
 | 
					const dialogOpen = defineModel({
 | 
				
			||||||
@@ -274,13 +263,37 @@ const emitter = useEmitter()
 | 
				
			|||||||
const loading = ref(false)
 | 
					const loading = ref(false)
 | 
				
			||||||
const searchResults = ref([])
 | 
					const searchResults = ref([])
 | 
				
			||||||
const emailQuery = ref('')
 | 
					const emailQuery = ref('')
 | 
				
			||||||
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
 | 
					const macroStore = useMacroStore()
 | 
				
			||||||
let timeoutId = null
 | 
					let timeoutId = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const contentToSet = ref('')
 | 
				
			||||||
 | 
					const clearEditorContent = ref(false)
 | 
				
			||||||
 | 
					const insertContent = ref('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleEmojiSelect = (emoji) => {
 | 
				
			||||||
 | 
					  insertContent.value = undefined
 | 
				
			||||||
 | 
					  // Force reactivity so the user can select the same emoji multiple times
 | 
				
			||||||
 | 
					  nextTick(() => (insertContent.value = emoji))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMediaFiles } =
 | 
				
			||||||
 | 
					  useFileUpload({
 | 
				
			||||||
 | 
					    linkedModel: 'messages'
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isDisabled = computed(() => {
 | 
				
			||||||
 | 
					  if (loading.value || uploadingFiles.value.length > 0) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return false
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const formSchema = z.object({
 | 
					const formSchema = z.object({
 | 
				
			||||||
  subject: z.string().min(
 | 
					  subject: z.string().min(
 | 
				
			||||||
    3,
 | 
					    1,
 | 
				
			||||||
    t('form.error.min', {
 | 
					    t('globals.messages.cannotBeEmpty', {
 | 
				
			||||||
      min: 3
 | 
					      name: t('globals.terms.subject')
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
  content: z.string().min(
 | 
					  content: z.string().min(
 | 
				
			||||||
@@ -296,7 +309,25 @@ const formSchema = z.object({
 | 
				
			|||||||
  agent_id: z.any().optional(),
 | 
					  agent_id: z.any().optional(),
 | 
				
			||||||
  contact_email: z.string().email(t('globals.messages.invalidEmailAddress')),
 | 
					  contact_email: z.string().email(t('globals.messages.invalidEmailAddress')),
 | 
				
			||||||
  first_name: z.string().min(1, t('globals.messages.required')),
 | 
					  first_name: z.string().min(1, t('globals.messages.required')),
 | 
				
			||||||
  last_name: z.string().min(1, t('globals.messages.required'))
 | 
					  last_name: z.string().optional()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  clearTimeout(timeoutId)
 | 
				
			||||||
 | 
					  clearMediaFiles()
 | 
				
			||||||
 | 
					  conversationStore.resetMacro('new-conversation')
 | 
				
			||||||
 | 
					  emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
 | 
				
			||||||
 | 
					    command: null,
 | 
				
			||||||
 | 
					    open: false
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  macroStore.setCurrentView('starting_conversation')
 | 
				
			||||||
 | 
					  emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
 | 
				
			||||||
 | 
					    command: 'apply-macro-to-new-conversation',
 | 
				
			||||||
 | 
					    open: false
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const form = useForm({
 | 
					const form = useForm({
 | 
				
			||||||
@@ -350,10 +381,29 @@ const selectContact = (contact) => {
 | 
				
			|||||||
const createConversation = form.handleSubmit(async (values) => {
 | 
					const createConversation = form.handleSubmit(async (values) => {
 | 
				
			||||||
  loading.value = true
 | 
					  loading.value = true
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    await api.createConversation(values)
 | 
					    // convert ids to numbers if they are not already
 | 
				
			||||||
 | 
					    values.inbox_id = Number(values.inbox_id)
 | 
				
			||||||
 | 
					    values.team_id = values.team_id ? Number(values.team_id) : null
 | 
				
			||||||
 | 
					    values.agent_id = values.agent_id ? Number(values.agent_id) : null
 | 
				
			||||||
 | 
					    // array of attachment ids.
 | 
				
			||||||
 | 
					    values.attachments = mediaFiles.value.map((file) => file.id)
 | 
				
			||||||
 | 
					    const conversation = await api.createConversation(values)
 | 
				
			||||||
 | 
					    const conversationUUID = conversation.data.data.uuid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Get macro from context, and set if any actions are available.
 | 
				
			||||||
 | 
					    const macro = conversationStore.getMacro('new-conversation')
 | 
				
			||||||
 | 
					    if (conversationUUID !== '' && macro?.id && macro?.actions?.length > 0) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await api.applyMacro(conversationUUID, macro.id, macro.actions)
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					          variant: 'destructive',
 | 
				
			||||||
 | 
					          description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    dialogOpen.value = false
 | 
					    dialogOpen.value = false
 | 
				
			||||||
    form.resetForm()
 | 
					    form.resetForm()
 | 
				
			||||||
    emailQuery.value = ''
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
@@ -363,4 +413,19 @@ const createConversation = form.handleSubmit(async (values) => {
 | 
				
			|||||||
    loading.value = false
 | 
					    loading.value = false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Watches for changes in the macro id and update message content.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					watch(
 | 
				
			||||||
 | 
					  () => conversationStore.getMacro('new-conversation').id,
 | 
				
			||||||
 | 
					  () => {
 | 
				
			||||||
 | 
					    // Setting timestamp, so the same macro can be set again.
 | 
				
			||||||
 | 
					    contentToSet.value = JSON.stringify({
 | 
				
			||||||
 | 
					      content: conversationStore.getMacro('new-conversation').message_content,
 | 
				
			||||||
 | 
					      timestamp: Date.now()
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { deep: true }
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,19 +4,18 @@
 | 
				
			|||||||
      <div
 | 
					      <div
 | 
				
			||||||
        v-for="action in actions"
 | 
					        v-for="action in actions"
 | 
				
			||||||
        :key="action.type"
 | 
					        :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 gap-2 py-1"
 | 
					        class="flex items-center border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex items-center space-x-2 px-2">
 | 
					        <div class="flex items-center space-x-2 px-2">
 | 
				
			||||||
          <component
 | 
					          <component
 | 
				
			||||||
            :is="getIcon(action.type)"
 | 
					            :is="getIcon(action.type)"
 | 
				
			||||||
            size="16"
 | 
					            size="16"
 | 
				
			||||||
            class="text-gray-500 text-primary group-hover:text-primary"
 | 
					            class="text-gray-500 text-primary"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <Tooltip>
 | 
					          <Tooltip>
 | 
				
			||||||
            <TooltipTrigger as-child>
 | 
					            <TooltipTrigger as-child>
 | 
				
			||||||
              <div
 | 
					              <div
 | 
				
			||||||
                class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
 | 
					                class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900 dark:group-hover:text-gray-100">
 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {{ getDisplayValue(action) }}
 | 
					                {{ getDisplayValue(action) }}
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </TooltipTrigger>
 | 
					            </TooltipTrigger>
 | 
				
			||||||
@@ -26,8 +25,8 @@
 | 
				
			|||||||
          </Tooltip>
 | 
					          </Tooltip>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          @click.stop="onRemove(action)"
 | 
					          @click.prevent="onRemove(action)"
 | 
				
			||||||
          class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
 | 
					          class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
 | 
				
			||||||
          title="Remove action"
 | 
					          title="Remove action"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <X size="14" />
 | 
					          <X size="14" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -42,7 +42,7 @@
 | 
				
			|||||||
    <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
 | 
					    <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
 | 
				
			||||||
      <DialogContent
 | 
					      <DialogContent
 | 
				
			||||||
        class="max-w-[60%] max-h-[75%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
 | 
					        class="max-w-[60%] max-h-[75%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
 | 
				
			||||||
        :class="{ '!bg-[#FEF1E1]': messageType === 'private_note' }"
 | 
					        :class="{ '!bg-[#FEF1E1] dark:!bg-[#4C3A24]': messageType === 'private_note' }"
 | 
				
			||||||
        @escapeKeyDown="isEditorFullscreen = false"
 | 
					        @escapeKeyDown="isEditorFullscreen = false"
 | 
				
			||||||
        :hide-close-button="true"
 | 
					        :hide-close-button="true"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
@@ -66,11 +66,10 @@
 | 
				
			|||||||
          v-model:emailErrors="emailErrors"
 | 
					          v-model:emailErrors="emailErrors"
 | 
				
			||||||
          v-model:messageType="messageType"
 | 
					          v-model:messageType="messageType"
 | 
				
			||||||
          v-model:showBcc="showBcc"
 | 
					          v-model:showBcc="showBcc"
 | 
				
			||||||
          @toggleFullscreen="isEditorFullscreen = true"
 | 
					          @toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
 | 
				
			||||||
          @send="processSend"
 | 
					          @send="processSend"
 | 
				
			||||||
          @fileUpload="handleFileUpload"
 | 
					          @fileUpload="handleFileUpload"
 | 
				
			||||||
          @inlineImageUpload="handleInlineImageUpload"
 | 
					          @fileDelete="handleFileDelete"
 | 
				
			||||||
          @fileDelete="handleOnFileDelete"
 | 
					 | 
				
			||||||
          @aiPromptSelected="handleAiPromptSelected"
 | 
					          @aiPromptSelected="handleAiPromptSelected"
 | 
				
			||||||
          class="h-full flex-grow"
 | 
					          class="h-full flex-grow"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
@@ -79,8 +78,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <!-- Main Editor non-fullscreen -->
 | 
					    <!-- Main Editor non-fullscreen -->
 | 
				
			||||||
    <div
 | 
					    <div
 | 
				
			||||||
      class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
 | 
					      class="bg-background text-card-foreground box m-2 px-2 pt-2 flex flex-col"
 | 
				
			||||||
      :class="{ '!bg-[#FEF1E1]': messageType === 'private_note' }"
 | 
					      :class="{ '!bg-[#FEF1E1] dark:!bg-[#4C3A24]': messageType === 'private_note' }"
 | 
				
			||||||
      v-if="!isEditorFullscreen"
 | 
					      v-if="!isEditorFullscreen"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <ReplyBoxContent
 | 
					      <ReplyBoxContent
 | 
				
			||||||
@@ -90,6 +89,7 @@
 | 
				
			|||||||
        :uploadingFiles="uploadingFiles"
 | 
					        :uploadingFiles="uploadingFiles"
 | 
				
			||||||
        :clearEditorContent="clearEditorContent"
 | 
					        :clearEditorContent="clearEditorContent"
 | 
				
			||||||
        :contentToSet="contentToSet"
 | 
					        :contentToSet="contentToSet"
 | 
				
			||||||
 | 
					        :uploadedFiles="mediaFiles"
 | 
				
			||||||
        v-model:htmlContent="htmlContent"
 | 
					        v-model:htmlContent="htmlContent"
 | 
				
			||||||
        v-model:textContent="textContent"
 | 
					        v-model:textContent="textContent"
 | 
				
			||||||
        v-model:selectedText="selectedText"
 | 
					        v-model:selectedText="selectedText"
 | 
				
			||||||
@@ -102,11 +102,10 @@
 | 
				
			|||||||
        v-model:emailErrors="emailErrors"
 | 
					        v-model:emailErrors="emailErrors"
 | 
				
			||||||
        v-model:messageType="messageType"
 | 
					        v-model:messageType="messageType"
 | 
				
			||||||
        v-model:showBcc="showBcc"
 | 
					        v-model:showBcc="showBcc"
 | 
				
			||||||
        @toggleFullscreen="isEditorFullscreen = true"
 | 
					        @toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
 | 
				
			||||||
        @send="processSend"
 | 
					        @send="processSend"
 | 
				
			||||||
        @fileUpload="handleFileUpload"
 | 
					        @fileUpload="handleFileUpload"
 | 
				
			||||||
        @inlineImageUpload="handleInlineImageUpload"
 | 
					        @fileDelete="handleFileDelete"
 | 
				
			||||||
        @fileDelete="handleOnFileDelete"
 | 
					 | 
				
			||||||
        @aiPromptSelected="handleAiPromptSelected"
 | 
					        @aiPromptSelected="handleAiPromptSelected"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -115,7 +114,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
 | 
					import { ref, onMounted, nextTick, watch, computed } from 'vue'
 | 
				
			||||||
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 { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
@@ -133,6 +131,7 @@ import {
 | 
				
			|||||||
} from '@/components/ui/dialog'
 | 
					} from '@/components/ui/dialog'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { useFileUpload } from '@/composables/useFileUpload'
 | 
				
			||||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
 | 
					import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Form,
 | 
					  Form,
 | 
				
			||||||
@@ -155,10 +154,21 @@ const { t } = useI18n()
 | 
				
			|||||||
const conversationStore = useConversationStore()
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Setup file upload composable
 | 
				
			||||||
 | 
					const {
 | 
				
			||||||
 | 
					  uploadingFiles,
 | 
				
			||||||
 | 
					  handleFileUpload,
 | 
				
			||||||
 | 
					  handleFileDelete,
 | 
				
			||||||
 | 
					  mediaFiles,
 | 
				
			||||||
 | 
					  clearMediaFiles,
 | 
				
			||||||
 | 
					} = useFileUpload({
 | 
				
			||||||
 | 
					  linkedModel: 'messages'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Rest of existing state
 | 
				
			||||||
const openAIKeyPrompt = ref(false)
 | 
					const openAIKeyPrompt = ref(false)
 | 
				
			||||||
const isOpenAIKeyUpdating = ref(false)
 | 
					const isOpenAIKeyUpdating = ref(false)
 | 
				
			||||||
 | 
					 | 
				
			||||||
// Shared state between the two editor components.
 | 
					 | 
				
			||||||
const clearEditorContent = ref(false)
 | 
					const clearEditorContent = ref(false)
 | 
				
			||||||
const isEditorFullscreen = ref(false)
 | 
					const isEditorFullscreen = ref(false)
 | 
				
			||||||
const isSending = ref(false)
 | 
					const isSending = ref(false)
 | 
				
			||||||
@@ -169,7 +179,6 @@ const bcc = ref('')
 | 
				
			|||||||
const showBcc = ref(false)
 | 
					const showBcc = ref(false)
 | 
				
			||||||
const emailErrors = ref([])
 | 
					const emailErrors = ref([])
 | 
				
			||||||
const aiPrompts = ref([])
 | 
					const aiPrompts = ref([])
 | 
				
			||||||
const uploadingFiles = ref([])
 | 
					 | 
				
			||||||
const htmlContent = ref('')
 | 
					const htmlContent = ref('')
 | 
				
			||||||
const textContent = ref('')
 | 
					const textContent = ref('')
 | 
				
			||||||
const selectedText = ref('')
 | 
					const selectedText = ref('')
 | 
				
			||||||
@@ -249,63 +258,6 @@ const updateProvider = async (values) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Handles the file upload process when files are selected.
 | 
					 | 
				
			||||||
 * Uploads each file to the server and adds them to the conversation's mediaFiles.
 | 
					 | 
				
			||||||
 * @param {Event} event - The file input change event containing selected files
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
const handleFileUpload = (event) => {
 | 
					 | 
				
			||||||
  const files = Array.from(event.target.files)
 | 
					 | 
				
			||||||
  uploadingFiles.value = files
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const file of files) {
 | 
					 | 
				
			||||||
    api
 | 
					 | 
				
			||||||
      .uploadMedia({
 | 
					 | 
				
			||||||
        files: file,
 | 
					 | 
				
			||||||
        inline: false,
 | 
					 | 
				
			||||||
        linked_model: 'messages'
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .then((resp) => {
 | 
					 | 
				
			||||||
        conversationStore.conversation.mediaFiles.push(resp.data.data)
 | 
					 | 
				
			||||||
        uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch((error) => {
 | 
					 | 
				
			||||||
        uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
 | 
					 | 
				
			||||||
        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					 | 
				
			||||||
          variant: 'destructive',
 | 
					 | 
				
			||||||
          description: handleHTTPError(error).message
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Inline image upload is not supported yet.
 | 
					 | 
				
			||||||
const handleInlineImageUpload = (event) => {
 | 
					 | 
				
			||||||
  for (const file of event.target.files) {
 | 
					 | 
				
			||||||
    api
 | 
					 | 
				
			||||||
      .uploadMedia({
 | 
					 | 
				
			||||||
        files: file,
 | 
					 | 
				
			||||||
        inline: true,
 | 
					 | 
				
			||||||
        linked_model: 'messages'
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .then((resp) => {
 | 
					 | 
				
			||||||
        const imageData = {
 | 
					 | 
				
			||||||
          src: resp.data.data.url,
 | 
					 | 
				
			||||||
          alt: resp.data.data.filename,
 | 
					 | 
				
			||||||
          title: resp.data.data.uuid
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        conversationStore.conversation.mediaFiles.push(resp.data.data)
 | 
					 | 
				
			||||||
        return imageData
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch((error) => {
 | 
					 | 
				
			||||||
        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					 | 
				
			||||||
          variant: 'destructive',
 | 
					 | 
				
			||||||
          description: handleHTTPError(error).message
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Returns true if the editor has text content.
 | 
					 * Returns true if the editor has text content.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
@@ -317,36 +269,17 @@ const hasTextContent = computed(() => {
 | 
				
			|||||||
 * Processes the send action.
 | 
					 * Processes the send action.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
const processSend = async () => {
 | 
					const processSend = async () => {
 | 
				
			||||||
  let hasAPIErrored = false
 | 
					  let hasMessageSendingErrored = false
 | 
				
			||||||
  isEditorFullscreen.value = false
 | 
					  isEditorFullscreen.value = false
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    isSending.value = true
 | 
					    isSending.value = true
 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Send message if there is text content in the editor.
 | 
					    // Send message if there is text content in the editor.
 | 
				
			||||||
    if (hasTextContent.value > 0) {
 | 
					    if (hasTextContent.value > 0) {
 | 
				
			||||||
      // Replace inline image url with cid.
 | 
					      const message = htmlContent.value
 | 
				
			||||||
      const message = transformImageSrcToCID(htmlContent.value)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Check which images are still in editor before sending.
 | 
					 | 
				
			||||||
      const parser = new DOMParser()
 | 
					 | 
				
			||||||
      const doc = parser.parseFromString(htmlContent.value, 'text/html')
 | 
					 | 
				
			||||||
      const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
 | 
					 | 
				
			||||||
        .map((img) => img.getAttribute('title'))
 | 
					 | 
				
			||||||
        .filter(Boolean)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // TODO: Inline images are not supported yet, this is some old boilerplate code.
 | 
					 | 
				
			||||||
      conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
 | 
					 | 
				
			||||||
        (file) =>
 | 
					 | 
				
			||||||
          // Keep if:
 | 
					 | 
				
			||||||
          // 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, {
 | 
					      await api.sendMessage(conversationStore.current.uuid, {
 | 
				
			||||||
        private: messageType.value === 'private_note',
 | 
					        private: messageType.value === 'private_note',
 | 
				
			||||||
        message: message,
 | 
					        message: message,
 | 
				
			||||||
        attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
 | 
					        attachments: mediaFiles.value.map((file) => file.id),
 | 
				
			||||||
        // Convert email addresses to array and remove empty strings.
 | 
					        // Convert email addresses to array and remove empty strings.
 | 
				
			||||||
        cc: cc.value
 | 
					        cc: cc.value
 | 
				
			||||||
          .split(',')
 | 
					          .split(',')
 | 
				
			||||||
@@ -367,15 +300,12 @@ const processSend = async () => {
 | 
				
			|||||||
      })
 | 
					      })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Apply macro actions if any.
 | 
					    // Apply macro actions if any, for macro errors just show toast and clear the editor.
 | 
				
			||||||
    // For macro errors just show toast and clear the editor.
 | 
					    const macroID = conversationStore.getMacro('reply')?.id
 | 
				
			||||||
    if (conversationStore.conversation?.macro?.actions?.length > 0) {
 | 
					    const macroActions = conversationStore.getMacro('reply')?.actions || []
 | 
				
			||||||
 | 
					    if (macroID > 0 && macroActions.length > 0) {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        await api.applyMacro(
 | 
					        await api.applyMacro(conversationStore.current.uuid, macroID, macroActions)
 | 
				
			||||||
          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, {
 | 
				
			||||||
          variant: 'destructive',
 | 
					          variant: 'destructive',
 | 
				
			||||||
@@ -384,22 +314,22 @@ const processSend = async () => {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    hasAPIErrored = true
 | 
					    hasMessageSendingErrored = true
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
    // If API has NOT errored clear state.
 | 
					    // If API has NOT errored clear state.
 | 
				
			||||||
    if (hasAPIErrored === false) {
 | 
					    if (hasMessageSendingErrored === false) {
 | 
				
			||||||
      // Clear editor.
 | 
					      // Clear editor.
 | 
				
			||||||
      clearEditorContent.value = true
 | 
					      clearEditorContent.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Clear macro.
 | 
					      // Clear macro.
 | 
				
			||||||
      conversationStore.resetMacro()
 | 
					      conversationStore.resetMacro('reply')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Clear media files.
 | 
					      // Clear media files.
 | 
				
			||||||
      conversationStore.resetMediaFiles()
 | 
					      clearMediaFiles()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Clear any email errors.
 | 
					      // Clear any email errors.
 | 
				
			||||||
      emailErrors.value = []
 | 
					      emailErrors.value = []
 | 
				
			||||||
@@ -410,30 +340,17 @@ const processSend = async () => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    isSending.value = false
 | 
					    isSending.value = false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  // Update assignee last seen timestamp.
 | 
					 | 
				
			||||||
  api.updateAssigneeLastSeen(conversationStore.current.uuid)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Handles the file delete event.
 | 
					 | 
				
			||||||
 * Removes the file from the conversation's mediaFiles.
 | 
					 | 
				
			||||||
 * @param {String} uuid - The UUID of the file to delete
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
const handleOnFileDelete = (uuid) => {
 | 
					 | 
				
			||||||
  conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
 | 
					 | 
				
			||||||
    (item) => item.uuid !== uuid
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Watches for changes in the conversation's macro id and update message content.
 | 
					 * Watches for changes in the conversation's macro id and update message content.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => conversationStore.conversation.macro.id,
 | 
					  () => conversationStore.getMacro('reply').id,
 | 
				
			||||||
  () => {
 | 
					  () => {
 | 
				
			||||||
    // Setting timestamp, so the same macro can be set again.
 | 
					    // Setting timestamp, so the same macro can be set again.
 | 
				
			||||||
    contentToSet.value = JSON.stringify({
 | 
					    contentToSet.value = JSON.stringify({
 | 
				
			||||||
      content: conversationStore.conversation.macro.message_content,
 | 
					      content: conversationStore.getMacro('reply').message_content,
 | 
				
			||||||
      timestamp: Date.now()
 | 
					      timestamp: Date.now()
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,35 +6,27 @@
 | 
				
			|||||||
      class="flex justify-between items-center"
 | 
					      class="flex justify-between items-center"
 | 
				
			||||||
      :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
 | 
					      :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <Tabs v-model="messageType" class="rounded-lg border">
 | 
					      <Tabs v-model="messageType" class="rounded border">
 | 
				
			||||||
        <TabsList class="bg-muted p-1 rounded-lg">
 | 
					        <TabsList class="bg-muted p-1 rounded">
 | 
				
			||||||
          <TabsTrigger
 | 
					          <TabsTrigger
 | 
				
			||||||
            value="reply"
 | 
					            value="reply"
 | 
				
			||||||
            class="px-3 py-1 rounded-lg transition-colors duration-200"
 | 
					            class="px-3 py-1 rounded transition-colors duration-200"
 | 
				
			||||||
            :class="{ 'bg-background text-foreground': messageType === 'reply' }"
 | 
					            :class="{ 'bg-background text-foreground': messageType === 'reply' }"
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {{ $t('replyBox.reply') }}
 | 
					            {{ $t('replyBox.reply') }}
 | 
				
			||||||
          </TabsTrigger>
 | 
					          </TabsTrigger>
 | 
				
			||||||
          <TabsTrigger
 | 
					          <TabsTrigger
 | 
				
			||||||
            value="private_note"
 | 
					            value="private_note"
 | 
				
			||||||
            class="px-3 py-1 rounded-lg transition-colors duration-200"
 | 
					            class="px-3 py-1 rounded transition-colors duration-200"
 | 
				
			||||||
            :class="{ 'bg-background text-foreground': messageType === 'private_note' }"
 | 
					            :class="{ 'bg-background text-foreground': messageType === 'private_note' }"
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {{ $t('replyBox.privateNote') }}
 | 
					            {{ $t('globals.terms.privateNote') }}
 | 
				
			||||||
          </TabsTrigger>
 | 
					          </TabsTrigger>
 | 
				
			||||||
        </TabsList>
 | 
					        </TabsList>
 | 
				
			||||||
      </Tabs>
 | 
					      </Tabs>
 | 
				
			||||||
      <span
 | 
					      <Button class="text-muted-foreground" variant="ghost" @click="toggleFullscreen">
 | 
				
			||||||
        class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
 | 
					        <component :is="isFullscreen ? Minimize2 : Maximize2" />
 | 
				
			||||||
        variant="ghost"
 | 
					      </Button>
 | 
				
			||||||
        @click="toggleFullscreen"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <component
 | 
					 | 
				
			||||||
          :is="isFullscreen ? Minimize2 : Maximize2"
 | 
					 | 
				
			||||||
          :size="isFullscreen ? '18' : '15'"
 | 
					 | 
				
			||||||
          :class="{ 'mr-2': !isFullscreen, 'mr-1 mb-2': isFullscreen }"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </span>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- To, CC, and BCC fields -->
 | 
					    <!-- To, CC, and BCC fields -->
 | 
				
			||||||
@@ -43,13 +35,13 @@
 | 
				
			|||||||
      v-if="messageType === 'reply'"
 | 
					      v-if="messageType === 'reply'"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div class="flex items-center space-x-2">
 | 
					      <div class="flex items-center space-x-2">
 | 
				
			||||||
        <label class="w-12 text-sm font-medium text-muted-foreground">To:</label>
 | 
					        <label class="w-12 text-sm font-medium text-muted-foreground">TO:</label>
 | 
				
			||||||
        <Input
 | 
					        <Input
 | 
				
			||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          :placeholder="t('replyBox.emailAddresess')"
 | 
					          :placeholder="t('replyBox.emailAddresess')"
 | 
				
			||||||
          v-model="to"
 | 
					          v-model="to"
 | 
				
			||||||
          class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
 | 
					          class="flex-grow px-3 py-2 text-sm border rounded focus:ring-2 focus:ring-ring"
 | 
				
			||||||
          @blur="validateEmails('to')"
 | 
					          @blur="validateEmails"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="flex items-center space-x-2">
 | 
					      <div class="flex items-center space-x-2">
 | 
				
			||||||
@@ -58,8 +50,8 @@
 | 
				
			|||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          :placeholder="t('replyBox.emailAddresess')"
 | 
					          :placeholder="t('replyBox.emailAddresess')"
 | 
				
			||||||
          v-model="cc"
 | 
					          v-model="cc"
 | 
				
			||||||
          class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
 | 
					          class="flex-grow px-3 py-2 text-sm border rounded focus:ring-2 focus:ring-ring"
 | 
				
			||||||
          @blur="validateEmails('cc')"
 | 
					          @blur="validateEmails"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
@@ -75,8 +67,8 @@
 | 
				
			|||||||
          type="text"
 | 
					          type="text"
 | 
				
			||||||
          :placeholder="t('replyBox.emailAddresess')"
 | 
					          :placeholder="t('replyBox.emailAddresess')"
 | 
				
			||||||
          v-model="bcc"
 | 
					          v-model="bcc"
 | 
				
			||||||
          class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
 | 
					          class="flex-grow px-3 py-2 text-sm border rounded focus:ring-2 focus:ring-ring"
 | 
				
			||||||
          @blur="validateEmails('bcc')"
 | 
					          @blur="validateEmails"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -98,7 +90,7 @@
 | 
				
			|||||||
        v-model:htmlContent="htmlContent"
 | 
					        v-model:htmlContent="htmlContent"
 | 
				
			||||||
        v-model:textContent="textContent"
 | 
					        v-model:textContent="textContent"
 | 
				
			||||||
        v-model:cursorPosition="cursorPosition"
 | 
					        v-model:cursorPosition="cursorPosition"
 | 
				
			||||||
        :placeholder="editorPlaceholder"
 | 
					        :placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
 | 
				
			||||||
        :aiPrompts="aiPrompts"
 | 
					        :aiPrompts="aiPrompts"
 | 
				
			||||||
        @aiPromptSelected="handleAiPromptSelected"
 | 
					        @aiPromptSelected="handleAiPromptSelected"
 | 
				
			||||||
        :contentToSet="contentToSet"
 | 
					        :contentToSet="contentToSet"
 | 
				
			||||||
@@ -106,23 +98,24 @@
 | 
				
			|||||||
        :clearContent="clearEditorContent"
 | 
					        :clearContent="clearEditorContent"
 | 
				
			||||||
        :setInlineImage="setInlineImage"
 | 
					        :setInlineImage="setInlineImage"
 | 
				
			||||||
        :insertContent="insertContent"
 | 
					        :insertContent="insertContent"
 | 
				
			||||||
 | 
					        :autoFocus="true"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Macro preview -->
 | 
					    <!-- Macro preview -->
 | 
				
			||||||
    <MacroActionsPreview
 | 
					    <MacroActionsPreview
 | 
				
			||||||
      v-if="conversationStore.conversation?.macro?.actions?.length > 0"
 | 
					      v-if="conversationStore.getMacro('reply')?.actions?.length > 0"
 | 
				
			||||||
      :actions="conversationStore.conversation.macro.actions"
 | 
					      :actions="conversationStore.getMacro('reply').actions"
 | 
				
			||||||
      :onRemove="conversationStore.removeMacroAction"
 | 
					      :onRemove="(action) => conversationStore.removeMacroAction(action, 'reply')"
 | 
				
			||||||
      class="mt-2"
 | 
					      class="mt-2"
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Attachments preview -->
 | 
					    <!-- Attachments preview -->
 | 
				
			||||||
    <AttachmentsPreview
 | 
					    <AttachmentsPreview
 | 
				
			||||||
      :attachments="attachments"
 | 
					      :attachments="uploadedFiles"
 | 
				
			||||||
      :uploadingFiles="uploadingFiles"
 | 
					      :uploadingFiles="uploadingFiles"
 | 
				
			||||||
      :onDelete="handleOnFileDelete"
 | 
					      :onDelete="handleOnFileDelete"
 | 
				
			||||||
      v-if="attachments.length > 0 || uploadingFiles.length > 0"
 | 
					      v-if="uploadedFiles.length > 0 || uploadingFiles.length > 0"
 | 
				
			||||||
      class="mt-2"
 | 
					      class="mt-2"
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -131,7 +124,6 @@
 | 
				
			|||||||
      class="mt-1 shrink-0"
 | 
					      class="mt-1 shrink-0"
 | 
				
			||||||
      :isFullscreen="isFullscreen"
 | 
					      :isFullscreen="isFullscreen"
 | 
				
			||||||
      :handleFileUpload="handleFileUpload"
 | 
					      :handleFileUpload="handleFileUpload"
 | 
				
			||||||
      :handleInlineImageUpload="handleInlineImageUpload"
 | 
					 | 
				
			||||||
      :isBold="isBold"
 | 
					      :isBold="isBold"
 | 
				
			||||||
      :isItalic="isItalic"
 | 
					      :isItalic="isItalic"
 | 
				
			||||||
      :isSending="isSending"
 | 
					      :isSending="isSending"
 | 
				
			||||||
@@ -139,16 +131,17 @@
 | 
				
			|||||||
      @toggleItalic="toggleItalic"
 | 
					      @toggleItalic="toggleItalic"
 | 
				
			||||||
      :enableSend="enableSend"
 | 
					      :enableSend="enableSend"
 | 
				
			||||||
      :handleSend="handleSend"
 | 
					      :handleSend="handleSend"
 | 
				
			||||||
 | 
					      :showSendButton="true"
 | 
				
			||||||
      @emojiSelect="handleEmojiSelect"
 | 
					      @emojiSelect="handleEmojiSelect"
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, computed, nextTick } from 'vue'
 | 
					import { ref, computed, nextTick, watch } from 'vue'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
 | 
					import { Maximize2, Minimize2 } from 'lucide-vue-next'
 | 
				
			||||||
import Editor from './ConversationTextEditor.vue'
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
@@ -159,8 +152,8 @@ import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue
 | 
				
			|||||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
 | 
					import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { validateEmail } from '@/utils/strings'
 | 
					import { validateEmail } from '@/utils/strings'
 | 
				
			||||||
 | 
					import { useMacroStore } from '@/stores/macro'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Define models for two-way binding
 | 
					 | 
				
			||||||
const messageType = defineModel('messageType', { default: 'reply' })
 | 
					const messageType = defineModel('messageType', { default: 'reply' })
 | 
				
			||||||
const to = defineModel('to', { default: '' })
 | 
					const to = defineModel('to', { default: '' })
 | 
				
			||||||
const cc = defineModel('cc', { default: '' })
 | 
					const cc = defineModel('cc', { default: '' })
 | 
				
			||||||
@@ -173,6 +166,7 @@ const selectedText = defineModel('selectedText', { default: '' })
 | 
				
			|||||||
const isBold = defineModel('isBold', { default: false })
 | 
					const isBold = defineModel('isBold', { default: false })
 | 
				
			||||||
const isItalic = defineModel('isItalic', { default: false })
 | 
					const isItalic = defineModel('isItalic', { default: false })
 | 
				
			||||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
 | 
					const cursorPosition = defineModel('cursorPosition', { default: 0 })
 | 
				
			||||||
 | 
					const macroStore = useMacroStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  isFullscreen: {
 | 
					  isFullscreen: {
 | 
				
			||||||
@@ -198,6 +192,11 @@ const props = defineProps({
 | 
				
			|||||||
  contentToSet: {
 | 
					  contentToSet: {
 | 
				
			||||||
    type: String,
 | 
					    type: String,
 | 
				
			||||||
    default: null
 | 
					    default: null
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  uploadedFiles: {
 | 
				
			||||||
 | 
					    type: Array,
 | 
				
			||||||
 | 
					    required: false,
 | 
				
			||||||
 | 
					    default: () => []
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -216,14 +215,15 @@ const { t } = useI18n()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const insertContent = ref(null)
 | 
					const insertContent = ref(null)
 | 
				
			||||||
const setInlineImage = ref(null)
 | 
					const setInlineImage = ref(null)
 | 
				
			||||||
const editorPlaceholder = t('replyBox.editor.placeholder')
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const toggleBcc = async () => {
 | 
					const toggleBcc = async () => {
 | 
				
			||||||
  showBcc.value = !showBcc.value
 | 
					  showBcc.value = !showBcc.value
 | 
				
			||||||
  await nextTick()
 | 
					  await nextTick()
 | 
				
			||||||
  // If hiding BCC field, clear the content
 | 
					  // If hiding BCC field, clear the content and validate email bcc so it doesn't show errors.
 | 
				
			||||||
  if (!showBcc.value) {
 | 
					  if (!showBcc.value) {
 | 
				
			||||||
    bcc.value = ''
 | 
					    bcc.value = ''
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					    validateEmails()
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -239,53 +239,42 @@ const toggleItalic = () => {
 | 
				
			|||||||
  isItalic.value = !isItalic.value
 | 
					  isItalic.value = !isItalic.value
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const attachments = computed(() => {
 | 
					 | 
				
			||||||
  return conversationStore.conversation.mediaFiles.filter(
 | 
					 | 
				
			||||||
    (upload) => upload.disposition === 'attachment'
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const enableSend = computed(() => {
 | 
					const enableSend = computed(() => {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    (textContent.value.trim().length > 0 ||
 | 
					    (textContent.value.trim().length > 0 ||
 | 
				
			||||||
      conversationStore.conversation?.macro?.actions?.length > 0) &&
 | 
					      conversationStore.getMacro('reply')?.actions?.length > 0) &&
 | 
				
			||||||
    emailErrors.value.length === 0 &&
 | 
					    emailErrors.value.length === 0 &&
 | 
				
			||||||
    !props.uploadingFiles.length
 | 
					    !props.uploadingFiles.length
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Validate email addresses in the To, CC, and BCC fields
 | 
					 * Validates email addresses in To, CC, and BCC fields.
 | 
				
			||||||
 * @param {string} field - 'to', 'cc', or 'bcc'
 | 
					 * Populates `emailErrors` with invalid emails grouped by field.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
const validateEmails = (field) => {
 | 
					const validateEmails = async () => {
 | 
				
			||||||
  const emails = field === 'to' ? to.value : field === 'cc' ? cc.value : bcc.value
 | 
					 | 
				
			||||||
  const emailList = emails
 | 
					 | 
				
			||||||
    .split(',')
 | 
					 | 
				
			||||||
    .map((e) => e.trim())
 | 
					 | 
				
			||||||
    .filter((e) => e !== '')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const invalidEmails = emailList.filter((email) => !validateEmail(email))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Clear existing errors
 | 
					 | 
				
			||||||
  emailErrors.value = []
 | 
					  emailErrors.value = []
 | 
				
			||||||
 | 
					  await nextTick()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Add new error if there are invalid emails
 | 
					  const fields = ['to', 'cc', 'bcc']
 | 
				
			||||||
  if (invalidEmails.length > 0) {
 | 
					  const values = { to: to.value, cc: cc.value, bcc: bcc.value }
 | 
				
			||||||
    emailErrors.value = [
 | 
					
 | 
				
			||||||
      ...emailErrors.value,
 | 
					  fields.forEach((field) => {
 | 
				
			||||||
      `${t('replyBox.invalidEmailsIn')} '${field}': ${invalidEmails.join(', ')}`
 | 
					    const invalid = values[field]
 | 
				
			||||||
    ]
 | 
					      .split(',')
 | 
				
			||||||
  }
 | 
					      .map((e) => e.trim())
 | 
				
			||||||
 | 
					      .filter((e) => e && !validateEmail(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (invalid.length)
 | 
				
			||||||
 | 
					      emailErrors.value.push(`${t('replyBox.invalidEmailsIn')} '${field}': ${invalid.join(', ')}`)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Send the reply or private note
 | 
					 * Send the reply or private note
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
const handleSend = async () => {
 | 
					const handleSend = async () => {
 | 
				
			||||||
  validateEmails('to')
 | 
					  await validateEmails()
 | 
				
			||||||
  validateEmails('cc')
 | 
					 | 
				
			||||||
  validateEmails('bcc')
 | 
					 | 
				
			||||||
  if (emailErrors.value.length > 0) {
 | 
					  if (emailErrors.value.length > 0) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
@@ -300,10 +289,6 @@ const handleFileUpload = (event) => {
 | 
				
			|||||||
  emit('fileUpload', event)
 | 
					  emit('fileUpload', event)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleInlineImageUpload = (event) => {
 | 
					 | 
				
			||||||
  emit('inlineImageUpload', event)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const handleOnFileDelete = (uuid) => {
 | 
					const handleOnFileDelete = (uuid) => {
 | 
				
			||||||
  emit('fileDelete', uuid)
 | 
					  emit('fileDelete', uuid)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -317,4 +302,17 @@ const handleEmojiSelect = (emoji) => {
 | 
				
			|||||||
const handleAiPromptSelected = (key) => {
 | 
					const handleAiPromptSelected = (key) => {
 | 
				
			||||||
  emit('aiPromptSelected', key)
 | 
					  emit('aiPromptSelected', key)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Watch and update macro view based on message type this filters our macros.
 | 
				
			||||||
 | 
					watch(
 | 
				
			||||||
 | 
					  messageType,
 | 
				
			||||||
 | 
					  (newType) => {
 | 
				
			||||||
 | 
					    if (newType === 'reply') {
 | 
				
			||||||
 | 
					      macroStore.setCurrentView('replying')
 | 
				
			||||||
 | 
					    } else if (newType === 'private_note') {
 | 
				
			||||||
 | 
					      macroStore.setCurrentView('adding_private_note')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { immediate: true }
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,13 +13,13 @@
 | 
				
			|||||||
    <div class="flex justify-items-start gap-2">
 | 
					    <div class="flex justify-items-start gap-2">
 | 
				
			||||||
      <!-- File inputs -->
 | 
					      <!-- File inputs -->
 | 
				
			||||||
      <input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
 | 
					      <input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
 | 
				
			||||||
      <input
 | 
					      <!-- <input
 | 
				
			||||||
        type="file"
 | 
					        type="file"
 | 
				
			||||||
        class="hidden"
 | 
					        class="hidden"
 | 
				
			||||||
        ref="inlineImageInput"
 | 
					        ref="inlineImageInput"
 | 
				
			||||||
        accept="image/*"
 | 
					        accept="image/*"
 | 
				
			||||||
        @change="handleInlineImageUpload"
 | 
					        @change="handleInlineImageUpload"
 | 
				
			||||||
      />
 | 
					      /> -->
 | 
				
			||||||
      <!-- Editor buttons -->
 | 
					      <!-- Editor buttons -->
 | 
				
			||||||
      <Toggle
 | 
					      <Toggle
 | 
				
			||||||
        class="px-2 py-2 border-0"
 | 
					        class="px-2 py-2 border-0"
 | 
				
			||||||
@@ -38,7 +38,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="!enableSend" :isLoading="isSending">
 | 
					    <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending" v-if="showSendButton">
 | 
				
			||||||
      {{ $t('globals.buttons.send') }}
 | 
					      {{ $t('globals.buttons.send') }}
 | 
				
			||||||
    </Button>
 | 
					    </Button>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
@@ -54,7 +54,7 @@ import EmojiPicker from 'vue3-emoji-picker'
 | 
				
			|||||||
import 'vue3-emoji-picker/css'
 | 
					import 'vue3-emoji-picker/css'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const attachmentInput = ref(null)
 | 
					const attachmentInput = ref(null)
 | 
				
			||||||
const inlineImageInput = ref(null)
 | 
					// const inlineImageInput = ref(null)
 | 
				
			||||||
const isEmojiPickerVisible = ref(false)
 | 
					const isEmojiPickerVisible = ref(false)
 | 
				
			||||||
const emojiPickerRef = ref(null)
 | 
					const emojiPickerRef = ref(null)
 | 
				
			||||||
const emit = defineEmits(['emojiSelect'])
 | 
					const emit = defineEmits(['emojiSelect'])
 | 
				
			||||||
@@ -65,6 +65,7 @@ defineProps({
 | 
				
			|||||||
  isSending: Boolean,
 | 
					  isSending: Boolean,
 | 
				
			||||||
  enableSend: Boolean,
 | 
					  enableSend: Boolean,
 | 
				
			||||||
  handleSend: Function,
 | 
					  handleSend: Function,
 | 
				
			||||||
 | 
					  showSendButton: Boolean,
 | 
				
			||||||
  handleFileUpload: Function,
 | 
					  handleFileUpload: Function,
 | 
				
			||||||
  handleInlineImageUpload: Function
 | 
					  handleInlineImageUpload: Function
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
<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" :size="50" />
 | 
					    <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 dark:text-foreground">
 | 
				
			||||||
      {{ title }}
 | 
					      {{ title }}
 | 
				
			||||||
    </h1>
 | 
					    </h1>
 | 
				
			||||||
    <p class="text-gray-600 text-center text-sm">
 | 
					    <p class="text-gray-600 dark:text-gray-300 text-center text-sm">
 | 
				
			||||||
      {{ message }}
 | 
					      {{ message }}
 | 
				
			||||||
    </p>
 | 
					    </p>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,12 +2,12 @@
 | 
				
			|||||||
  <div class="h-screen flex flex-col">
 | 
					  <div class="h-screen flex flex-col">
 | 
				
			||||||
    <!-- Header -->
 | 
					    <!-- Header -->
 | 
				
			||||||
    <div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
 | 
					    <div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
 | 
				
			||||||
      <SidebarTrigger class="h-4 w-4" />
 | 
					      <SidebarTrigger class="cursor-pointer" />
 | 
				
			||||||
      <span class="text-xl font-semibold text-gray-800">{{ title }}</span>
 | 
					      <span class="text-xl font-semibold">{{ title }}</span>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Filters -->
 | 
					    <!-- Filters -->
 | 
				
			||||||
    <div class="bg-white p-2 flex justify-between items-center">
 | 
					    <div class="p-2 flex justify-between items-center">
 | 
				
			||||||
      <!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
 | 
					      <!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
 | 
				
			||||||
      <DropdownMenu v-if="!route.params.viewID">
 | 
					      <DropdownMenu v-if="!route.params.viewID">
 | 
				
			||||||
        <DropdownMenuTrigger asChild>
 | 
					        <DropdownMenuTrigger asChild>
 | 
				
			||||||
@@ -99,7 +99,7 @@
 | 
				
			|||||||
        <div
 | 
					        <div
 | 
				
			||||||
          v-if="!conversationStore.conversations.errorMessage"
 | 
					          v-if="!conversationStore.conversations.errorMessage"
 | 
				
			||||||
          key="list"
 | 
					          key="list"
 | 
				
			||||||
          class="divide-y divide-gray-200"
 | 
					          class="divide-y divide-gray-200 dark:divide-gray-700"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <ConversationListItem
 | 
					          <ConversationListItem
 | 
				
			||||||
            v-for="conversation in conversationStore.conversationsList"
 | 
					            v-for="conversation in conversationStore.conversationsList"
 | 
				
			||||||
@@ -107,7 +107,7 @@
 | 
				
			|||||||
            :conversation="conversation"
 | 
					            :conversation="conversation"
 | 
				
			||||||
            :currentConversation="conversationStore.current"
 | 
					            :currentConversation="conversationStore.current"
 | 
				
			||||||
            :contactFullName="conversationStore.getContactFullName(conversation.uuid)"
 | 
					            :contactFullName="conversationStore.getContactFullName(conversation.uuid)"
 | 
				
			||||||
            class="transition-colors duration-200 hover:bg-gray-50"
 | 
					            class="transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-600"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -144,7 +144,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed, onUnmounted, ref } from 'vue'
 | 
					import { computed } from 'vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } 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'
 | 
				
			||||||
@@ -163,7 +163,6 @@ import ConversationListItemSkeleton from '@/features/conversation/list/Conversat
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const conversationStore = useConversationStore()
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
let reFetchInterval = ref(null)
 | 
					 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const title = computed(() => {
 | 
					const title = computed(() => {
 | 
				
			||||||
@@ -174,11 +173,6 @@ const title = computed(() => {
 | 
				
			|||||||
  )
 | 
					  )
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onUnmounted(() => {
 | 
					 | 
				
			||||||
  clearInterval(reFetchInterval.value)
 | 
					 | 
				
			||||||
  conversationStore.clearListReRenderInterval()
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const handleStatusChange = (status) => {
 | 
					const handleStatusChange = (status) => {
 | 
				
			||||||
  conversationStore.setListStatus(status.label)
 | 
					  conversationStore.setListStatus(status.label)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div
 | 
					  <div
 | 
				
			||||||
    class="group relative p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 border-gray-200 last:border-b-0 hover:shadow-sm"
 | 
					    class="group relative px-4 p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 dark:hover:bg-accent/60"
 | 
				
			||||||
    :class="{
 | 
					    :class="{
 | 
				
			||||||
      'bg-accent/30 border-l-4': conversation.uuid === currentConversation?.uuid
 | 
					      'bg-accent/60 border-l-4': conversation.uuid === currentConversation?.uuid
 | 
				
			||||||
    }"
 | 
					    }"
 | 
				
			||||||
    @click="navigateToConversation(conversation.uuid)"
 | 
					    @click="navigateToConversation(conversation.uuid)"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
@@ -23,11 +23,11 @@
 | 
				
			|||||||
      <div class="flex-1 min-w-0 space-y-2">
 | 
					      <div class="flex-1 min-w-0 space-y-2">
 | 
				
			||||||
        <!-- Contact name and last message time -->
 | 
					        <!-- Contact name and last message time -->
 | 
				
			||||||
        <div class="flex items-center justify-between gap-2">
 | 
					        <div class="flex items-center justify-between gap-2">
 | 
				
			||||||
          <h3 class="text-sm font-semibold text-gray-900 truncate">
 | 
					          <h3 class="text-sm font-semibold truncate">
 | 
				
			||||||
            {{ contactFullName }}
 | 
					            {{ contactFullName }}
 | 
				
			||||||
          </h3>
 | 
					          </h3>
 | 
				
			||||||
          <span class="text-xs text-gray-400 whitespace-nowrap" v-if="conversation.last_message_at">
 | 
					          <span class="text-xs text-gray-400 whitespace-nowrap" v-if="conversation.last_message_at">
 | 
				
			||||||
            {{ formatTime(conversation.last_message_at) }}
 | 
					            {{ relativeLastMessageTime }}
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,7 +39,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        <!-- Message preview and unread count -->
 | 
					        <!-- Message preview and unread count -->
 | 
				
			||||||
        <div class="flex items-start justify-between gap-2">
 | 
					        <div class="flex items-start justify-between gap-2">
 | 
				
			||||||
          <div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all">
 | 
					          <div
 | 
				
			||||||
 | 
					            class="text-sm flex items-center gap-1.5 flex-1 break-all text-gray-600 dark:text-gray-300"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
            <Reply
 | 
					            <Reply
 | 
				
			||||||
              class="text-green-600 flex-shrink-0"
 | 
					              class="text-green-600 flex-shrink-0"
 | 
				
			||||||
              size="15"
 | 
					              size="15"
 | 
				
			||||||
@@ -55,21 +57,38 @@
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="flex items-center mt-2 space-x-2">
 | 
					        <!-- SLA Badges -->
 | 
				
			||||||
          <SlaBadge
 | 
					        <div class="flex items-center">
 | 
				
			||||||
            v-if="conversation.first_response_deadline_at"
 | 
					          <div :class="getSlaClass(frdStatus)">
 | 
				
			||||||
            :dueAt="conversation.first_response_deadline_at"
 | 
					            <SlaBadge
 | 
				
			||||||
            :actualAt="conversation.first_reply_at"
 | 
					              :dueAt="conversation.first_response_deadline_at"
 | 
				
			||||||
            :label="'FRD'"
 | 
					              :actualAt="conversation.first_reply_at"
 | 
				
			||||||
            :showExtra="false"
 | 
					              :label="'FRD'"
 | 
				
			||||||
          />
 | 
					              :showExtra="false"
 | 
				
			||||||
          <SlaBadge
 | 
					              @status="frdStatus = $event"
 | 
				
			||||||
            v-if="conversation.resolution_deadline_at"
 | 
					              :key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
 | 
				
			||||||
            :dueAt="conversation.resolution_deadline_at"
 | 
					            />
 | 
				
			||||||
            :actualAt="conversation.resolved_at"
 | 
					          </div>
 | 
				
			||||||
            :label="'RD'"
 | 
					          <div :class="getSlaClass(rdStatus)">
 | 
				
			||||||
            :showExtra="false"
 | 
					            <SlaBadge
 | 
				
			||||||
          />
 | 
					              :dueAt="conversation.resolution_deadline_at"
 | 
				
			||||||
 | 
					              :actualAt="conversation.resolved_at"
 | 
				
			||||||
 | 
					              :label="'RD'"
 | 
				
			||||||
 | 
					              :showExtra="false"
 | 
				
			||||||
 | 
					              @status="rdStatus = $event"
 | 
				
			||||||
 | 
					              :key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div :class="getSlaClass(nrdStatus)">
 | 
				
			||||||
 | 
					            <SlaBadge
 | 
				
			||||||
 | 
					              :dueAt="conversation.next_response_deadline_at"
 | 
				
			||||||
 | 
					              :actualAt="conversation.next_response_met_at"
 | 
				
			||||||
 | 
					              :label="'NRD'"
 | 
				
			||||||
 | 
					              :showExtra="false"
 | 
				
			||||||
 | 
					              @status="nrdStatus = $event"
 | 
				
			||||||
 | 
					              :key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -77,15 +96,20 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { ref, computed, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
import { useRouter, useRoute } from 'vue-router'
 | 
					import { useRouter, useRoute } from 'vue-router'
 | 
				
			||||||
import { formatTime } from '@/utils/datetime'
 | 
					import { getRelativeTime } from '@/utils/datetime'
 | 
				
			||||||
import { Mail, Reply } from 'lucide-vue-next'
 | 
					import { Mail, Reply } from 'lucide-vue-next'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			||||||
import SlaBadge from '@/features/sla/SlaBadge.vue'
 | 
					import SlaBadge from '@/features/sla/SlaBadge.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let timer = null
 | 
				
			||||||
 | 
					const now = ref(new Date())
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
 | 
					const frdStatus = ref('')
 | 
				
			||||||
 | 
					const rdStatus = ref('')
 | 
				
			||||||
 | 
					const nrdStatus = ref('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  conversation: Object,
 | 
					  conversation: Object,
 | 
				
			||||||
@@ -109,8 +133,26 @@ const navigateToConversation = (uuid) => {
 | 
				
			|||||||
  })
 | 
					  })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  timer = setInterval(() => {
 | 
				
			||||||
 | 
					    now.value = new Date()
 | 
				
			||||||
 | 
					  }, 60000)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  if (timer) clearInterval(timer)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const trimmedLastMessage = computed(() => {
 | 
					const trimmedLastMessage = computed(() => {
 | 
				
			||||||
  const message = props.conversation.last_message || ''
 | 
					  const message = props.conversation.last_message || ''
 | 
				
			||||||
  return message.length > 100 ? message.slice(0, 100) + '...' : message
 | 
					  return message.length > 100 ? message.slice(0, 100) + '...' : message
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getSlaClass = (status) => (['overdue', 'remaining'].includes(status) ? 'mr-2' : '')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const relativeLastMessageTime = computed(() => {
 | 
				
			||||||
 | 
					  return props.conversation.last_message_at
 | 
				
			||||||
 | 
					    ? getRelativeTime(props.conversation.last_message_at, now.value)
 | 
				
			||||||
 | 
					    : ''
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,10 +14,10 @@
 | 
				
			|||||||
        <div
 | 
					        <div
 | 
				
			||||||
          class="flex flex-col justify-end message-bubble relative"
 | 
					          class="flex flex-col justify-end message-bubble relative"
 | 
				
			||||||
          :class="{
 | 
					          :class="{
 | 
				
			||||||
            '!bg-[#FEF1E1]': message.private,
 | 
					            'bg-[#FEF1E1] dark:bg-[#4C3A24]': message.private,
 | 
				
			||||||
            'bg-white border border-border': !message.private,
 | 
					            'border border-border': !message.private,
 | 
				
			||||||
            'opacity-50 animate-pulse': message.status === 'pending',
 | 
					            'opacity-50 animate-pulse': message.status === 'pending',
 | 
				
			||||||
            'bg-red-50 border-red-200': message.status === 'failed'
 | 
					            'border-red-400': message.status === 'failed'
 | 
				
			||||||
          }"
 | 
					          }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <!-- Message Envelope -->
 | 
					          <!-- Message Envelope -->
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -42,7 +42,7 @@
 | 
				
			|||||||
          <div
 | 
					          <div
 | 
				
			||||||
            v-if="hasQuotedContent"
 | 
					            v-if="hasQuotedContent"
 | 
				
			||||||
            @click="toggleQuote"
 | 
					            @click="toggleQuote"
 | 
				
			||||||
            class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded-md transition-all"
 | 
					            class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded transition-all"
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {{
 | 
					            {{
 | 
				
			||||||
              showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText')
 | 
					              showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -58,7 +58,7 @@
 | 
				
			|||||||
      <div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
 | 
					      <div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          @click="handleScrollToBottom"
 | 
					          @click="handleScrollToBottom"
 | 
				
			||||||
          class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"
 | 
					          class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-background text-primary transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-700"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <ChevronDown size="18" />
 | 
					          <ChevronDown size="18" />
 | 
				
			||||||
        </button>
 | 
					        </button>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,16 +4,16 @@
 | 
				
			|||||||
      <div
 | 
					      <div
 | 
				
			||||||
        v-for="attachment in allAttachments"
 | 
					        v-for="attachment in allAttachments"
 | 
				
			||||||
        :key="attachment.uuid || attachment.tempId"
 | 
					        :key="attachment.uuid || attachment.tempId"
 | 
				
			||||||
        class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2"
 | 
					        class="flex items-center bg-background border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex items-center space-x-1 py-1">
 | 
					        <div class="flex items-center space-x-1 py-1">
 | 
				
			||||||
          <DotLoader v-if="attachment.loading"/>
 | 
					          <DotLoader v-if="attachment.loading"/>
 | 
				
			||||||
          <PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
 | 
					          <PaperclipIcon v-else size="16" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <Tooltip>
 | 
					          <Tooltip>
 | 
				
			||||||
            <TooltipTrigger as-child>
 | 
					            <TooltipTrigger as-child>
 | 
				
			||||||
              <div
 | 
					              <div
 | 
				
			||||||
                class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
 | 
					                class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900 dark:group-hover:text-foreground"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                {{ getAttachmentName(attachment.filename) }}
 | 
					                {{ getAttachmentName(attachment.filename) }}
 | 
				
			||||||
                <span class="text-xs text-gray-500 ml-1">
 | 
					                <span class="text-xs text-gray-500 ml-1">
 | 
				
			||||||
@@ -29,7 +29,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          v-if="!attachment.loading"
 | 
					          v-if="!attachment.loading"
 | 
				
			||||||
          @click.stop="onDelete(attachment.uuid)"
 | 
					          @click.prevent="onDelete(attachment.uuid)"
 | 
				
			||||||
          class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
 | 
					          class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
 | 
				
			||||||
          title="Remove attachment"
 | 
					          title="Remove attachment"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,7 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="space-y-4">
 | 
					  <div class="space-y-4">
 | 
				
			||||||
    <div
 | 
					    <div class="flex flex-col" v-if="conversation.subject">
 | 
				
			||||||
      class="flex flex-col"
 | 
					      <p class="font-medium">{{ $t('globals.terms.subject') }}</p>
 | 
				
			||||||
      v-if="conversation.subject"
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <p class="font-medium">{{ $t('form.field.subject') }}</p>
 | 
					 | 
				
			||||||
      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
				
			||||||
      <p v-else>
 | 
					      <p v-else>
 | 
				
			||||||
        {{ conversation.subject }}
 | 
					        {{ conversation.subject }}
 | 
				
			||||||
@@ -34,7 +31,7 @@
 | 
				
			|||||||
          v-if="conversation.first_response_deadline_at"
 | 
					          v-if="conversation.first_response_deadline_at"
 | 
				
			||||||
          :dueAt="conversation.first_response_deadline_at"
 | 
					          :dueAt="conversation.first_response_deadline_at"
 | 
				
			||||||
          :actualAt="conversation.first_reply_at"
 | 
					          :actualAt="conversation.first_reply_at"
 | 
				
			||||||
          :key="conversation.uuid"
 | 
					          :key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
				
			||||||
@@ -53,7 +50,7 @@
 | 
				
			|||||||
          v-if="conversation.resolution_deadline_at"
 | 
					          v-if="conversation.resolution_deadline_at"
 | 
				
			||||||
          :dueAt="conversation.resolution_deadline_at"
 | 
					          :dueAt="conversation.resolution_deadline_at"
 | 
				
			||||||
          :actualAt="conversation.resolved_at"
 | 
					          :actualAt="conversation.resolved_at"
 | 
				
			||||||
          :key="conversation.uuid"
 | 
					          :key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
				
			||||||
@@ -66,7 +63,15 @@
 | 
				
			|||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="flex flex-col">
 | 
					    <div class="flex flex-col">
 | 
				
			||||||
      <p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
 | 
					      <div class="flex justify-start items-center space-x-2">
 | 
				
			||||||
 | 
					        <p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
 | 
				
			||||||
 | 
					        <SlaBadge
 | 
				
			||||||
 | 
					          v-if="conversation.next_response_deadline_at"
 | 
				
			||||||
 | 
					          :dueAt="conversation.next_response_deadline_at"
 | 
				
			||||||
 | 
					          :actualAt="conversation.next_response_met_at"
 | 
				
			||||||
 | 
					          :key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					      <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
				
			||||||
      <p v-if="conversation.last_reply_at">
 | 
					      <p v-if="conversation.last_reply_at">
 | 
				
			||||||
        {{ format(conversation.last_reply_at, 'PPpp') }}
 | 
					        {{ format(conversation.last_reply_at, 'PPpp') }}
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user