mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-03 21:43:35 +00:00 
			
		
		
		
	WIP: MVP with shadcn sidebar
- csat - SLA - email notification templates
This commit is contained in:
		@@ -3,8 +3,8 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -55,7 +55,12 @@ func handleOIDCCallback(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set the session.
 | 
						// Set the session.
 | 
				
			||||||
	if err := app.auth.SaveSession(user, r); err != nil {
 | 
						if err := app.auth.SaveSession(amodels.User{
 | 
				
			||||||
 | 
							ID:        user.ID,
 | 
				
			||||||
 | 
							Email:     user.Email.String,
 | 
				
			||||||
 | 
							FirstName: user.FirstName,
 | 
				
			||||||
 | 
							LastName:  user.LastName,
 | 
				
			||||||
 | 
						}, r); err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										104
									
								
								cmd/business_hours.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								cmd/business_hours.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,104 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						models "github.com/abhinavxd/artemis/internal/business_hours/models"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetBusinessHours returns all business hours.
 | 
				
			||||||
 | 
					func handleGetBusinessHours(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						businessHours, err := app.businessHours.GetAll()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(businessHours)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetBusinessHour returns the business hour with the given id.
 | 
				
			||||||
 | 
					func handleGetBusinessHour(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						businessHour, err := app.businessHours.Get(id)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(businessHour)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleCreateBusinessHours creates a new business hour.
 | 
				
			||||||
 | 
					func handleCreateBusinessHours(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app           = r.Context.(*App)
 | 
				
			||||||
 | 
							businessHours = models.BusinessHours{}
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if err := r.Decode(&businessHours, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if businessHours.Name == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleDeleteBusinessHour deletes the business hour with the given id.
 | 
				
			||||||
 | 
					func handleDeleteBusinessHour(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = app.businessHours.Delete(id)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateBusinessHours updates the business hour with the given id.
 | 
				
			||||||
 | 
					func handleUpdateBusinessHours(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app           = r.Context.(*App)
 | 
				
			||||||
 | 
							businessHours = models.BusinessHours{}
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&businessHours, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if businessHours.Name == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,12 +3,15 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation/models"
 | 
						"github.com/abhinavxd/artemis/internal/automation/models"
 | 
				
			||||||
	cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
						cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
						umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/volatiletech/null/v9"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,13 +26,23 @@ func handleGetAllConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
							filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
				
			||||||
		total       = 0
 | 
							total       = 0
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	conversations, pageSize, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
 | 
					
 | 
				
			||||||
 | 
						conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if len(conversations) > 0 {
 | 
						if len(conversations) > 0 {
 | 
				
			||||||
		total = conversations[0].Total
 | 
							total = conversations[0].Total
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						for i := range conversations {
 | 
				
			||||||
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
								calculateSLA(app, &conversations[i])
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
		Results:    conversations,
 | 
							Results:    conversations,
 | 
				
			||||||
		Total:      total,
 | 
							Total:      total,
 | 
				
			||||||
@@ -43,21 +56,29 @@ func handleGetAllConversations(r *fastglue.Request) error {
 | 
				
			|||||||
func handleGetAssignedConversations(r *fastglue.Request) error {
 | 
					func handleGetAssignedConversations(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app         = r.Context.(*App)
 | 
							app         = r.Context.(*App)
 | 
				
			||||||
		user        = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							user        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
							order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
				
			||||||
		orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
							orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
				
			||||||
 | 
							filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
				
			||||||
		page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
							page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
				
			||||||
		pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
							pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
				
			||||||
		filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
					 | 
				
			||||||
		total       = 0
 | 
							total       = 0
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	conversations, pageSize, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
 | 
						conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if len(conversations) > 0 {
 | 
						if len(conversations) > 0 {
 | 
				
			||||||
		total = conversations[0].Total
 | 
							total = conversations[0].Total
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						for i := range conversations {
 | 
				
			||||||
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
								calculateSLA(app, &conversations[i])
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
		Results:    conversations,
 | 
							Results:    conversations,
 | 
				
			||||||
		Total:      total,
 | 
							Total:      total,
 | 
				
			||||||
@@ -71,21 +92,130 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
 | 
				
			|||||||
func handleGetUnassignedConversations(r *fastglue.Request) error {
 | 
					func handleGetUnassignedConversations(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app         = r.Context.(*App)
 | 
							app         = r.Context.(*App)
 | 
				
			||||||
		user        = r.RequestCtx.UserValue("user").(umodels.User)
 | 
					 | 
				
			||||||
		order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
							order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
				
			||||||
		orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
							orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
				
			||||||
 | 
							filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
				
			||||||
		page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
							page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
				
			||||||
		pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
							pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
				
			||||||
		filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
					 | 
				
			||||||
		total       = 0
 | 
							total       = 0
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	conversations, pageSize, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
 | 
					
 | 
				
			||||||
 | 
						conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if len(conversations) > 0 {
 | 
						if len(conversations) > 0 {
 | 
				
			||||||
		total = conversations[0].Total
 | 
							total = conversations[0].Total
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						for i := range conversations {
 | 
				
			||||||
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
								calculateSLA(app, &conversations[i])
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
 | 
							Results:    conversations,
 | 
				
			||||||
 | 
							Total:      total,
 | 
				
			||||||
 | 
							PerPage:    pageSize,
 | 
				
			||||||
 | 
							TotalPages: (total + pageSize - 1) / pageSize,
 | 
				
			||||||
 | 
							Page:       page,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetViewConversations retrieves conversations for a view.
 | 
				
			||||||
 | 
					func handleGetViewConversations(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app         = r.Context.(*App)
 | 
				
			||||||
 | 
							user        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							viewID, _   = strconv.Atoi(r.RequestCtx.UserValue("view_id").(string))
 | 
				
			||||||
 | 
							order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
				
			||||||
 | 
							orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
				
			||||||
 | 
							page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
				
			||||||
 | 
							pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
				
			||||||
 | 
							total       = 0
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if viewID < 1 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user has access to the view.
 | 
				
			||||||
 | 
						view, err := app.view.Get(viewID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if view.UserID != user.ID {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversations, err := app.conversation.GetViewConversationsList(user.ID, view.InboxType, order, orderBy, string(view.Filters), page, pageSize)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if len(conversations) > 0 {
 | 
				
			||||||
 | 
							total = conversations[0].Total
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						for i := range conversations {
 | 
				
			||||||
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
								calculateSLA(app, &conversations[i])
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
 | 
							Results:    conversations,
 | 
				
			||||||
 | 
							Total:      total,
 | 
				
			||||||
 | 
							PerPage:    pageSize,
 | 
				
			||||||
 | 
							TotalPages: (total + pageSize - 1) / pageSize,
 | 
				
			||||||
 | 
							Page:       page,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user.
 | 
				
			||||||
 | 
					func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app         = r.Context.(*App)
 | 
				
			||||||
 | 
							user        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							teamIDStr   = r.RequestCtx.UserValue("team_id").(string)
 | 
				
			||||||
 | 
							order       = string(r.RequestCtx.QueryArgs().Peek("order"))
 | 
				
			||||||
 | 
							orderBy     = string(r.RequestCtx.QueryArgs().Peek("order_by"))
 | 
				
			||||||
 | 
							filters     = string(r.RequestCtx.QueryArgs().Peek("filters"))
 | 
				
			||||||
 | 
							page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
				
			||||||
 | 
							pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
				
			||||||
 | 
							total       = 0
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						teamID, _ := strconv.Atoi(teamIDStr)
 | 
				
			||||||
 | 
						if teamID < 1 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user belongs to the team.
 | 
				
			||||||
 | 
						exists, err := app.team.UserBelongsToTeam(teamID, user.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !exists {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if len(conversations) > 0 {
 | 
				
			||||||
 | 
							total = conversations[0].Total
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						for i := range conversations {
 | 
				
			||||||
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
								calculateSLA(app, &conversations[i])
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
		Results:    conversations,
 | 
							Results:    conversations,
 | 
				
			||||||
		Total:      total,
 | 
							Total:      total,
 | 
				
			||||||
@@ -100,13 +230,28 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	conversation, err := enforceConversationAccess(app, uuid, user)
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate SLA deadlines if conversation has an SLA policy.
 | 
				
			||||||
 | 
						if conversation.SLAPolicyID.Int != 0 {
 | 
				
			||||||
 | 
							calculateSLA(app, &conversation)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(conversation)
 | 
						return r.SendEnvelope(conversation)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -115,12 +260,23 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	_, err := enforceConversationAccess(app, uuid, user)
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
 | 
						if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -132,12 +288,23 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	_, err := enforceConversationAccess(app, uuid, user)
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	p, err := app.conversation.GetConversationParticipants(uuid)
 | 
						p, err := app.conversation.GetConversationParticipants(uuid)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -150,19 +317,31 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app        = r.Context.(*App)
 | 
							app        = r.Context.(*App)
 | 
				
			||||||
		uuid       = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid       = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser      = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						if assigneeID == 0 {
 | 
				
			||||||
	assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, err = enforceConversationAccess(app, uuid, user)
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
 | 
						if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -178,16 +357,29 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
 | 
						assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	_, err = enforceConversationAccess(app, uuid, user)
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
 | 
						if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -204,12 +396,23 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
 | 
				
			|||||||
		p        = r.RequestCtx.PostArgs()
 | 
							p        = r.RequestCtx.PostArgs()
 | 
				
			||||||
		priority = p.Peek("priority")
 | 
							priority = p.Peek("priority")
 | 
				
			||||||
		uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user     = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	_, err := enforceConversationAccess(app, uuid, user)
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
 | 
						if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -226,20 +429,30 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			|||||||
		app          = r.Context.(*App)
 | 
							app          = r.Context.(*App)
 | 
				
			||||||
		p            = r.RequestCtx.PostArgs()
 | 
							p            = r.RequestCtx.PostArgs()
 | 
				
			||||||
		status       = p.Peek("status")
 | 
							status       = p.Peek("status")
 | 
				
			||||||
 | 
							snoozedUntil = p.Peek("snoozed_until")
 | 
				
			||||||
		uuid         = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid         = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user   = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	_, err := enforceConversationAccess(app, uuid, user)
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.conversation.UpdateConversationStatus(uuid, status, user); err != nil {
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := app.conversation.UpdateConversationStatus(uuid, status, snoozedUntil, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Evaluate automation rules.
 | 
						// Evaluate automation rules.
 | 
				
			||||||
	app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
 | 
						app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
 | 
				
			||||||
 | 
					 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -250,7 +463,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
 | 
				
			|||||||
		p       = r.RequestCtx.PostArgs()
 | 
							p       = r.RequestCtx.PostArgs()
 | 
				
			||||||
		tagIDs  = []int{}
 | 
							tagIDs  = []int{}
 | 
				
			||||||
		tagJSON = p.Peek("tag_ids")
 | 
							tagJSON = p.Peek("tag_ids")
 | 
				
			||||||
		user    = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser   = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		uuid    = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid    = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -261,11 +474,24 @@ func handleAddConversationTags(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, err = enforceConversationAccess(app, uuid, user)
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						allowed, err := app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !allowed {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
 | 
						if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -298,7 +524,7 @@ func handleDashboardCharts(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
 | 
					// enforceConversationAccess fetches the conversation and checks if the user has access to it.
 | 
				
			||||||
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
 | 
					func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
 | 
				
			||||||
	conversation, err := app.conversation.GetConversation(uuid)
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -311,3 +537,15 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return &conversation, nil
 | 
						return &conversation, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// calculateSLA calculates the SLA deadlines and sets them on the conversation.
 | 
				
			||||||
 | 
					func calculateSLA(app *App, conversation *cmodels.Conversation) error {
 | 
				
			||||||
 | 
						firstRespAt, resolutionDueAt, err := app.sla.CalculateConversationDeadlines(conversation.CreatedAt, conversation.AssignedTeamID.Int, conversation.SLAPolicyID.Int)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error calculating SLA deadlines for conversation", "id", conversation.ID, "error", err)
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						conversation.FirstReplyDueAt = null.NewTime(firstRespAt, firstRespAt != time.Time{})
 | 
				
			||||||
 | 
						conversation.ResolutionDueAt = null.NewTime(resolutionDueAt, resolutionDueAt != time.Time{})
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										81
									
								
								cmd/csat.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								cmd/csat.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleShowCSAT renders the CSAT page for a given csat.
 | 
				
			||||||
 | 
					func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app  = r.Context.(*App)
 | 
				
			||||||
 | 
							uuid = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if uuid == "" {
 | 
				
			||||||
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
 | 
								"error_message": "Page not found",
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						csat, err := app.csat.Get(uuid)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
 | 
								"error_message": "CSAT not found",
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if csat.ResponseTimestamp.Valid {
 | 
				
			||||||
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
				
			||||||
 | 
								"message": "You've already submitted your feedback",
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
 | 
								"error_message": "Conversation not found",
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
				
			||||||
 | 
							"csat": map[string]interface{}{
 | 
				
			||||||
 | 
								"uuid": csat.UUID,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							"conversation": map[string]interface{}{
 | 
				
			||||||
 | 
								"subject": conversation.Subject.String,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateCSATResponse updates the CSAT response for a given csat.
 | 
				
			||||||
 | 
					func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
 | 
							uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
 | 
							rating   = r.RequestCtx.FormValue("rating")
 | 
				
			||||||
 | 
							feedback = string(r.RequestCtx.FormValue("feedback"))
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						ratingI, err := strconv.Atoi(string(rating))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `rating`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if ratingI < 1 || ratingI > 5 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`rating` should be between 1 and 5", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if uuid == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `uuid`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope("CSAT response updated")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										240
									
								
								cmd/handlers.go
									
									
									
									
									
								
							
							
						
						
									
										240
									
								
								cmd/handlers.go
									
									
									
									
									
								
							@@ -12,129 +12,158 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var (
 | 
				
			||||||
 | 
						slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initHandlers initializes the HTTP routes and handlers for the application.
 | 
					// initHandlers initializes the HTTP routes and handlers for the application.
 | 
				
			||||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
					func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			||||||
	// Authentication.
 | 
						// Authentication.
 | 
				
			||||||
	g.POST("/api/login", handleLogin)
 | 
						g.POST("/api/v1/login", handleLogin)
 | 
				
			||||||
	g.GET("/logout", handleLogout)
 | 
						g.GET("/logout", handleLogout)
 | 
				
			||||||
	g.GET("/api/oidc/{id}/login", handleOIDCLogin)
 | 
						g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
 | 
				
			||||||
	g.GET("/api/oidc/finish", handleOIDCCallback)
 | 
						g.GET("/api/v1/oidc/finish", handleOIDCCallback)
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Health check.
 | 
					 | 
				
			||||||
	g.GET("/health", handleHealthCheck)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Serve media files.
 | 
						// Serve media files.
 | 
				
			||||||
	g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
						g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Settings.
 | 
						// Settings.
 | 
				
			||||||
	g.GET("/api/settings/general", handleGetGeneralSettings)
 | 
						g.GET("/api/v1/settings/general", handleGetGeneralSettings)
 | 
				
			||||||
	g.PUT("/api/settings/general", authPerm(handleUpdateGeneralSettings, "settings_general", "write"))
 | 
						g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
 | 
				
			||||||
	g.GET("/api/settings/notifications/email", authPerm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
 | 
						g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
 | 
				
			||||||
	g.PUT("/api/settings/notifications/email", authPerm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
 | 
						g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// OpenID SSO.
 | 
						// OpenID connect single sign-on.
 | 
				
			||||||
	g.GET("/api/oidc", handleGetAllOIDC)
 | 
						g.GET("/api/v1/oidc", handleGetAllOIDC)
 | 
				
			||||||
	g.GET("/api/oidc/{id}", authPerm(handleGetOIDC, "oidc", "read"))
 | 
						g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
 | 
				
			||||||
	g.POST("/api/oidc", authPerm(handleCreateOIDC, "oidc", "write"))
 | 
						g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc", "write"))
 | 
				
			||||||
	g.PUT("/api/oidc/{id}", authPerm(handleUpdateOIDC, "oidc", "write"))
 | 
						g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
 | 
				
			||||||
	g.DELETE("/api/oidc/{id}", authPerm(handleDeleteOIDC, "oidc", "delete"))
 | 
						g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Conversation and message.
 | 
						// All.
 | 
				
			||||||
	g.GET("/api/conversations/all", authPerm(handleGetAllConversations, "conversations", "read_all"))
 | 
						g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
 | 
				
			||||||
	g.GET("/api/conversations/unassigned", authPerm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
 | 
						// Not assigned to any user or team.
 | 
				
			||||||
	g.GET("/api/conversations/assigned", authPerm(handleGetAssignedConversations, "conversations", "read_assigned"))
 | 
						g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
 | 
				
			||||||
	g.GET("/api/conversations/{uuid}", authPerm(handleGetConversation, "conversations", "read"))
 | 
						// Assigned to logged in user.
 | 
				
			||||||
	g.GET("/api/conversations/{uuid}/participants", authPerm(handleGetConversationParticipants, "conversations", "read"))
 | 
						g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
 | 
				
			||||||
	g.PUT("/api/conversations/{uuid}/assignee/user", authPerm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
 | 
						// Unassigned conversations assigned to a team.
 | 
				
			||||||
	g.PUT("/api/conversations/{uuid}/assignee/team", authPerm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
 | 
						g.GET("/api/v1/teams/{team_id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations", "read_assigned"))
 | 
				
			||||||
	g.PUT("/api/conversations/{uuid}/priority", authPerm(handleUpdateConversationPriority, "conversations", "update_priority"))
 | 
						// Filtered by view.
 | 
				
			||||||
	g.PUT("/api/conversations/{uuid}/status", authPerm(handleUpdateConversationStatus, "conversations", "update_status"))
 | 
						g.GET("/api/v1/views/{view_id}/conversations", perm(handleGetViewConversations, "conversations", "read"))
 | 
				
			||||||
	g.PUT("/api/conversations/{uuid}/last-seen", authPerm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
 | 
					
 | 
				
			||||||
	g.POST("/api/conversations/{uuid}/tags", authPerm(handleAddConversationTags, "conversations", "update_tags"))
 | 
						g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
 | 
				
			||||||
	g.POST("/api/conversations/{cuuid}/messages", authPerm(handleSendMessage, "messages", "write"))
 | 
						g.GET("/api/v1/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
 | 
				
			||||||
	g.GET("/api/conversations/{uuid}/messages", authPerm(handleGetMessages, "messages", "read"))
 | 
						g.PUT("/api/v1/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
 | 
				
			||||||
	g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authPerm(handleRetryMessage, "messages", "write"))
 | 
						g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
 | 
				
			||||||
	g.GET("/api/conversations/{cuuid}/messages/{uuid}", authPerm(handleGetMessage, "messages", "read"))
 | 
						g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations", "update_priority"))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations", "update_status"))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
 | 
				
			||||||
 | 
						g.POST("/api/v1/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations", "update_tags"))
 | 
				
			||||||
 | 
						g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
 | 
				
			||||||
 | 
						g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
 | 
				
			||||||
 | 
						g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Views.
 | 
				
			||||||
 | 
						g.GET("/api/v1/views/me", auth(handleGetUserViews))
 | 
				
			||||||
 | 
						g.POST("/api/v1/views/me", auth(handleCreateUserView))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/views/me/{id}", auth(handleUpdateUserView))
 | 
				
			||||||
 | 
						g.DELETE("/api/v1/views/me/{id}", auth(handleDeleteUserView))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Status and priority.
 | 
						// Status and priority.
 | 
				
			||||||
	g.GET("/api/statuses", auth(handleGetStatuses))
 | 
						g.GET("/api/v1/statuses", auth(handleGetStatuses))
 | 
				
			||||||
	g.POST("/api/statuses", authPerm(handleCreateStatus, "status", "write"))
 | 
						g.POST("/api/v1/statuses", perm(handleCreateStatus, "status", "write"))
 | 
				
			||||||
	g.PUT("/api/statuses/{id}", authPerm(handleUpdateStatus, "status", "write"))
 | 
						g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
 | 
				
			||||||
	g.DELETE("/api/statuses/{id}", authPerm(handleDeleteStatus, "status", "delete"))
 | 
						g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
 | 
				
			||||||
	g.GET("/api/priorities", auth(handleGetPriorities))
 | 
						g.GET("/api/v1/priorities", auth(handleGetPriorities))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Tag.
 | 
						// Tag.
 | 
				
			||||||
	g.GET("/api/tags", auth(handleGetTags))
 | 
						g.GET("/api/v1/tags", auth(handleGetTags))
 | 
				
			||||||
	g.POST("/api/tags", authPerm(handleCreateTag, "tags", "write"))
 | 
						g.POST("/api/v1/tags", perm(handleCreateTag, "tags", "write"))
 | 
				
			||||||
	g.PUT("/api/tags/{id}", authPerm(handleUpdateTag, "tags", "write"))
 | 
						g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags", "write"))
 | 
				
			||||||
	g.DELETE("/api/tags/{id}", authPerm(handleDeleteTag, "tags", "delete"))
 | 
						g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Media.
 | 
						// Media.
 | 
				
			||||||
	g.POST("/api/media", auth(handleMediaUpload))
 | 
						g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Canned response.
 | 
						// Canned response.
 | 
				
			||||||
	g.GET("/api/canned-responses", auth(handleGetCannedResponses))
 | 
						g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
 | 
				
			||||||
	g.POST("/api/canned-responses", authPerm(handleCreateCannedResponse, "canned_responses", "write"))
 | 
						g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
 | 
				
			||||||
	g.PUT("/api/canned-responses/{id}", authPerm(handleUpdateCannedResponse, "canned_responses", "write"))
 | 
						g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
 | 
				
			||||||
	g.DELETE("/api/canned-responses/{id}", authPerm(handleDeleteCannedResponse, "canned_responses", "delete"))
 | 
						g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// User.
 | 
						// User.
 | 
				
			||||||
	g.GET("/api/users/me", auth(handleGetCurrentUser))
 | 
						g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
 | 
				
			||||||
	g.PUT("/api/users/me", auth(handleUpdateCurrentUser))
 | 
						g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
 | 
				
			||||||
	g.DELETE("/api/users/me/avatar", auth(handleDeleteAvatar))
 | 
						g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
 | 
				
			||||||
	g.GET("/api/users/compact", auth(handleGetUsersCompact))
 | 
						g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
 | 
				
			||||||
	g.GET("/api/users", authPerm(handleGetUsers, "users", "read"))
 | 
						g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
 | 
				
			||||||
	g.GET("/api/users/{id}", authPerm(handleGetUser, "users", "read"))
 | 
						g.GET("/api/v1/users", perm(handleGetUsers, "users", "read"))
 | 
				
			||||||
	g.POST("/api/users", authPerm(handleCreateUser, "users", "write"))
 | 
						g.GET("/api/v1/users/{id}", perm(handleGetUser, "users", "read"))
 | 
				
			||||||
	g.PUT("/api/users/{id}", authPerm(handleUpdateUser, "users", "write"))
 | 
						g.POST("/api/v1/users", perm(handleCreateUser, "users", "write"))
 | 
				
			||||||
	g.DELETE("/api/users/{id}", authPerm(handleDeleteUser, "users", "delete"))
 | 
						g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users", "write"))
 | 
				
			||||||
	g.POST("/api/users/reset-password", tryAuth(handleResetPassword))
 | 
						g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users", "delete"))
 | 
				
			||||||
	g.POST("/api/users/set-password", tryAuth(handleSetPassword))
 | 
						g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
 | 
				
			||||||
 | 
						g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Team.
 | 
						// Team.
 | 
				
			||||||
	g.GET("/api/teams/compact", auth(handleGetTeamsCompact))
 | 
						g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
 | 
				
			||||||
	g.GET("/api/teams", authPerm(handleGetTeams, "teams", "read"))
 | 
						g.GET("/api/v1/teams", perm(handleGetTeams, "teams", "read"))
 | 
				
			||||||
	g.POST("/api/teams", authPerm(handleCreateTeam, "teams", "write"))
 | 
						g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams", "read"))
 | 
				
			||||||
	g.GET("/api/teams/{id}", authPerm(handleGetTeam, "teams", "read"))
 | 
						g.POST("/api/v1/teams", perm(handleCreateTeam, "teams", "write"))
 | 
				
			||||||
	g.PUT("/api/teams/{id}", authPerm(handleUpdateTeam, "teams", "write"))
 | 
						g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
 | 
				
			||||||
	g.DELETE("/api/teams/{id}", authPerm(handleDeleteTeam, "teams", "delete"))
 | 
						g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// i18n.
 | 
						// i18n.
 | 
				
			||||||
	g.GET("/api/lang/{lang}", handleGetI18nLang)
 | 
						g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Automation.
 | 
						// Automation.
 | 
				
			||||||
	g.GET("/api/automation/rules", authPerm(handleGetAutomationRules, "automations", "read"))
 | 
						g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
 | 
				
			||||||
	g.GET("/api/automation/rules/{id}", authPerm(handleGetAutomationRule, "automations", "read"))
 | 
						g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
 | 
				
			||||||
	g.POST("/api/automation/rules", authPerm(handleCreateAutomationRule, "automations", "write"))
 | 
						g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
 | 
				
			||||||
	g.PUT("/api/automation/rules/{id}/toggle", authPerm(handleToggleAutomationRule, "automations", "write"))
 | 
						g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
 | 
				
			||||||
	g.PUT("/api/automation/rules/{id}", authPerm(handleUpdateAutomationRule, "automations", "write"))
 | 
						g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
 | 
				
			||||||
	g.DELETE("/api/automation/rules/{id}", authPerm(handleDeleteAutomationRule, "automations", "delete"))
 | 
						g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Inbox.
 | 
						// Inbox.
 | 
				
			||||||
	g.GET("/api/inboxes", authPerm(handleGetInboxes, "inboxes", "read"))
 | 
						g.GET("/api/v1/inboxes", perm(handleGetInboxes, "inboxes", "read"))
 | 
				
			||||||
	g.GET("/api/inboxes/{id}", authPerm(handleGetInbox, "inboxes", "read"))
 | 
						g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
 | 
				
			||||||
	g.POST("/api/inboxes", authPerm(handleCreateInbox, "inboxes", "write"))
 | 
						g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes", "write"))
 | 
				
			||||||
	g.PUT("/api/inboxes/{id}/toggle", authPerm(handleToggleInbox, "inboxes", "write"))
 | 
						g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
 | 
				
			||||||
	g.PUT("/api/inboxes/{id}", authPerm(handleUpdateInbox, "inboxes", "write"))
 | 
						g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
 | 
				
			||||||
	g.DELETE("/api/inboxes/{id}", authPerm(handleDeleteInbox, "inboxes", "delete"))
 | 
						g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Role.
 | 
						// Role.
 | 
				
			||||||
	g.GET("/api/roles", authPerm(handleGetRoles, "roles", "read"))
 | 
						g.GET("/api/v1/roles", perm(handleGetRoles, "roles", "read"))
 | 
				
			||||||
	g.GET("/api/roles/{id}", authPerm(handleGetRole, "roles", "read"))
 | 
						g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles", "read"))
 | 
				
			||||||
	g.POST("/api/roles", authPerm(handleCreateRole, "roles", "write"))
 | 
						g.POST("/api/v1/roles", perm(handleCreateRole, "roles", "write"))
 | 
				
			||||||
	g.PUT("/api/roles/{id}", authPerm(handleUpdateRole, "roles", "write"))
 | 
						g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles", "write"))
 | 
				
			||||||
	g.DELETE("/api/roles/{id}", authPerm(handleDeleteRole, "roles", "delete"))
 | 
						g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Dashboard.
 | 
						// Dashboard.
 | 
				
			||||||
	g.GET("/api/dashboard/global/counts", authPerm(handleDashboardCounts, "dashboard_global", "read"))
 | 
						g.GET("/api/v1/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
 | 
				
			||||||
	g.GET("/api/dashboard/global/charts", authPerm(handleDashboardCharts, "dashboard_global", "read"))
 | 
						g.GET("/api/v1/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Template.
 | 
						// Template.
 | 
				
			||||||
	g.GET("/api/templates", authPerm(handleGetTemplates, "templates", "read"))
 | 
						g.GET("/api/v1/templates", perm(handleGetTemplates, "templates", "read"))
 | 
				
			||||||
	g.GET("/api/templates/{id}", authPerm(handleGetTemplate, "templates", "read"))
 | 
						g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates", "read"))
 | 
				
			||||||
	g.POST("/api/templates", authPerm(handleCreateTemplate, "templates", "write"))
 | 
						g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates", "write"))
 | 
				
			||||||
	g.PUT("/api/templates/{id}", authPerm(handleUpdateTemplate, "templates", "write"))
 | 
						g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
 | 
				
			||||||
	g.DELETE("/api/templates/{id}", authPerm(handleDeleteTemplate, "templates", "delete"))
 | 
						g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Business hours.
 | 
				
			||||||
 | 
						g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
 | 
				
			||||||
 | 
						g.GET("/api/v1/business-hours/{id}", auth(handleGetBusinessHour))
 | 
				
			||||||
 | 
						g.POST("/api/v1/business-hours", auth(handleCreateBusinessHours))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/business-hours/{id}", auth(handleUpdateBusinessHours))
 | 
				
			||||||
 | 
						g.DELETE("/api/v1/business-hours/{id}", auth(handleDeleteBusinessHour))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// SLA.
 | 
				
			||||||
 | 
						g.GET("/api/v1/sla", auth(handleGetSLAs))
 | 
				
			||||||
 | 
						g.GET("/api/v1/sla/{id}", auth(handleGetSLA))
 | 
				
			||||||
 | 
						g.POST("/api/v1/sla", auth(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields)))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/sla/{id}", auth(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields)))
 | 
				
			||||||
 | 
						g.DELETE("/api/v1/sla/{id}", auth(handleDeleteSLA))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// WebSocket.
 | 
						// WebSocket.
 | 
				
			||||||
	g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
						g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
				
			||||||
@@ -150,8 +179,16 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.GET("/admin/{all:*}", authPage(serveIndexPage))
 | 
						g.GET("/admin/{all:*}", authPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/reset-password", notAuthPage(serveIndexPage))
 | 
						g.GET("/reset-password", notAuthPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/set-password", notAuthPage(serveIndexPage))
 | 
						g.GET("/set-password", notAuthPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/assets/{all:*}", serveStaticFiles)
 | 
						g.GET("/assets/{all:*}", serveFrontendStaticFiles)
 | 
				
			||||||
	g.GET("/images/{all:*}", serveStaticFiles)
 | 
						g.GET("/images/{all:*}", serveFrontendStaticFiles)
 | 
				
			||||||
 | 
						g.GET("/static/public/{all:*}", serveStaticFiles)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Public pages.
 | 
				
			||||||
 | 
						g.GET("/csat/{uuid}", handleShowCSAT)
 | 
				
			||||||
 | 
						g.POST("/csat/{uuid}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Health check.
 | 
				
			||||||
 | 
						g.GET("/health", handleHealthCheck)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// serveIndexPage serves the main index page of the application.
 | 
					// serveIndexPage serves the main index page of the application.
 | 
				
			||||||
@@ -186,6 +223,29 @@ func serveStaticFiles(r *fastglue.Request) error {
 | 
				
			|||||||
	// Get the requested file path.
 | 
						// Get the requested file path.
 | 
				
			||||||
	filePath := string(r.RequestCtx.Path())
 | 
						filePath := string(r.RequestCtx.Path())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						file, err := app.fs.Get(filePath)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set the appropriate Content-Type based on the file extension.
 | 
				
			||||||
 | 
						ext := filepath.Ext(filePath)
 | 
				
			||||||
 | 
						contentType := mime.TypeByExtension(ext)
 | 
				
			||||||
 | 
						if contentType == "" {
 | 
				
			||||||
 | 
							contentType = http.DetectContentType(file.ReadBytes())
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Set("Content-Type", contentType)
 | 
				
			||||||
 | 
						r.RequestCtx.SetBody(file.ReadBytes())
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// serveFrontendStaticFiles serves static assets from the embedded filesystem.
 | 
				
			||||||
 | 
					func serveFrontendStaticFiles(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						app := r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get the requested file path.
 | 
				
			||||||
 | 
						filePath := string(r.RequestCtx.Path())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Fetch and serve the file from the embedded filesystem.
 | 
						// Fetch and serve the file from the embedded filesystem.
 | 
				
			||||||
	finalPath := filepath.Join(frontendDir, filePath)
 | 
						finalPath := filepath.Join(frontendDir, filePath)
 | 
				
			||||||
	file, err := app.fs.Get(finalPath)
 | 
						file, err := app.fs.Get(finalPath)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										89
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										89
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -16,11 +16,12 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/artemis/internal/authz"
 | 
						"github.com/abhinavxd/artemis/internal/authz"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/autoassigner"
 | 
						"github.com/abhinavxd/artemis/internal/autoassigner"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation"
 | 
						"github.com/abhinavxd/artemis/internal/automation"
 | 
				
			||||||
 | 
						businesshours "github.com/abhinavxd/artemis/internal/business_hours"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
						"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/priority"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/priority"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/status"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/status"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/csat"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox"
 | 
						"github.com/abhinavxd/artemis/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox/channel/email"
 | 
						"github.com/abhinavxd/artemis/internal/inbox/channel/email"
 | 
				
			||||||
	imodels "github.com/abhinavxd/artemis/internal/inbox/models"
 | 
						imodels "github.com/abhinavxd/artemis/internal/inbox/models"
 | 
				
			||||||
@@ -32,10 +33,13 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/artemis/internal/oidc"
 | 
						"github.com/abhinavxd/artemis/internal/oidc"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/role"
 | 
						"github.com/abhinavxd/artemis/internal/role"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/setting"
 | 
						"github.com/abhinavxd/artemis/internal/setting"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/sla"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/tag"
 | 
						"github.com/abhinavxd/artemis/internal/tag"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team"
 | 
						"github.com/abhinavxd/artemis/internal/team"
 | 
				
			||||||
	tmpl "github.com/abhinavxd/artemis/internal/template"
 | 
						tmpl "github.com/abhinavxd/artemis/internal/template"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user"
 | 
						"github.com/abhinavxd/artemis/internal/user"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/view"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/workerpool"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws"
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/knadh/go-i18n"
 | 
						"github.com/knadh/go-i18n"
 | 
				
			||||||
@@ -189,9 +193,19 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initConversations inits conversation manager.
 | 
					// initConversations inits conversation manager.
 | 
				
			||||||
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sqlx.DB, contactStore *contact.Manager,
 | 
					func initConversations(
 | 
				
			||||||
	inboxStore *inbox.Manager, userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, automationEngine *automation.Engine, template *tmpl.Manager) *conversation.Manager {
 | 
						i18n *i18n.I18n,
 | 
				
			||||||
	c, err := conversation.New(hub, i18n, n, contactStore, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
 | 
						hub *ws.Hub,
 | 
				
			||||||
 | 
						n *notifier.Service,
 | 
				
			||||||
 | 
						db *sqlx.DB,
 | 
				
			||||||
 | 
						inboxStore *inbox.Manager,
 | 
				
			||||||
 | 
						userStore *user.Manager,
 | 
				
			||||||
 | 
						teamStore *team.Manager,
 | 
				
			||||||
 | 
						mediaStore *media.Manager,
 | 
				
			||||||
 | 
						automationEngine *automation.Engine,
 | 
				
			||||||
 | 
						template *tmpl.Manager,
 | 
				
			||||||
 | 
					) *conversation.Manager {
 | 
				
			||||||
 | 
						c, err := conversation.New(hub, i18n, n, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
 | 
				
			||||||
		DB:                       db,
 | 
							DB:                       db,
 | 
				
			||||||
		Lo:                       initLogger("conversation_manager"),
 | 
							Lo:                       initLogger("conversation_manager"),
 | 
				
			||||||
		OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
							OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
				
			||||||
@@ -203,8 +217,8 @@ func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sq
 | 
				
			|||||||
	return c
 | 
						return c
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initTags inits tag manager.
 | 
					// initTag inits tag manager.
 | 
				
			||||||
func initTags(db *sqlx.DB) *tag.Manager {
 | 
					func initTag(db *sqlx.DB) *tag.Manager {
 | 
				
			||||||
	var lo = initLogger("tag_manager")
 | 
						var lo = initLogger("tag_manager")
 | 
				
			||||||
	mgr, err := tag.New(tag.Opts{
 | 
						mgr, err := tag.New(tag.Opts{
 | 
				
			||||||
		DB: db,
 | 
							DB: db,
 | 
				
			||||||
@@ -216,6 +230,19 @@ func initTags(db *sqlx.DB) *tag.Manager {
 | 
				
			|||||||
	return mgr
 | 
						return mgr
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// initViews inits view manager.
 | 
				
			||||||
 | 
					func initView(db *sqlx.DB) *view.Manager {
 | 
				
			||||||
 | 
						var lo = initLogger("view_manager")
 | 
				
			||||||
 | 
						m, err := view.New(view.Opts{
 | 
				
			||||||
 | 
							DB: db,
 | 
				
			||||||
 | 
							Lo: lo,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error initializing view manager: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return m
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initCannedResponse inits canned response manager.
 | 
					// initCannedResponse inits canned response manager.
 | 
				
			||||||
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
 | 
					func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
 | 
				
			||||||
	var lo = initLogger("canned-response")
 | 
						var lo = initLogger("canned-response")
 | 
				
			||||||
@@ -229,26 +256,62 @@ func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
 | 
				
			|||||||
	return c
 | 
						return c
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initContact(db *sqlx.DB) *contact.Manager {
 | 
					// initBusinessHours inits business hours manager.
 | 
				
			||||||
	var lo = initLogger("contact-manager")
 | 
					func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
 | 
				
			||||||
	m, err := contact.New(contact.Opts{
 | 
						var lo = initLogger("business-hours")
 | 
				
			||||||
 | 
						m, err := businesshours.New(businesshours.Opts{
 | 
				
			||||||
		DB: db,
 | 
							DB: db,
 | 
				
			||||||
		Lo: lo,
 | 
							Lo: lo,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing contact manager: %v", err)
 | 
							log.Fatalf("error initializing business hours manager: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return m
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// initSLA inits SLA manager.
 | 
				
			||||||
 | 
					func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
 | 
				
			||||||
 | 
						var lo = initLogger("sla")
 | 
				
			||||||
 | 
						m, err := sla.New(sla.Opts{
 | 
				
			||||||
 | 
							DB:              db,
 | 
				
			||||||
 | 
							Lo:              lo,
 | 
				
			||||||
 | 
							ScannerInterval: ko.MustDuration("sla.scanner_interval"),
 | 
				
			||||||
 | 
						}, workerpool.New(ko.MustInt("sla.worker_count"), ko.MustInt("sla.queue_size")), teamManager, settings, businessHours)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error initializing SLA manager: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return m
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// initCSAT inits CSAT manager.
 | 
				
			||||||
 | 
					func initCSAT(db *sqlx.DB) *csat.Manager {
 | 
				
			||||||
 | 
						var lo = initLogger("csat")
 | 
				
			||||||
 | 
						m, err := csat.New(csat.Opts{
 | 
				
			||||||
 | 
							DB: db,
 | 
				
			||||||
 | 
							Lo: lo,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error initializing CSAT manager: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return m
 | 
						return m
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initTemplates inits template manager.
 | 
					// initTemplates inits template manager.
 | 
				
			||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
 | 
					func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
 | 
				
			||||||
	lo := initLogger("template")
 | 
						var (
 | 
				
			||||||
	tpls, err := stuffbin.ParseTemplatesGlob(getTmplFuncs(consts), fs, "/static/email-templates/*.html")
 | 
							lo      = initLogger("template")
 | 
				
			||||||
 | 
							funcMap = getTmplFuncs(consts)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error parsing e-mail templates: %v", err)
 | 
							log.Fatalf("error parsing e-mail templates: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	m, err := tmpl.New(lo, db, tpls)
 | 
					
 | 
				
			||||||
 | 
						webTpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/public/web-templates/*.html")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error parsing web templates: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing template manager: %v", err)
 | 
							log.Fatalf("error initializing template manager: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
@@ -12,13 +13,13 @@ import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
 | 
					// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
 | 
				
			||||||
func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
 | 
					func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
 | 
				
			||||||
	installed, err := checkSchema(db)
 | 
						installed, err := checkSchema(db)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error checking db schema: %v", err)
 | 
							log.Fatalf("error checking db schema: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if installed {
 | 
						if installed {
 | 
				
			||||||
		fmt.Printf("\033[31m** WARNING: This will wipe your entire DB - '%s' **\033[0m\n", ko.String("db.database"))
 | 
							fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
 | 
				
			||||||
		fmt.Print("Continue (y/n)? ")
 | 
							fmt.Print("Continue (y/n)? ")
 | 
				
			||||||
		var ok string
 | 
							var ok string
 | 
				
			||||||
		fmt.Scanf("%s", &ok)
 | 
							fmt.Scanf("%s", &ok)
 | 
				
			||||||
@@ -35,15 +36,15 @@ func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
 | 
				
			|||||||
	log.Println("Schema installed successfully")
 | 
						log.Println("Schema installed successfully")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create system user.
 | 
						// Create system user.
 | 
				
			||||||
	if err := user.CreateSystemUser(db); err != nil {
 | 
						if err := user.CreateSystemUser(ctx, db); err != nil {
 | 
				
			||||||
		log.Fatalf("error creating system user: %v", err)
 | 
							log.Fatalf("error creating system user: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// setSystemUserPass prompts for pass and sets system user password.
 | 
					// setSystemUserPass prompts for pass and sets system user password.
 | 
				
			||||||
func setSystemUserPass(db *sqlx.DB) {
 | 
					func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
 | 
				
			||||||
	user.ChangeSystemUserPassword(db)
 | 
						user.ChangeSystemUserPassword(ctx, db)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// checkSchema verifies if the DB schema is already installed by querying a table.
 | 
					// checkSchema verifies if the DB schema is already installed by querying a table.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								cmd/login.go
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								cmd/login.go
									
									
									
									
									
								
							@@ -1,12 +1,13 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleLogin logs in the user.
 | 
					// handleLogin logs a user in.
 | 
				
			||||||
func handleLogin(r *fastglue.Request) error {
 | 
					func handleLogin(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app      = r.Context.(*App)
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
@@ -14,11 +15,16 @@ func handleLogin(r *fastglue.Request) error {
 | 
				
			|||||||
		email    = string(p.Peek("email"))
 | 
							email    = string(p.Peek("email"))
 | 
				
			||||||
		password = p.Peek("password")
 | 
							password = p.Peek("password")
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Login(email, password)
 | 
						user, err := app.user.VerifyPassword(email, password)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.auth.SaveSession(user, r); err != nil {
 | 
						if err := app.auth.SaveSession(amodels.User{
 | 
				
			||||||
 | 
							ID:        user.ID,
 | 
				
			||||||
 | 
							Email:     user.Email.String,
 | 
				
			||||||
 | 
							FirstName: user.FirstName,
 | 
				
			||||||
 | 
							LastName:  user.LastName,
 | 
				
			||||||
 | 
						}, r); err != nil {
 | 
				
			||||||
		app.lo.Error("error saving session", "error", err)
 | 
							app.lo.Error("error saving session", "error", err)
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										66
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										66
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -10,11 +10,15 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	auth_ "github.com/abhinavxd/artemis/internal/auth"
 | 
						auth_ "github.com/abhinavxd/artemis/internal/auth"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/authz"
 | 
						"github.com/abhinavxd/artemis/internal/authz"
 | 
				
			||||||
 | 
						businesshours "github.com/abhinavxd/artemis/internal/business_hours"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/colorlog"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/csat"
 | 
				
			||||||
	notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
						notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/sla"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/view"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation"
 | 
						"github.com/abhinavxd/artemis/internal/automation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
						"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/priority"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/priority"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/status"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/status"
 | 
				
			||||||
@@ -55,7 +59,6 @@ type App struct {
 | 
				
			|||||||
	media         *media.Manager
 | 
						media         *media.Manager
 | 
				
			||||||
	setting       *setting.Manager
 | 
						setting       *setting.Manager
 | 
				
			||||||
	role          *role.Manager
 | 
						role          *role.Manager
 | 
				
			||||||
	contact      *contact.Manager
 | 
					 | 
				
			||||||
	user          *user.Manager
 | 
						user          *user.Manager
 | 
				
			||||||
	team          *team.Manager
 | 
						team          *team.Manager
 | 
				
			||||||
	status        *status.Manager
 | 
						status        *status.Manager
 | 
				
			||||||
@@ -66,12 +69,17 @@ type App struct {
 | 
				
			|||||||
	cannedResp    *cannedresp.Manager
 | 
						cannedResp    *cannedresp.Manager
 | 
				
			||||||
	conversation  *conversation.Manager
 | 
						conversation  *conversation.Manager
 | 
				
			||||||
	automation    *automation.Engine
 | 
						automation    *automation.Engine
 | 
				
			||||||
 | 
						businessHours *businesshours.Manager
 | 
				
			||||||
 | 
						sla           *sla.Manager
 | 
				
			||||||
 | 
						csat          *csat.Manager
 | 
				
			||||||
 | 
						view          *view.Manager
 | 
				
			||||||
	notifier      *notifier.Service
 | 
						notifier      *notifier.Service
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	// Set up signal handler.
 | 
						// Set up signal handler.
 | 
				
			||||||
	ctx, _ = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 | 
						ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 | 
				
			||||||
 | 
						defer stop()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Load command line flags into Koanf.
 | 
						// Load command line flags into Koanf.
 | 
				
			||||||
	initFlags()
 | 
						initFlags()
 | 
				
			||||||
@@ -95,13 +103,13 @@ func main() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Installer.
 | 
						// Installer.
 | 
				
			||||||
	if ko.Bool("install") {
 | 
						if ko.Bool("install") {
 | 
				
			||||||
		install(db, fs)
 | 
							install(ctx, db, fs)
 | 
				
			||||||
		os.Exit(0)
 | 
							os.Exit(0)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set system user password.
 | 
						// Set system user password.
 | 
				
			||||||
	if ko.Bool("set-system-user-password") {
 | 
						if ko.Bool("set-system-user-password") {
 | 
				
			||||||
		setSystemUserPass(db)
 | 
							setSystemUserPass(ctx, db)
 | 
				
			||||||
		os.Exit(0)
 | 
							os.Exit(0)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -132,19 +140,20 @@ func main() {
 | 
				
			|||||||
		auth                        = initAuth(oidc, rdb)
 | 
							auth                        = initAuth(oidc, rdb)
 | 
				
			||||||
		template                    = initTemplate(db, fs, constants)
 | 
							template                    = initTemplate(db, fs, constants)
 | 
				
			||||||
		media                       = initMedia(db)
 | 
							media                       = initMedia(db)
 | 
				
			||||||
		contact                     = initContact(db)
 | 
					 | 
				
			||||||
		inbox                       = initInbox(db)
 | 
							inbox                       = initInbox(db)
 | 
				
			||||||
		team                        = initTeam(db)
 | 
							team                        = initTeam(db)
 | 
				
			||||||
 | 
							businessHours               = initBusinessHours(db)
 | 
				
			||||||
		user                        = initUser(i18n, db)
 | 
							user                        = initUser(i18n, db)
 | 
				
			||||||
		notifier                    = initNotifier(user)
 | 
							notifier                    = initNotifier(user)
 | 
				
			||||||
		automation                  = initAutomationEngine(db, user)
 | 
							automation                  = initAutomationEngine(db, user)
 | 
				
			||||||
		conversation                = initConversations(i18n, wsHub, notifier, db, contact, inbox, user, team, media, automation, template)
 | 
							sla                         = initSLA(db, team, settings, businessHours)
 | 
				
			||||||
 | 
							conversation                = initConversations(i18n, wsHub, notifier, db, inbox, user, team, media, automation, template)
 | 
				
			||||||
		autoassigner                = initAutoAssigner(team, user, conversation)
 | 
							autoassigner                = initAutoAssigner(team, user, conversation)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set stores.
 | 
						// Set stores.
 | 
				
			||||||
	wsHub.SetConversationStore(conversation)
 | 
						wsHub.SetConversationStore(conversation)
 | 
				
			||||||
	automation.SetConversationStore(conversation)
 | 
						automation.SetConversationStore(conversation, sla)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Start inbox receivers.
 | 
						// Start inbox receivers.
 | 
				
			||||||
	startInboxes(ctx, inbox, conversation)
 | 
						startInboxes(ctx, inbox, conversation)
 | 
				
			||||||
@@ -161,37 +170,45 @@ func main() {
 | 
				
			|||||||
	// Start notifier.
 | 
						// Start notifier.
 | 
				
			||||||
	go notifier.Run(ctx)
 | 
						go notifier.Run(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Delete media not linked to any message.
 | 
						// Start SLA monitor.
 | 
				
			||||||
 | 
						go sla.Run(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Purge unlinked message media.
 | 
				
			||||||
	go media.DeleteUnlinkedMessageMedia(ctx)
 | 
						go media.DeleteUnlinkedMessageMedia(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Init the app
 | 
						// Init the app
 | 
				
			||||||
	var app = &App{
 | 
						var app = &App{
 | 
				
			||||||
		lo:            lo,
 | 
							lo:            lo,
 | 
				
			||||||
		auth:         auth,
 | 
					 | 
				
			||||||
		fs:            fs,
 | 
							fs:            fs,
 | 
				
			||||||
 | 
							sla:           sla,
 | 
				
			||||||
 | 
							oidc:          oidc,
 | 
				
			||||||
		i18n:          i18n,
 | 
							i18n:          i18n,
 | 
				
			||||||
 | 
							auth:          auth,
 | 
				
			||||||
		media:         media,
 | 
							media:         media,
 | 
				
			||||||
		setting:       settings,
 | 
							setting:       settings,
 | 
				
			||||||
		contact:      contact,
 | 
					 | 
				
			||||||
		inbox:         inbox,
 | 
							inbox:         inbox,
 | 
				
			||||||
		user:          user,
 | 
							user:          user,
 | 
				
			||||||
		team:          team,
 | 
							team:          team,
 | 
				
			||||||
		tmpl:          template,
 | 
							tmpl:          template,
 | 
				
			||||||
 | 
							notifier:      notifier,
 | 
				
			||||||
 | 
							consts:        constants,
 | 
				
			||||||
		conversation:  conversation,
 | 
							conversation:  conversation,
 | 
				
			||||||
		automation:    automation,
 | 
							automation:    automation,
 | 
				
			||||||
		oidc:         oidc,
 | 
							businessHours: businessHours,
 | 
				
			||||||
		consts:       constants,
 | 
							view:          initView(db),
 | 
				
			||||||
		notifier:     notifier,
 | 
							csat:          initCSAT(db),
 | 
				
			||||||
		authz:         initAuthz(),
 | 
							authz:         initAuthz(),
 | 
				
			||||||
		status:        initStatus(db),
 | 
							status:        initStatus(db),
 | 
				
			||||||
		priority:      initPriority(db),
 | 
							priority:      initPriority(db),
 | 
				
			||||||
		role:          initRole(db),
 | 
							role:          initRole(db),
 | 
				
			||||||
		tag:          initTags(db),
 | 
							tag:           initTag(db),
 | 
				
			||||||
		cannedResp:    initCannedResponse(db),
 | 
							cannedResp:    initCannedResponse(db),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Init fastglue and set app in ctx.
 | 
						// Init fastglue and set app in ctx.
 | 
				
			||||||
	g := fastglue.NewGlue()
 | 
						g := fastglue.NewGlue()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set the app in context.
 | 
				
			||||||
	g.SetContext(app)
 | 
						g.SetContext(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Init HTTP handlers.
 | 
						// Init HTTP handlers.
 | 
				
			||||||
@@ -206,24 +223,37 @@ func main() {
 | 
				
			|||||||
		ReadBufferSize:       ko.MustInt("app.server.max_body_size"),
 | 
							ReadBufferSize:       ko.MustInt("app.server.max_body_size"),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	log.Printf("%s🚀 server listening on %s %s\x1b[0m", "\x1b[32m", ko.String("app.server.address"), ko.String("app.server.socket"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	go func() {
 | 
						go func() {
 | 
				
			||||||
		if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
 | 
							if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
 | 
				
			||||||
			log.Fatalf("error starting server: %v", err)
 | 
								log.Fatalf("error starting server: %v", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}()
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						colorlog.Green("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Wait for shutdown signal.
 | 
				
			||||||
	<-ctx.Done()
 | 
						<-ctx.Done()
 | 
				
			||||||
	log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m")
 | 
						colorlog.Red("Shutting down the server. Please wait....")
 | 
				
			||||||
	// Shutdown HTTP server.
 | 
						// Shutdown HTTP server.
 | 
				
			||||||
	s.Shutdown()
 | 
						s.Shutdown()
 | 
				
			||||||
 | 
						colorlog.Red("Server shutdown complete.")
 | 
				
			||||||
 | 
						colorlog.Red("Shutting down services. Please wait....")
 | 
				
			||||||
	// Shutdown services.
 | 
						// Shutdown services.
 | 
				
			||||||
	inbox.Close()
 | 
						inbox.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Inbox shutdown complete.")
 | 
				
			||||||
	automation.Close()
 | 
						automation.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Automation shutdown complete.")
 | 
				
			||||||
	autoassigner.Close()
 | 
						autoassigner.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Autoassigner shutdown complete.")
 | 
				
			||||||
	notifier.Close()
 | 
						notifier.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Notifier shutdown complete.")
 | 
				
			||||||
	conversation.Close()
 | 
						conversation.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Conversation shutdown complete.")
 | 
				
			||||||
 | 
						sla.Close()
 | 
				
			||||||
 | 
						colorlog.Red("SLA shutdown complete.")
 | 
				
			||||||
	db.Close()
 | 
						db.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Database shutdown complete.")
 | 
				
			||||||
	rdb.Close()
 | 
						rdb.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Redis shutdown complete.")
 | 
				
			||||||
 | 
						colorlog.Green("Shutdown complete.")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,10 +10,10 @@ import (
 | 
				
			|||||||
	"slices"
 | 
						"slices"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment"
 | 
						"github.com/abhinavxd/artemis/internal/attachment"
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/image"
 | 
						"github.com/abhinavxd/artemis/internal/image"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/stringutil"
 | 
						"github.com/abhinavxd/artemis/internal/stringutil"
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
					 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
@@ -146,10 +146,15 @@ func handleMediaUpload(r *fastglue.Request) error {
 | 
				
			|||||||
func handleServeMedia(r *fastglue.Request) error {
 | 
					func handleServeMedia(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Fetch media from DB.
 | 
						// Fetch media from DB.
 | 
				
			||||||
	media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
 | 
						media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,10 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation/models"
 | 
						"github.com/abhinavxd/artemis/internal/automation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	medModels "github.com/abhinavxd/artemis/internal/media/models"
 | 
						medModels "github.com/abhinavxd/artemis/internal/media/models"
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
					 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -22,14 +22,19 @@ func handleGetMessages(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app         = r.Context.(*App)
 | 
							app         = r.Context.(*App)
 | 
				
			||||||
		uuid        = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid        = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		user        = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser       = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
							page, _     = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
 | 
				
			||||||
		pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
							pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
 | 
				
			||||||
		total       = 0
 | 
							total       = 0
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check permission
 | 
						// Check permission
 | 
				
			||||||
	_, err := enforceConversationAccess(app, uuid, user)
 | 
						_, err = enforceConversationAccess(app, uuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -60,11 +65,15 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		user  = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check permission
 | 
						// Check permission
 | 
				
			||||||
	_, err := enforceConversationAccess(app, cuuid, user)
 | 
						_, err = enforceConversationAccess(app, cuuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -87,11 +96,16 @@ func handleRetryMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		user  = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser  = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check permission
 | 
						// Check permission
 | 
				
			||||||
	_, err := enforceConversationAccess(app, cuuid, user)
 | 
						_, err = enforceConversationAccess(app, cuuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -107,14 +121,19 @@ func handleRetryMessage(r *fastglue.Request) error {
 | 
				
			|||||||
func handleSendMessage(r *fastglue.Request) error {
 | 
					func handleSendMessage(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user   = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser  = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		req   = messageReq{}
 | 
							req   = messageReq{}
 | 
				
			||||||
		media = []medModels.Media{}
 | 
							media = []medModels.Media{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check permission
 | 
						// Check permission
 | 
				
			||||||
	_, err := enforceConversationAccess(app, cuuid, user)
 | 
						_, err = enforceConversationAccess(app, cuuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
@@ -27,7 +28,12 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Set user in context if found.
 | 
							// Set user in context if found.
 | 
				
			||||||
		r.RequestCtx.SetUserValue("user", user)
 | 
							r.RequestCtx.SetUserValue("user", amodels.User{
 | 
				
			||||||
 | 
								ID:        user.ID,
 | 
				
			||||||
 | 
								Email:     user.Email.String,
 | 
				
			||||||
 | 
								FirstName: user.FirstName,
 | 
				
			||||||
 | 
								LastName:  user.LastName,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return handler(r)
 | 
							return handler(r)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -52,25 +58,30 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		r.RequestCtx.SetUserValue("user", user)
 | 
							r.RequestCtx.SetUserValue("user", amodels.User{
 | 
				
			||||||
 | 
								ID:        user.ID,
 | 
				
			||||||
 | 
								Email:     user.Email.String,
 | 
				
			||||||
 | 
								FirstName: user.FirstName,
 | 
				
			||||||
 | 
								LastName:  user.LastName,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return handler(r)
 | 
							return handler(r)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// authPerm does session validation, CSRF, and permission enforcement.
 | 
					// perm does session validation, CSRF, and permission enforcement.
 | 
				
			||||||
func authPerm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
 | 
					func perm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
 | 
				
			||||||
	return func(r *fastglue.Request) error {
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
		var (
 | 
							var (
 | 
				
			||||||
			app = r.Context.(*App)
 | 
								app = r.Context.(*App)
 | 
				
			||||||
			cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
 | 
								// cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
 | 
				
			||||||
			hdrToken    = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
 | 
								// hdrToken    = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
 | 
							// if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
 | 
				
			||||||
			app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
 | 
							// 	app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
 | 
				
			||||||
			return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
 | 
							// 	return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
 | 
				
			||||||
		}
 | 
							// }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Validate session and fetch user.
 | 
							// Validate session and fetch user.
 | 
				
			||||||
		userSession, err := app.auth.ValidateSession(r)
 | 
							userSession, err := app.auth.ValidateSession(r)
 | 
				
			||||||
@@ -96,7 +107,12 @@ func authPerm(handler fastglue.FastRequestHandler, object, action string) fastgl
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Set user in the request context.
 | 
							// Set user in the request context.
 | 
				
			||||||
		r.RequestCtx.SetUserValue("user", user)
 | 
							r.RequestCtx.SetUserValue("user", amodels.User{
 | 
				
			||||||
 | 
								ID:        user.ID,
 | 
				
			||||||
 | 
								Email:     user.Email.String,
 | 
				
			||||||
 | 
								FirstName: user.FirstName,
 | 
				
			||||||
 | 
								LastName:  user.LastName,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return handler(r)
 | 
							return handler(r)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetGeneralSettings fetches general settings.
 | 
				
			||||||
func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
					func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -22,6 +23,7 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(out)
 | 
						return r.SendEnvelope(out)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateGeneralSettings updates general settings.
 | 
				
			||||||
func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
					func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -35,9 +37,10 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := app.setting.Update(req); err != nil {
 | 
						if err := app.setting.Update(req); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope("Settings updated successfully")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetEmailNotificationSettings fetches email notification settings.
 | 
				
			||||||
func handleGetEmailNotificationSettings(r *fastglue.Request) error {
 | 
					func handleGetEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
@@ -59,6 +62,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(notif)
 | 
						return r.SendEnvelope(notif)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateEmailNotificationSettings updates email notification settings.
 | 
				
			||||||
func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
					func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -86,5 +90,5 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := app.setting.Update(req); err != nil {
 | 
						if err := app.setting.Update(req); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope("Settings updated successfully")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										106
									
								
								cmd/sla.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								cmd/sla.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func handleGetSLAs(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						slas, err := app.sla.GetAll()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(slas)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func handleGetSLA(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						sla, err := app.sla.Get(id)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(sla)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func handleCreateSLA(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app           = r.Context.(*App)
 | 
				
			||||||
 | 
							name          = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
				
			||||||
 | 
							desc          = string(r.RequestCtx.PostArgs().Peek("description"))
 | 
				
			||||||
 | 
							firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
 | 
				
			||||||
 | 
							resTime       = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate time duration strings
 | 
				
			||||||
 | 
						if _, err := time.ParseDuration(firstRespTime); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if _, err := time.ParseDuration(resTime); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func handleDeleteSLA(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err = app.sla.Delete(id); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func handleUpdateSLA(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app           = r.Context.(*App)
 | 
				
			||||||
 | 
							name          = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
				
			||||||
 | 
							desc          = string(r.RequestCtx.PostArgs().Peek("description"))
 | 
				
			||||||
 | 
							firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
 | 
				
			||||||
 | 
							resTime       = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate time duration strings
 | 
				
			||||||
 | 
						if _, err := time.ParseDuration(firstRespTime); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if _, err := time.ParseDuration(resTime); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										43
									
								
								cmd/teams.go
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								cmd/teams.go
									
									
									
									
									
								
							@@ -1,15 +1,14 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
					 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team/models"
 | 
					 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTeams returns a list of all teams.
 | 
				
			||||||
func handleGetTeams(r *fastglue.Request) error {
 | 
					func handleGetTeams(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -21,6 +20,7 @@ func handleGetTeams(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(teams)
 | 
						return r.SendEnvelope(teams)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTeamsCompact returns a list of all teams in a compact format.
 | 
				
			||||||
func handleGetTeamsCompact(r *fastglue.Request) error {
 | 
					func handleGetTeamsCompact(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -32,6 +32,7 @@ func handleGetTeamsCompact(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(teams)
 | 
						return r.SendEnvelope(teams)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTeam returns a single team.
 | 
				
			||||||
func handleGetTeam(r *fastglue.Request) error {
 | 
					func handleGetTeam(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -41,35 +42,38 @@ func handleGetTeam(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
			"Invalid team `id`.", nil, envelope.InputError)
 | 
								"Invalid team `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	team, err := app.team.GetTeam(id)
 | 
						team, err := app.team.Get(id)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(team)
 | 
						return r.SendEnvelope(team)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleCreateTeam creates a new team.
 | 
				
			||||||
func handleCreateTeam(r *fastglue.Request) error {
 | 
					func handleCreateTeam(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app                        = r.Context.(*App)
 | 
							app                        = r.Context.(*App)
 | 
				
			||||||
		req = models.Team{}
 | 
							name                       = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
				
			||||||
 | 
							timezone                   = string(r.RequestCtx.PostArgs().Peek("timezone"))
 | 
				
			||||||
 | 
							conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
 | 
				
			||||||
	if _, err := fastglue.ScanArgs(r.RequestCtx.PostArgs(), &req, `json`); err != nil {
 | 
						if err != nil || businessHrsID == 0 {
 | 
				
			||||||
		app.lo.Error("error scanning args", "error", err)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
 | 
				
			||||||
		return envelope.NewError(envelope.InputError,
 | 
					 | 
				
			||||||
			fmt.Sprintf("Invalid request (%s)", err.Error()), nil)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	err := app.team.CreateTeam(req)
 | 
						if err := app.team.Create(name, timezone, conversationAssignmentType, businessHrsID); err != nil {
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope("Team created successfully.")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateTeam updates an existing team.
 | 
				
			||||||
func handleUpdateTeam(r *fastglue.Request) error {
 | 
					func handleUpdateTeam(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app                        = r.Context.(*App)
 | 
							app                        = r.Context.(*App)
 | 
				
			||||||
		req = models.Team{}
 | 
							name                       = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
				
			||||||
 | 
							timezone                   = string(r.RequestCtx.PostArgs().Peek("timezone"))
 | 
				
			||||||
 | 
							conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	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 {
 | 
				
			||||||
@@ -77,11 +81,12 @@ func handleUpdateTeam(r *fastglue.Request) error {
 | 
				
			|||||||
			"Invalid team `id`.", nil, envelope.InputError)
 | 
								"Invalid team `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, "Bad request", nil)
 | 
						if err != nil || businessHrsID == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	err = app.team.UpdateTeam(id, req)
 | 
					
 | 
				
			||||||
	if err != nil {
 | 
						if err = app.team.Update(id, name, timezone, conversationAssignmentType, businessHrsID); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
@@ -97,9 +102,9 @@ func handleDeleteTeam(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
			"Invalid team `id`.", nil, envelope.InputError)
 | 
								"Invalid team `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	err = app.team.DeleteTeam(id)
 | 
						err = app.team.Delete(id)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope("Team deleted successfully.")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -9,17 +9,23 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTemplates returns all templates.
 | 
				
			||||||
func handleGetTemplates(r *fastglue.Request) error {
 | 
					func handleGetTemplates(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
 | 
							typ = string(r.RequestCtx.QueryArgs().Peek("type"))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	t, err := app.tmpl.GetAll()
 | 
						if typ == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `type`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						t, err := app.tmpl.GetAll(typ)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(t)
 | 
						return r.SendEnvelope(t)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetTemplate returns a template by id.
 | 
				
			||||||
func handleGetTemplate(r *fastglue.Request) error {
 | 
					func handleGetTemplate(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -36,6 +42,7 @@ func handleGetTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(t)
 | 
						return r.SendEnvelope(t)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleCreateTemplate creates a new template.
 | 
				
			||||||
func handleCreateTemplate(r *fastglue.Request) error {
 | 
					func handleCreateTemplate(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -44,14 +51,13 @@ func handleCreateTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						if err := app.tmpl.Create(req); err != nil {
 | 
				
			||||||
	err := app.tmpl.Create(req)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateTemplate updates a template.
 | 
				
			||||||
func handleUpdateTemplate(r *fastglue.Request) error {
 | 
					func handleUpdateTemplate(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -62,17 +68,16 @@ func handleUpdateTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
			"Invalid template `id`.", nil, envelope.InputError)
 | 
								"Invalid template `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err = app.tmpl.Update(id, req); err != nil {
 | 
						if err = app.tmpl.Update(id, req); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleDeleteTemplate deletes a template.
 | 
				
			||||||
func handleDeleteTemplate(r *fastglue.Request) error {
 | 
					func handleDeleteTemplate(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -83,11 +88,9 @@ func handleDeleteTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
			"Invalid template `id`.", nil, envelope.InputError)
 | 
								"Invalid template `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err = app.tmpl.Delete(id); err != nil {
 | 
						if err = app.tmpl.Delete(id); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										57
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -8,13 +8,14 @@ import (
 | 
				
			|||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/envelope"
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/image"
 | 
						"github.com/abhinavxd/artemis/internal/image"
 | 
				
			||||||
	mmodels "github.com/abhinavxd/artemis/internal/media/models"
 | 
						mmodels "github.com/abhinavxd/artemis/internal/media/models"
 | 
				
			||||||
	notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
						notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/stringutil"
 | 
						"github.com/abhinavxd/artemis/internal/stringutil"
 | 
				
			||||||
	tmpl "github.com/abhinavxd/artemis/internal/template"
 | 
						tmpl "github.com/abhinavxd/artemis/internal/template"
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
						"github.com/abhinavxd/artemis/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -23,6 +24,7 @@ const (
 | 
				
			|||||||
	maxAvatarSizeMB = 5
 | 
						maxAvatarSizeMB = 5
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetUsers returns all users.
 | 
				
			||||||
func handleGetUsers(r *fastglue.Request) error {
 | 
					func handleGetUsers(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
@@ -61,11 +63,33 @@ func handleGetUser(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(user)
 | 
						return r.SendEnvelope(user)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetCurrentUserTeams returns the teams of a user.
 | 
				
			||||||
 | 
					func handleGetCurrentUserTeams(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						teams, err := app.team.GetUserTeams(user.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(teams)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func handleUpdateCurrentUser(r *fastglue.Request) error {
 | 
					func handleUpdateCurrentUser(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Get current user.
 | 
						// Get current user.
 | 
				
			||||||
	currentUser, err := app.user.Get(user.ID)
 | 
						currentUser, err := app.user.Get(user.ID)
 | 
				
			||||||
@@ -144,17 +168,17 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
func handleCreateUser(r *fastglue.Request) error {
 | 
					func handleCreateUser(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app  = r.Context.(*App)
 | 
							app  = r.Context.(*App)
 | 
				
			||||||
		user = umodels.User{}
 | 
							user = models.User{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
						if err := r.Decode(&user, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Email == "" {
 | 
						if user.Email.String == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	err := app.user.Create(&user)
 | 
						err := app.user.CreateAgent(&user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -172,7 +196,7 @@ func handleCreateUser(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Render template and send email.
 | 
							// Render template and send email.
 | 
				
			||||||
		content, err := app.tmpl.Render(tmpl.TmplWelcome, map[string]interface{}{
 | 
							content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
 | 
				
			||||||
			"ResetToken": resetToken,
 | 
								"ResetToken": resetToken,
 | 
				
			||||||
			"Email":      user.Email,
 | 
								"Email":      user.Email,
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
@@ -198,7 +222,7 @@ func handleCreateUser(r *fastglue.Request) error {
 | 
				
			|||||||
func handleUpdateUser(r *fastglue.Request) error {
 | 
					func handleUpdateUser(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app  = r.Context.(*App)
 | 
							app  = r.Context.(*App)
 | 
				
			||||||
		user = umodels.User{}
 | 
							user = models.User{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	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 {
 | 
				
			||||||
@@ -253,8 +277,12 @@ func handleDeleteUser(r *fastglue.Request) error {
 | 
				
			|||||||
func handleGetCurrentUser(r *fastglue.Request) error {
 | 
					func handleGetCurrentUser(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	u, err := app.user.Get(user.ID)
 | 
						u, err := app.user.Get(user.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -266,11 +294,11 @@ func handleGetCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
func handleDeleteAvatar(r *fastglue.Request) error {
 | 
					func handleDeleteAvatar(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Get user
 | 
						// Get user
 | 
				
			||||||
	user, err := app.user.Get(user.ID)
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -298,11 +326,10 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app       = r.Context.(*App)
 | 
				
			||||||
		p         = r.RequestCtx.PostArgs()
 | 
							p         = r.RequestCtx.PostArgs()
 | 
				
			||||||
		user, ok = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		email     = string(p.Peek("email"))
 | 
							email     = string(p.Peek("email"))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						if ok && auser.ID > 0 {
 | 
				
			||||||
	if ok && user.ID > 0 {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -321,7 +348,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send email.
 | 
						// Send email.
 | 
				
			||||||
	content, err := app.tmpl.Render(tmpl.TmplResetPassword,
 | 
						content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
 | 
				
			||||||
		map[string]string{
 | 
							map[string]string{
 | 
				
			||||||
			"ResetToken": token,
 | 
								"ResetToken": token,
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
@@ -347,7 +374,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
func handleSetPassword(r *fastglue.Request) error {
 | 
					func handleSetPassword(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app      = r.Context.(*App)
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
		user, ok = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							user, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		p        = r.RequestCtx.PostArgs()
 | 
							p        = r.RequestCtx.PostArgs()
 | 
				
			||||||
		password = string(p.Peek("password"))
 | 
							password = string(p.Peek("password"))
 | 
				
			||||||
		token    = string(p.Peek("token"))
 | 
							token    = string(p.Peek("token"))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										139
									
								
								cmd/views.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								cmd/views.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,139 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/envelope"
 | 
				
			||||||
 | 
						vmodels "github.com/abhinavxd/artemis/internal/view/models"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetUserViews returns all views for a user.
 | 
				
			||||||
 | 
					func handleGetUserViews(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						v, err := app.view.GetUsersViews(user.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(v)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleCreateUserView creates a view for a user.
 | 
				
			||||||
 | 
					func handleCreateUserView(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							view  = vmodels.View{}
 | 
				
			||||||
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if err := r.Decode(&view, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if view.Name == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if string(view.Filters) == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.view.Create(view.Name, view.Filters, view.InboxType, user.ID); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope("View created successfully")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetUserView deletes a view for a user.
 | 
				
			||||||
 | 
					func handleDeleteUserView(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
 | 
								"Invalid view `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if id <= 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						view, err := app.view.Get(id)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if view.UserID != user.ID {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err = app.view.Delete(id); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope("View deleted successfully")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleUpdateUserView updates a view for a user.
 | 
				
			||||||
 | 
					func handleUpdateUserView(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							view  = vmodels.View{}
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						if err != nil || id == 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
 | 
								"Invalid view `id`.", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&view, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if view.Name == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if string(view.Filters) == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						v, err := app.view.Get(id)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if v.UserID != user.ID {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err = app.view.Update(id, view.Name, view.Filters, view.InboxType); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,7 +3,7 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
						amodels "github.com/abhinavxd/artemis/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws"
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
 | 
						wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
 | 
				
			||||||
	"github.com/fasthttp/websocket"
 | 
						"github.com/fasthttp/websocket"
 | 
				
			||||||
@@ -26,10 +26,14 @@ var upgrader = websocket.FastHTTPUpgrader{
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
 | 
					func handleWS(r *fastglue.Request, hub *ws.Hub) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		user = r.RequestCtx.UserValue("user").(umodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
 | 
						user, err := app.user.Get(auser.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						err = upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
 | 
				
			||||||
		c := ws.Client{
 | 
							c := ws.Client{
 | 
				
			||||||
			ID:   user.ID,
 | 
								ID:   user.ID,
 | 
				
			||||||
			Hub:  hub,
 | 
								Hub:  hub,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,7 +35,7 @@
 | 
				
			|||||||
    "@vue/reactivity": "^3.4.15",
 | 
					    "@vue/reactivity": "^3.4.15",
 | 
				
			||||||
    "@vue/runtime-core": "^3.4.15",
 | 
					    "@vue/runtime-core": "^3.4.15",
 | 
				
			||||||
    "@vueup/vue-quill": "^1.2.0",
 | 
					    "@vueup/vue-quill": "^1.2.0",
 | 
				
			||||||
    "@vueuse/core": "^11.2.0",
 | 
					    "@vueuse/core": "^12.2.0",
 | 
				
			||||||
    "add": "^2.0.6",
 | 
					    "add": "^2.0.6",
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "class-variance-authority": "^0.7.0",
 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
@@ -59,6 +59,7 @@
 | 
				
			|||||||
    "vue-letter": "^0.2.0",
 | 
					    "vue-letter": "^0.2.0",
 | 
				
			||||||
    "vue-picture-cropper": "^0.7.0",
 | 
					    "vue-picture-cropper": "^0.7.0",
 | 
				
			||||||
    "vue-router": "^4.2.5",
 | 
					    "vue-router": "^4.2.5",
 | 
				
			||||||
 | 
					    "vue-sonner": "^1.3.0",
 | 
				
			||||||
    "zod": "^3.23.8"
 | 
					    "zod": "^3.23.8"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex">
 | 
					  <Toaster />
 | 
				
			||||||
    <NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
 | 
					  <Sidebar :isLoading="false" :open="sidebarOpen" :userTeams="userStore.teams" :userViews="userViews" @update:open="sidebarOpen = $event"
 | 
				
			||||||
      class="shadow shadow-gray-300 h-screen" />
 | 
					    @create-view="openCreateViewForm = true" @edit-view="editView" @delete-view="deleteView">
 | 
				
			||||||
    <ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
 | 
					    <ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
 | 
				
			||||||
      <ResizableHandle id="resize-handle-1" />
 | 
					      <ResizableHandle id="resize-handle-1" />
 | 
				
			||||||
      <ResizablePanel id="resize-panel-2">
 | 
					      <ResizablePanel id="resize-panel-2">
 | 
				
			||||||
@@ -9,75 +9,94 @@
 | 
				
			|||||||
          <RouterView />
 | 
					          <RouterView />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </ResizablePanel>
 | 
					      </ResizablePanel>
 | 
				
			||||||
 | 
					      <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
 | 
				
			||||||
    </ResizablePanelGroup>
 | 
					    </ResizablePanelGroup>
 | 
				
			||||||
  </div>
 | 
					  </Sidebar>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
 | 
					import { onMounted, onUnmounted, ref } from 'vue'
 | 
				
			||||||
import { RouterView, useRouter } from 'vue-router'
 | 
					import { RouterView, useRouter } from 'vue-router'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
import { initWS } from '@/websocket.js'
 | 
					import { initWS } from '@/websocket.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { Toaster } from '@/components/ui/sonner'
 | 
				
			||||||
import { useToast } from '@/components/ui/toast/use-toast'
 | 
					import { useToast } from '@/components/ui/toast/use-toast'
 | 
				
			||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
 | 
					import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import NavBar from '@/components/NavBar.vue'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import { useConversationStore } from './stores/conversation'
 | 
				
			||||||
 | 
					import ViewForm from '@/components/ViewForm.vue'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { t } = useI18n()
 | 
					 | 
				
			||||||
const { toast } = useToast()
 | 
					const { toast } = useToast()
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const isCollapsed = ref(true)
 | 
					const sidebarOpen = ref(true)
 | 
				
			||||||
const allNavLinks = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: t('navbar.dashboard'),
 | 
					 | 
				
			||||||
    to: '/dashboard',
 | 
					 | 
				
			||||||
    label: '',
 | 
					 | 
				
			||||||
    icon: 'lucide:layout-dashboard',
 | 
					 | 
				
			||||||
    permission: 'dashboard_global:read',
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: t('navbar.conversations'),
 | 
					 | 
				
			||||||
    to: '/conversations',
 | 
					 | 
				
			||||||
    label: '',
 | 
					 | 
				
			||||||
    icon: 'lucide:message-circle-more'
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: t('navbar.account'),
 | 
					 | 
				
			||||||
    to: '/account/profile',
 | 
					 | 
				
			||||||
    label: '',
 | 
					 | 
				
			||||||
    icon: 'lucide:circle-user-round'
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: t('navbar.admin'),
 | 
					 | 
				
			||||||
    to: '/admin/general',
 | 
					 | 
				
			||||||
    label: '',
 | 
					 | 
				
			||||||
    icon: 'lucide:settings',
 | 
					 | 
				
			||||||
    permission: 'admin:read'
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const bottomLinks = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    to: '/logout',
 | 
					 | 
				
			||||||
    icon: 'lucide:log-out',
 | 
					 | 
				
			||||||
    title: 'Logout'
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
initWS()
 | 
					const userViews = ref([])
 | 
				
			||||||
 | 
					const view = ref({})
 | 
				
			||||||
 | 
					const openCreateViewForm = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					initWS()
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  initToaster()
 | 
					  initToaster()
 | 
				
			||||||
 | 
					  listenViewRefresh()
 | 
				
			||||||
  getCurrentUser()
 | 
					  getCurrentUser()
 | 
				
			||||||
 | 
					  getUserViews()
 | 
				
			||||||
 | 
					  intiStores()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onUnmounted(() => {
 | 
					onUnmounted(() => {
 | 
				
			||||||
  emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
 | 
					  emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
 | 
				
			||||||
 | 
					  emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const intiStores = () => {
 | 
				
			||||||
 | 
					  Promise.all([
 | 
				
			||||||
 | 
					    conversationStore.fetchStatuses(),
 | 
				
			||||||
 | 
					    conversationStore.fetchPriorities()
 | 
				
			||||||
 | 
					  ])
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const editView = (v) => {
 | 
				
			||||||
 | 
					  view.value = { ...v }
 | 
				
			||||||
 | 
					  openCreateViewForm.value = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteView = async (view) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await api.deleteView(view.id)
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      title: 'Success',
 | 
				
			||||||
 | 
					      variant: 'success',
 | 
				
			||||||
 | 
					      description: 'View deleted successfully'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      title: 'Error',
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: handleHTTPError(err).message
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getUserViews = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await api.getCurrentUserViews()
 | 
				
			||||||
 | 
					    userViews.value = response.data.data
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      title: 'Error',
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: handleHTTPError(err).message
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getCurrentUser = () => {
 | 
					const getCurrentUser = () => {
 | 
				
			||||||
  userStore.getCurrentUser().catch((err) => {
 | 
					  userStore.getCurrentUser().catch((err) => {
 | 
				
			||||||
    if (err.response && err.response.status === 401) {
 | 
					    if (err.response && err.response.status === 401) {
 | 
				
			||||||
@@ -90,9 +109,14 @@ const initToaster = () => {
 | 
				
			|||||||
  emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
 | 
					  emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const navLinks = computed(() =>
 | 
					const listenViewRefresh = () => {
 | 
				
			||||||
  allNavLinks.filter((link) =>
 | 
					  emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
 | 
				
			||||||
    !link.permission || (userStore.permissions.includes(link.permission) && link.permission)
 | 
					}
 | 
				
			||||||
  )
 | 
					
 | 
				
			||||||
)
 | 
					const refreshViews = (data) => {
 | 
				
			||||||
 | 
					  openCreateViewForm.value = false
 | 
				
			||||||
 | 
					  if (data?.model === 'view') {
 | 
				
			||||||
 | 
					    getUserViews()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <Toaster />
 | 
					    <Toaster />
 | 
				
			||||||
    <TooltipProvider :delay-duration="250">
 | 
					    <TooltipProvider :delay-duration="200">
 | 
				
			||||||
        <div class="font-inter">
 | 
					        <div class="font-inter">
 | 
				
			||||||
            <RouterView />
 | 
					            <RouterView />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,177 +33,214 @@ http.interceptors.request.use((request) => {
 | 
				
			|||||||
  return request
 | 
					  return request
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const resetPassword = (data) => http.post('/api/users/reset-password', data)
 | 
					const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
 | 
				
			||||||
const setPassword = (data) => http.post('/api/users/set-password', data)
 | 
					const setPassword = (data) => http.post('/api/v1/users/set-password', data)
 | 
				
			||||||
const deleteUser = (id) => http.delete(`/api/users/${id}`)
 | 
					const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
 | 
				
			||||||
const getEmailNotificationSettings = () => http.get('/api/settings/notifications/email')
 | 
					const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
 | 
				
			||||||
const updateEmailNotificationSettings = (data) => http.put('/api/settings/notifications/email', data)
 | 
					const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
 | 
				
			||||||
const getPriorities = () => http.get('/api/priorities')
 | 
					const getPriorities = () => http.get('/api/v1/priorities')
 | 
				
			||||||
const getStatuses = () => http.get('/api/statuses')
 | 
					const getStatuses = () => http.get('/api/v1/statuses')
 | 
				
			||||||
const createStatus = (data) => http.post('/api/statuses', data)
 | 
					const createStatus = (data) => http.post('/api/v1/statuses', data)
 | 
				
			||||||
const updateStatus = (id, data) => http.put(`/api/statuses/${id}`, data)
 | 
					const updateStatus = (id, data) => http.put(`/api/v1/statuses/${id}`, data)
 | 
				
			||||||
const deleteStatus = (id) => http.delete(`/api/statuses/${id}`)
 | 
					const deleteStatus = (id) => http.delete(`/api/v1/statuses/${id}`)
 | 
				
			||||||
const createTag = (data) => http.post('/api/tags', data)
 | 
					const createTag = (data) => http.post('/api/v1/tags', data)
 | 
				
			||||||
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
 | 
					const updateTag = (id, data) => http.put(`/api/v1/tags/${id}`, data)
 | 
				
			||||||
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
 | 
					const deleteTag = (id) => http.delete(`/api/v1/tags/${id}`)
 | 
				
			||||||
const getTemplate = (id) => http.get(`/api/templates/${id}`)
 | 
					const getTemplate = (id) => http.get(`/api/v1/templates/${id}`)
 | 
				
			||||||
const getTemplates = () => http.get('/api/templates')
 | 
					const getTemplates = (type) => http.get('/api/v1/templates', { params: { type: type } })
 | 
				
			||||||
const createTemplate = (data) =>
 | 
					const createTemplate = (data) =>
 | 
				
			||||||
  http.post('/api/templates', data, {
 | 
					  http.post('/api/v1/templates', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const deleteTemplate = (id) => http.delete(`/api/templates/${id}`)
 | 
					const deleteTemplate = (id) => http.delete(`/api/v1/templates/${id}`)
 | 
				
			||||||
const updateTemplate = (id, data) =>
 | 
					const updateTemplate = (id, data) =>
 | 
				
			||||||
  http.put(`/api/templates/${id}`, data, {
 | 
					  http.put(`/api/v1/templates/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getAllBusinessHours = () => http.get('/api/v1/business-hours')
 | 
				
			||||||
 | 
					const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
 | 
				
			||||||
 | 
					const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
 | 
				
			||||||
 | 
					  headers: {
 | 
				
			||||||
 | 
					    'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					const updateBusinessHours = (id, data) =>
 | 
				
			||||||
 | 
					  http.put(`/api/v1/business-hours/${id}`, data, {
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getAllSLAs = () => http.get('/api/v1/sla')
 | 
				
			||||||
 | 
					const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
 | 
				
			||||||
 | 
					const createSLA = (data) => http.post('/api/v1/sla', data)
 | 
				
			||||||
 | 
					const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
 | 
				
			||||||
 | 
					const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const createOIDC = (data) =>
 | 
					const createOIDC = (data) =>
 | 
				
			||||||
  http.post('/api/oidc', data, {
 | 
					  http.post('/api/v1/oidc', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getAllOIDC = () => http.get('/api/oidc')
 | 
					const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
				
			||||||
const getOIDC = (id) => http.get(`/api/oidc/${id}`)
 | 
					const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
				
			||||||
const updateOIDC = (id, data) =>
 | 
					const updateOIDC = (id, data) =>
 | 
				
			||||||
  http.put(`/api/oidc/${id}`, data, {
 | 
					  http.put(`/api/v1/oidc/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const deleteOIDC = (id) => http.delete(`/api/oidc/${id}`)
 | 
					const deleteOIDC = (id) => http.delete(`/api/v1/oidc/${id}`)
 | 
				
			||||||
const updateSettings = (key, data) =>
 | 
					const updateSettings = (key, data) =>
 | 
				
			||||||
  http.put(`/api/settings/${key}`, data, {
 | 
					  http.put(`/api/v1/settings/${key}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getSettings = (key) => http.get(`/api/settings/${key}`)
 | 
					const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
 | 
				
			||||||
const login = (data) => http.post(`/api/login`, data)
 | 
					const login = (data) => http.post(`/api/v1/login`, data)
 | 
				
			||||||
const getAutomationRules = (type) =>
 | 
					const getAutomationRules = (type) =>
 | 
				
			||||||
  http.get(`/api/automation/rules`, {
 | 
					  http.get(`/api/v1/automation/rules`, {
 | 
				
			||||||
    params: { type: type }
 | 
					    params: { type: type }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const toggleAutomationRule = (id) => http.put(`/api/automation/rules/${id}/toggle`)
 | 
					const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
 | 
				
			||||||
const getAutomationRule = (id) => http.get(`/api/automation/rules/${id}`)
 | 
					const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
 | 
				
			||||||
const updateAutomationRule = (id, data) =>
 | 
					const updateAutomationRule = (id, data) =>
 | 
				
			||||||
  http.put(`/api/automation/rules/${id}`, data, {
 | 
					  http.put(`/api/v1/automation/rules/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const createAutomationRule = (data) =>
 | 
					const createAutomationRule = (data) =>
 | 
				
			||||||
  http.post(`/api/automation/rules`, data, {
 | 
					  http.post(`/api/v1/automation/rules`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getRoles = () => http.get('/api/roles')
 | 
					const getRoles = () => http.get('/api/v1/roles')
 | 
				
			||||||
const getRole = (id) => http.get(`/api/roles/${id}`)
 | 
					const getRole = (id) => http.get(`/api/v1/roles/${id}`)
 | 
				
			||||||
const createRole = (data) =>
 | 
					const createRole = (data) =>
 | 
				
			||||||
  http.post('/api/roles', data, {
 | 
					  http.post('/api/v1/roles', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const updateRole = (id, data) =>
 | 
					const updateRole = (id, data) =>
 | 
				
			||||||
  http.put(`/api/roles/${id}`, data, {
 | 
					  http.put(`/api/v1/roles/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const deleteRole = (id) => http.delete(`/api/roles/${id}`)
 | 
					const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
 | 
				
			||||||
const deleteAutomationRule = (id) => http.delete(`/api/automation/rules/${id}`)
 | 
					const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
 | 
				
			||||||
const getUser = (id) => http.get(`/api/users/${id}`)
 | 
					const getUser = (id) => http.get(`/api/v1/users/${id}`)
 | 
				
			||||||
const getTeam = (id) => http.get(`/api/teams/${id}`)
 | 
					
 | 
				
			||||||
const getTeams = () => http.get('/api/teams')
 | 
					const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
 | 
				
			||||||
const getTeamsCompact = () => http.get('/api/teams/compact')
 | 
					const getTeams = () => http.get('/api/v1/teams')
 | 
				
			||||||
const getUsers = () => http.get('/api/users')
 | 
					const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
 | 
				
			||||||
const getUsersCompact = () => http.get('/api/users/compact')
 | 
					const createTeam = (data) => http.post('/api/v1/teams', data)
 | 
				
			||||||
 | 
					const getTeamsCompact = () => http.get('/api/v1/teams/compact')
 | 
				
			||||||
 | 
					const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getUsers = () => http.get('/api/v1/users')
 | 
				
			||||||
 | 
					const getUsersCompact = () => http.get('/api/v1/users/compact')
 | 
				
			||||||
const updateCurrentUser = (data) =>
 | 
					const updateCurrentUser = (data) =>
 | 
				
			||||||
  http.put('/api/users/me', data, {
 | 
					  http.put('/api/v1/users/me', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'multipart/form-data'
 | 
					      'Content-Type': 'multipart/form-data'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const deleteUserAvatar = () => http.delete('/api/users/me/avatar')
 | 
					const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
 | 
				
			||||||
const getCurrentUser = () => http.get('/api/users/me')
 | 
					const getCurrentUser = () => http.get('/api/v1/users/me')
 | 
				
			||||||
const getTags = () => http.get('/api/tags')
 | 
					const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
 | 
				
			||||||
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
 | 
					const getTags = () => http.get('/api/v1/tags')
 | 
				
			||||||
 | 
					const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
 | 
				
			||||||
const updateAssignee = (uuid, assignee_type, data) =>
 | 
					const updateAssignee = (uuid, assignee_type, data) =>
 | 
				
			||||||
  http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
 | 
					  http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
 | 
				
			||||||
const updateConversationStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
 | 
					const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
 | 
				
			||||||
const updateConversationPriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
 | 
					const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
 | 
				
			||||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
 | 
					const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
				
			||||||
const getConversationMessage = (cuuid, uuid) => http.get(`/api/conversations/${cuuid}/messages/${uuid}`)
 | 
					const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
 | 
				
			||||||
const retryMessage = (cuuid, uuid) => http.put(`/api/conversations/${cuuid}/messages/${uuid}/retry`)
 | 
					const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
 | 
				
			||||||
const getConversationMessages = (uuid, page) =>
 | 
					const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
 | 
				
			||||||
  http.get(`/api/conversations/${uuid}/messages`, {
 | 
					 | 
				
			||||||
    params: { page: page }
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
const sendMessage = (uuid, data) =>
 | 
					const sendMessage = (uuid, data) =>
 | 
				
			||||||
  http.post(`/api/conversations/${uuid}/messages`, data, {
 | 
					  http.post(`/api/v1/conversations/${uuid}/messages`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
 | 
					const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
 | 
				
			||||||
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
 | 
					const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
 | 
				
			||||||
const getCannedResponses = () => http.get('/api/canned-responses')
 | 
					const getCannedResponses = () => http.get('/api/v1/canned-responses')
 | 
				
			||||||
const createCannedResponse = (data) => http.post('/api/canned-responses', data)
 | 
					const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
 | 
				
			||||||
const updateCannedResponse = (id, data) => http.put(`/api/canned-responses/${id}`, data)
 | 
					const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
 | 
				
			||||||
const deleteCannedResponse = (id) => http.delete(`/api/canned-responses/${id}`)
 | 
					const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
 | 
				
			||||||
const getAssignedConversations = (params) =>
 | 
					const getTeamUnassignedConversations = (teamID, params) =>
 | 
				
			||||||
  http.get('/api/conversations/assigned', { params })
 | 
					  http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
 | 
				
			||||||
const getUnassignedConversations = (params) =>
 | 
					const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
 | 
				
			||||||
  http.get('/api/conversations/unassigned', { params })
 | 
					const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
 | 
				
			||||||
const getAllConversations = (params) =>
 | 
					const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
 | 
				
			||||||
  http.get('/api/conversations/all', { params })
 | 
					const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
 | 
				
			||||||
const uploadMedia = (data) =>
 | 
					const uploadMedia = (data) =>
 | 
				
			||||||
  http.post('/api/media', data, {
 | 
					  http.post('/api/v1/media', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'multipart/form-data'
 | 
					      'Content-Type': 'multipart/form-data'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts')
 | 
					const getGlobalDashboardCounts = () => http.get('/api/v1/dashboard/global/counts')
 | 
				
			||||||
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts')
 | 
					const getGlobalDashboardCharts = () => http.get('/api/v1/dashboard/global/charts')
 | 
				
			||||||
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`)
 | 
					const getUserDashboardCounts = () => http.get(`/api/v1/dashboard/me/counts`)
 | 
				
			||||||
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`)
 | 
					const getUserDashboardCharts = () => http.get(`/api/v1/dashboard/me/charts`)
 | 
				
			||||||
const getLanguage = (lang) => http.get(`/api/lang/${lang}`)
 | 
					const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
 | 
				
			||||||
const createUser = (data) =>
 | 
					const createUser = (data) =>
 | 
				
			||||||
  http.post('/api/users', data, {
 | 
					  http.post('/api/v1/users', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const updateUser = (id, data) =>
 | 
					const updateUser = (id, data) =>
 | 
				
			||||||
  http.put(`/api/users/${id}`, data, {
 | 
					  http.put(`/api/v1/users/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const updateTeam = (id, data) => http.put(`/api/teams/${id}`, data)
 | 
					 | 
				
			||||||
const createTeam = (data) => http.post('/api/teams', data)
 | 
					 | 
				
			||||||
const createInbox = (data) =>
 | 
					const createInbox = (data) =>
 | 
				
			||||||
  http.post('/api/inboxes', data, {
 | 
					  http.post('/api/v1/inboxes', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getInboxes = () => http.get('/api/inboxes')
 | 
					const getInboxes = () => http.get('/api/v1/inboxes')
 | 
				
			||||||
const getInbox = (id) => http.get(`/api/inboxes/${id}`)
 | 
					const getInbox = (id) => http.get(`/api/v1/inboxes/${id}`)
 | 
				
			||||||
const toggleInbox = (id) => http.put(`/api/inboxes/${id}/toggle`)
 | 
					const toggleInbox = (id) => http.put(`/api/v1/inboxes/${id}/toggle`)
 | 
				
			||||||
const updateInbox = (id, data) =>
 | 
					const updateInbox = (id, data) =>
 | 
				
			||||||
  http.put(`/api/inboxes/${id}`, data, {
 | 
					  http.put(`/api/v1/inboxes/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const deleteInbox = (id) => http.delete(`/api/inboxes/${id}`)
 | 
					const deleteInbox = (id) => http.delete(`/api/v1/inboxes/${id}`)
 | 
				
			||||||
 | 
					const getCurrentUserViews = () => http.get('/api/v1/views/me')
 | 
				
			||||||
 | 
					const createView = (data) =>
 | 
				
			||||||
 | 
					  http.post('/api/v1/views/me', data, {
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					const updateView = (id, data) =>
 | 
				
			||||||
 | 
					  http.put(`/api/v1/views/me/${id}`, data, {
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
  login,
 | 
					  login,
 | 
				
			||||||
@@ -219,6 +256,7 @@ export default {
 | 
				
			|||||||
  deleteRole,
 | 
					  deleteRole,
 | 
				
			||||||
  updateRole,
 | 
					  updateRole,
 | 
				
			||||||
  getTeams,
 | 
					  getTeams,
 | 
				
			||||||
 | 
					  deleteTeam,
 | 
				
			||||||
  getUsers,
 | 
					  getUsers,
 | 
				
			||||||
  getInbox,
 | 
					  getInbox,
 | 
				
			||||||
  getInboxes,
 | 
					  getInboxes,
 | 
				
			||||||
@@ -226,9 +264,21 @@ export default {
 | 
				
			|||||||
  getConversation,
 | 
					  getConversation,
 | 
				
			||||||
  getAutomationRule,
 | 
					  getAutomationRule,
 | 
				
			||||||
  getAutomationRules,
 | 
					  getAutomationRules,
 | 
				
			||||||
 | 
					  getAllBusinessHours,
 | 
				
			||||||
 | 
					  getBusinessHours,
 | 
				
			||||||
 | 
					  createBusinessHours,
 | 
				
			||||||
 | 
					  updateBusinessHours,
 | 
				
			||||||
 | 
					  deleteBusinessHours,
 | 
				
			||||||
 | 
					  getAllSLAs,
 | 
				
			||||||
 | 
					  getSLA,
 | 
				
			||||||
 | 
					  createSLA,
 | 
				
			||||||
 | 
					  updateSLA,
 | 
				
			||||||
 | 
					  deleteSLA,
 | 
				
			||||||
  getAssignedConversations,
 | 
					  getAssignedConversations,
 | 
				
			||||||
  getUnassignedConversations,
 | 
					  getUnassignedConversations,
 | 
				
			||||||
  getAllConversations,
 | 
					  getAllConversations,
 | 
				
			||||||
 | 
					  getTeamUnassignedConversations,
 | 
				
			||||||
 | 
					  getViewConversations,
 | 
				
			||||||
  getGlobalDashboardCharts,
 | 
					  getGlobalDashboardCharts,
 | 
				
			||||||
  getGlobalDashboardCounts,
 | 
					  getGlobalDashboardCounts,
 | 
				
			||||||
  getUserDashboardCounts,
 | 
					  getUserDashboardCounts,
 | 
				
			||||||
@@ -237,6 +287,7 @@ export default {
 | 
				
			|||||||
  getConversationMessage,
 | 
					  getConversationMessage,
 | 
				
			||||||
  getConversationMessages,
 | 
					  getConversationMessages,
 | 
				
			||||||
  getCurrentUser,
 | 
					  getCurrentUser,
 | 
				
			||||||
 | 
					  getCurrentUserTeams,
 | 
				
			||||||
  getCannedResponses,
 | 
					  getCannedResponses,
 | 
				
			||||||
  createCannedResponse,
 | 
					  createCannedResponse,
 | 
				
			||||||
  updateCannedResponse,
 | 
					  updateCannedResponse,
 | 
				
			||||||
@@ -287,4 +338,8 @@ export default {
 | 
				
			|||||||
  getUsersCompact,
 | 
					  getUsersCompact,
 | 
				
			||||||
  getEmailNotificationSettings,
 | 
					  getEmailNotificationSettings,
 | 
				
			||||||
  updateEmailNotificationSettings,
 | 
					  updateEmailNotificationSettings,
 | 
				
			||||||
 | 
					  getCurrentUserViews,
 | 
				
			||||||
 | 
					  createView,
 | 
				
			||||||
 | 
					  updateView,
 | 
				
			||||||
 | 
					  deleteView
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,12 +5,10 @@
 | 
				
			|||||||
// App default font-size.
 | 
					// App default font-size.
 | 
				
			||||||
// Default: 16px, 15px looks wide.
 | 
					// Default: 16px, 15px looks wide.
 | 
				
			||||||
:root {
 | 
					:root {
 | 
				
			||||||
  font-size: 14px;
 | 
					  font-size: 16px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
body {
 | 
					body {
 | 
				
			||||||
  -webkit-font-smoothing: antialiased;
 | 
					 | 
				
			||||||
  -moz-osx-font-smoothing: grayscale;
 | 
					 | 
				
			||||||
  overflow-x: hidden;
 | 
					  overflow-x: hidden;
 | 
				
			||||||
  overflow-y: hidden;
 | 
					  overflow-y: hidden;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -176,27 +174,28 @@ body {
 | 
				
			|||||||
  @apply p-0;
 | 
					  @apply p-0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Scrollbar
 | 
				
			||||||
::-webkit-scrollbar {
 | 
					::-webkit-scrollbar {
 | 
				
			||||||
  width: 10px;
 | 
					  width: 8px; /* Adjust width */
 | 
				
			||||||
}
 | 
					  height: 8px; /* Adjust height */
 | 
				
			||||||
 | 
					 | 
				
			||||||
::-webkit-scrollbar-track {
 | 
					 | 
				
			||||||
  background: #f1f1f1;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
::-webkit-scrollbar-thumb {
 | 
					::-webkit-scrollbar-thumb {
 | 
				
			||||||
  background-color: #888;
 | 
					  background-color: #888;
 | 
				
			||||||
  border-radius: 4px;
 | 
					  border-radius: 10px;
 | 
				
			||||||
  border: 2px solid transparent;
 | 
					  border: 2px solid transparent;
 | 
				
			||||||
 | 
					  background-clip: content-box;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
::-webkit-scrollbar-thumb:hover {
 | 
					::-webkit-scrollbar-thumb:hover {
 | 
				
			||||||
  background-color: #555;
 | 
					  background-color: #555; /* Hover effect */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* {
 | 
					::-webkit-scrollbar-track {
 | 
				
			||||||
  scrollbar-width: thin;
 | 
					  background: #f0f0f0;
 | 
				
			||||||
 | 
					  border-radius: 10px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					// End Scrollbar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.code-editor {
 | 
					.code-editor {
 | 
				
			||||||
  @apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
 | 
					  @apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
 | 
				
			||||||
@@ -212,3 +211,46 @@ body {
 | 
				
			|||||||
.ql-toolbar {
 | 
					.ql-toolbar {
 | 
				
			||||||
  @apply rounded-t-lg;
 | 
					  @apply rounded-t-lg;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@layer base {
 | 
				
			||||||
 | 
					  :root {
 | 
				
			||||||
 | 
					    --sidebar-background: 0 0% 98%;
 | 
				
			||||||
 | 
					    --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%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										157
									
								
								frontend/src/components/ViewForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								frontend/src/components/ViewForm.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,157 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <Dialog :open="openDialog" @update:open="openDialog = false">
 | 
				
			||||||
 | 
					        <DialogContent>
 | 
				
			||||||
 | 
					            <DialogHeader>
 | 
				
			||||||
 | 
					                <DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
 | 
				
			||||||
 | 
					                <DialogDescription>Views let you create custom filters and save them for reuse.</DialogDescription>
 | 
				
			||||||
 | 
					            </DialogHeader>
 | 
				
			||||||
 | 
					            <form @submit.prevent="onSubmit">
 | 
				
			||||||
 | 
					                <div class="grid gap-4 py-4">
 | 
				
			||||||
 | 
					                    <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
 | 
					                        <FormItem>
 | 
				
			||||||
 | 
					                            <FormLabel>Name</FormLabel>
 | 
				
			||||||
 | 
					                            <FormControl>
 | 
				
			||||||
 | 
					                                <Input id="name" class="col-span-3" placeholder="Enter view name"
 | 
				
			||||||
 | 
					                                    v-bind="componentField" />
 | 
				
			||||||
 | 
					                            </FormControl>
 | 
				
			||||||
 | 
					                            <FormDescription>Enter a unique name for your view</FormDescription>
 | 
				
			||||||
 | 
					                            <FormMessage />
 | 
				
			||||||
 | 
					                        </FormItem>
 | 
				
			||||||
 | 
					                    </FormField>
 | 
				
			||||||
 | 
					                    <FormField v-slot="{ componentField }" name="inbox_type">
 | 
				
			||||||
 | 
					                        <FormItem>
 | 
				
			||||||
 | 
					                            <FormLabel>Inbox</FormLabel>
 | 
				
			||||||
 | 
					                            <FormControl>
 | 
				
			||||||
 | 
					                                <Select class="w-full" v-bind="componentField">
 | 
				
			||||||
 | 
					                                    <SelectTrigger>
 | 
				
			||||||
 | 
					                                        <SelectValue placeholder="Select inbox" />
 | 
				
			||||||
 | 
					                                    </SelectTrigger>
 | 
				
			||||||
 | 
					                                    <SelectContent>
 | 
				
			||||||
 | 
					                                        <SelectGroup>
 | 
				
			||||||
 | 
					                                            <SelectItem v-for="(value, key) in CONVERSATION_VIEWS_INBOXES" :key="key"
 | 
				
			||||||
 | 
					                                                :value="key">
 | 
				
			||||||
 | 
					                                                {{ value }}
 | 
				
			||||||
 | 
					                                            </SelectItem>
 | 
				
			||||||
 | 
					                                        </SelectGroup>
 | 
				
			||||||
 | 
					                                    </SelectContent>
 | 
				
			||||||
 | 
					                                </Select>
 | 
				
			||||||
 | 
					                            </FormControl>
 | 
				
			||||||
 | 
					                            <FormDescription>Select inbox to filter conversations</FormDescription>
 | 
				
			||||||
 | 
					                            <FormMessage />
 | 
				
			||||||
 | 
					                        </FormItem>
 | 
				
			||||||
 | 
					                    </FormField>
 | 
				
			||||||
 | 
					                    <FormField v-slot="{ componentField }" name="filters">
 | 
				
			||||||
 | 
					                        <FormItem>
 | 
				
			||||||
 | 
					                            <FormLabel>Filters</FormLabel>
 | 
				
			||||||
 | 
					                            <FormControl>
 | 
				
			||||||
 | 
					                                <Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
 | 
				
			||||||
 | 
					                            </FormControl>
 | 
				
			||||||
 | 
					                            <FormDescription>Add filters to customize view</FormDescription>
 | 
				
			||||||
 | 
					                            <FormMessage />
 | 
				
			||||||
 | 
					                        </FormItem>
 | 
				
			||||||
 | 
					                    </FormField>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <DialogFooter>
 | 
				
			||||||
 | 
					                    <Button type="submit" :disabled="isSubmitting" :isLoading="isSubmitting">
 | 
				
			||||||
 | 
					                        {{ isSubmitting ? 'Saving...' : 'Save changes' }}
 | 
				
			||||||
 | 
					                    </Button>
 | 
				
			||||||
 | 
					                </DialogFooter>
 | 
				
			||||||
 | 
					            </form>
 | 
				
			||||||
 | 
					        </DialogContent>
 | 
				
			||||||
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { defineModel, ref, onMounted, watch } from 'vue'
 | 
				
			||||||
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
 | 
					import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
 | 
				
			||||||
 | 
					import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
				
			||||||
 | 
					import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation'
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
 | 
					import Filter from '@/components/common/Filter.vue'
 | 
				
			||||||
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import { z } from 'zod'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const openDialog = defineModel('openDialog', { required: false, default: false })
 | 
				
			||||||
 | 
					const view = defineModel('view', { required: false, default: {} })
 | 
				
			||||||
 | 
					const isSubmitting = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const {
 | 
				
			||||||
 | 
					    conversationsListFilters,
 | 
				
			||||||
 | 
					    initConversationListFilters
 | 
				
			||||||
 | 
					} = useConversationFilters()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const filterFields = ref([])
 | 
				
			||||||
 | 
					const initFields = async () => {
 | 
				
			||||||
 | 
					    await initConversationListFilters()
 | 
				
			||||||
 | 
					    filterFields.value = Object.entries(conversationsListFilters.value).map(([field, value]) => ({
 | 
				
			||||||
 | 
					        model: 'conversations',
 | 
				
			||||||
 | 
					        label: value.label,
 | 
				
			||||||
 | 
					        field,
 | 
				
			||||||
 | 
					        type: value.type,
 | 
				
			||||||
 | 
					        operators: value.operators,
 | 
				
			||||||
 | 
					        options: value.options?.map(option => ({
 | 
				
			||||||
 | 
					            ...option,
 | 
				
			||||||
 | 
					            value: String(option.value)
 | 
				
			||||||
 | 
					        })) ?? []
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(initFields)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formSchema = toTypedSchema(z.object({
 | 
				
			||||||
 | 
					    id: z.number().optional(),
 | 
				
			||||||
 | 
					    name: z.string()
 | 
				
			||||||
 | 
					        .min(2, { message: "Name must be at least 2 characters." })
 | 
				
			||||||
 | 
					        .max(250, { message: "Name cannot exceed 250 characters." }),
 | 
				
			||||||
 | 
					    inbox_type: z.enum(Object.keys(CONVERSATION_VIEWS_INBOXES)),
 | 
				
			||||||
 | 
					    filters: z.array(
 | 
				
			||||||
 | 
					        z.object({
 | 
				
			||||||
 | 
					            model: z.string({ required_error: "Model required" }),
 | 
				
			||||||
 | 
					            field: z.string({ required_error: "Field required" }),
 | 
				
			||||||
 | 
					            operator: z.string({ required_error: "Operator required" }),
 | 
				
			||||||
 | 
					            value: z.union([z.string(), z.number(), z.boolean()])
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    ).default([])
 | 
				
			||||||
 | 
					}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const form = useForm({ validationSchema: formSchema })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			||||||
 | 
					    if (isSubmitting.value) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isSubmitting.value = true
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        if (values.id) {
 | 
				
			||||||
 | 
					            await api.updateView(values.id, values)
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            await api.createView(values)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
 | 
				
			||||||
 | 
					        openDialog.value = false
 | 
				
			||||||
 | 
					        form.resetForm()
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					            title: 'Error',
 | 
				
			||||||
 | 
					            variant: 'destructive',
 | 
				
			||||||
 | 
					            description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					        isSubmitting.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watch(() => view.value, (newVal) => {
 | 
				
			||||||
 | 
					    if (newVal && Object.keys(newVal).length) {
 | 
				
			||||||
 | 
					        form.setValues(newVal)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}, { immediate: true })
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -15,9 +15,6 @@ const sidebarNavItems = [
 | 
				
			|||||||
  <div class="space-y-4 md:block page-content">
 | 
					  <div class="space-y-4 md:block page-content">
 | 
				
			||||||
    <PageHeader title="Account settings" subTitle="Manage your account settings." />
 | 
					    <PageHeader title="Account settings" subTitle="Manage your account settings." />
 | 
				
			||||||
    <div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
 | 
					    <div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
 | 
				
			||||||
      <aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
 | 
					 | 
				
			||||||
        <SidebarNav :navItems="sidebarNavItems" />
 | 
					 | 
				
			||||||
      </aside>
 | 
					 | 
				
			||||||
      <div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
 | 
					      <div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
 | 
				
			||||||
        <div class="space-y-6">
 | 
					        <div class="space-y-6">
 | 
				
			||||||
          <slot></slot>
 | 
					          <slot></slot>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,8 +14,8 @@
 | 
				
			|||||||
        <div class="flex flex-col space-y-5 justify-center">
 | 
					        <div class="flex flex-col space-y-5 justify-center">
 | 
				
			||||||
          <input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
 | 
					          <input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
 | 
				
			||||||
            @change="selectFile" />
 | 
					            @change="selectFile" />
 | 
				
			||||||
          <Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button>
 | 
					          <Button class="w-28" @click="selectAvatar"> Choose a file... </Button>
 | 
				
			||||||
          <Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">Remove
 | 
					          <Button class="w-28" @click="removeAvatar" variant="destructive">Remove
 | 
				
			||||||
            avatar</Button>
 | 
					            avatar</Button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,79 +1,5 @@
 | 
				
			|||||||
<script setup>
 | 
					 | 
				
			||||||
import { computed } from 'vue'
 | 
					 | 
				
			||||||
import PageHeader from '@/components/common/PageHeader.vue'
 | 
					 | 
				
			||||||
import SidebarNav from '@/components/common/SidebarNav.vue'
 | 
					 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const userStore = useUserStore()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const allNavItems = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'General',
 | 
					 | 
				
			||||||
    href: '/admin/general',
 | 
					 | 
				
			||||||
    description: 'Configure general app settings',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Conversations',
 | 
					 | 
				
			||||||
    href: '/admin/conversations',
 | 
					 | 
				
			||||||
    description: 'Manage tags, canned responses and statuses.',
 | 
					 | 
				
			||||||
    permission: null
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Inboxes',
 | 
					 | 
				
			||||||
    href: '/admin/inboxes',
 | 
					 | 
				
			||||||
    description: 'Manage your inboxes',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Teams',
 | 
					 | 
				
			||||||
    href: '/admin/teams',
 | 
					 | 
				
			||||||
    description: 'Manage teams, manage agents and roles',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Automations',
 | 
					 | 
				
			||||||
    href: '/admin/automations',
 | 
					 | 
				
			||||||
    description: 'Manage automations and time triggers',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Notification',
 | 
					 | 
				
			||||||
    href: '/admin/notification',
 | 
					 | 
				
			||||||
    description: 'Manage email notification settings',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Email templates',
 | 
					 | 
				
			||||||
    href: '/admin/templates',
 | 
					 | 
				
			||||||
    description: 'Manage outgoing email templates',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'OpenID Connect SSO',
 | 
					 | 
				
			||||||
    href: '/admin/oidc',
 | 
					 | 
				
			||||||
    description: 'Manage OpenID SSO configurations',
 | 
					 | 
				
			||||||
    permission: null,
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const sidebarNavItems = computed(() =>
 | 
					 | 
				
			||||||
  allNavItems.filter((item) => !item.permission || item.permission && userStore.permissions.includes(item.permission))
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="space-y-4 md:block overflow-y-auto">
 | 
					  <div class="overflow-y-auto ">
 | 
				
			||||||
    <PageHeader title="Admin settings" subTitle="Manage your helpdesk settings." />
 | 
					 | 
				
			||||||
    <div class="flex flex-col space-y-8 lg:flex-row lg:space-x-10 lg:space-y-5">
 | 
					 | 
				
			||||||
      <aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
 | 
					 | 
				
			||||||
        <SidebarNav :navItems="sidebarNavItems" />
 | 
					 | 
				
			||||||
      </aside>
 | 
					 | 
				
			||||||
      <div class="flex-1 lg:max-w-5xl admin-main-content min-h-[700px]">
 | 
					 | 
				
			||||||
        <div class="space-y-6">
 | 
					 | 
				
			||||||
    <slot></slot>
 | 
					    <slot></slot>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,24 +1,19 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="box rounded-lg">
 | 
					  <div class="w-full">
 | 
				
			||||||
 | 
					    <div class="rounded-md 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">
 | 
				
			||||||
          <TableHead v-for="header in headerGroup.headers" :key="header.id">
 | 
					            <TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
 | 
				
			||||||
            <FlexRender
 | 
					              <FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
 | 
				
			||||||
              v-if="!header.isPlaceholder"
 | 
					                :props="header.getContext()" />
 | 
				
			||||||
              :render="header.column.columnDef.header"
 | 
					 | 
				
			||||||
              :props="header.getContext()"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            </TableHead>
 | 
					            </TableHead>
 | 
				
			||||||
          </TableRow>
 | 
					          </TableRow>
 | 
				
			||||||
        </TableHeader>
 | 
					        </TableHeader>
 | 
				
			||||||
        <TableBody>
 | 
					        <TableBody>
 | 
				
			||||||
          <template v-if="table.getRowModel().rows?.length">
 | 
					          <template v-if="table.getRowModel().rows?.length">
 | 
				
			||||||
          <TableRow
 | 
					            <TableRow v-for="row in table.getRowModel().rows" :key="row.id"
 | 
				
			||||||
            v-for="row in table.getRowModel().rows"
 | 
					              :data-state="row.getIsSelected() ? 'selected' : undefined" class="hover:bg-muted/50">
 | 
				
			||||||
            :key="row.id"
 | 
					 | 
				
			||||||
            :data-state="row.getIsSelected() ? 'selected' : undefined"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
              <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
 | 
					              <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
 | 
				
			||||||
                <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
 | 
					                <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
 | 
				
			||||||
              </TableCell>
 | 
					              </TableCell>
 | 
				
			||||||
@@ -26,14 +21,18 @@
 | 
				
			|||||||
          </template>
 | 
					          </template>
 | 
				
			||||||
          <template v-else>
 | 
					          <template v-else>
 | 
				
			||||||
            <TableRow>
 | 
					            <TableRow>
 | 
				
			||||||
            <TableCell :colspan="columns.length" class="h-24 text-center"> No results. </TableCell>
 | 
					              <TableCell :colspan="columns.length" class="h-24 text-center">
 | 
				
			||||||
 | 
					                <div class="text-muted-foreground">{{ emptyText }}</div>
 | 
				
			||||||
 | 
					              </TableCell>
 | 
				
			||||||
            </TableRow>
 | 
					            </TableRow>
 | 
				
			||||||
          </template>
 | 
					          </template>
 | 
				
			||||||
        </TableBody>
 | 
					        </TableBody>
 | 
				
			||||||
      </Table>
 | 
					      </Table>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
 | 
					import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,14 +47,18 @@ import {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  columns: Array,
 | 
					  columns: Array,
 | 
				
			||||||
  data: Array
 | 
					  data: Array,
 | 
				
			||||||
 | 
					  emptyText: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: 'No results.'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const table = useVueTable({
 | 
					const table = useVueTable({
 | 
				
			||||||
  get data() {
 | 
					  get data () {
 | 
				
			||||||
    return props.data
 | 
					    return props.data
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  get columns() {
 | 
					  get columns () {
 | 
				
			||||||
    return props.columns
 | 
					    return props.columns
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  getCoreRowModel: getCoreRowModel()
 | 
					  getCoreRowModel: getCoreRowModel()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,7 +49,7 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <Button variant="outline" @click.prevent="addAction" size="sm">Add action</Button>
 | 
					      <Button variant="outline" @click.prevent="addAction">Add action</Button>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -83,6 +83,7 @@ const props = defineProps({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const { actions } = toRefs(props)
 | 
					const { actions } = toRefs(props)
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const slas = ref([])
 | 
				
			||||||
const teams = ref([])
 | 
					const teams = ref([])
 | 
				
			||||||
const users = ref([])
 | 
					const users = ref([])
 | 
				
			||||||
const statuses = ref([])
 | 
					const statuses = ref([])
 | 
				
			||||||
@@ -91,7 +92,8 @@ const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
onMounted(async () => {
 | 
					onMounted(async () => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const [teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
 | 
					    const [slasResp, teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
 | 
				
			||||||
 | 
					      api.getAllSLAs(),
 | 
				
			||||||
      api.getTeamsCompact(),
 | 
					      api.getTeamsCompact(),
 | 
				
			||||||
      api.getUsersCompact(),
 | 
					      api.getUsersCompact(),
 | 
				
			||||||
      api.getStatuses(),
 | 
					      api.getStatuses(),
 | 
				
			||||||
@@ -117,9 +119,14 @@ onMounted(async () => {
 | 
				
			|||||||
      value: priority.name,
 | 
					      value: priority.name,
 | 
				
			||||||
      name: priority.name
 | 
					      name: priority.name
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    slas.value = slasResp.data.data.map(sla => ({
 | 
				
			||||||
 | 
					      value: sla.id,
 | 
				
			||||||
 | 
					      name: sla.name
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Something went wrong',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -173,6 +180,10 @@ const conversationActions = {
 | 
				
			|||||||
  reply: {
 | 
					  reply: {
 | 
				
			||||||
    label: 'Send reply',
 | 
					    label: 'Send reply',
 | 
				
			||||||
    inputType: 'richtext',
 | 
					    inputType: 'richtext',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  set_sla: {
 | 
				
			||||||
 | 
					    label: 'Set SLA',
 | 
				
			||||||
 | 
					    inputType: 'select',
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -181,6 +192,7 @@ const actionDropdownValues = {
 | 
				
			|||||||
  assign_user: users,
 | 
					  assign_user: users,
 | 
				
			||||||
  set_status: statuses,
 | 
					  set_status: statuses,
 | 
				
			||||||
  set_priority: priorities,
 | 
					  set_priority: priorities,
 | 
				
			||||||
 | 
					  set_sla: slas,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getDropdownValues = (field) => {
 | 
					const getDropdownValues = (field) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,25 +1,29 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
 | 
					  <PageHeader title="Automation" description="Manage automation rules" />
 | 
				
			||||||
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <div v-if="router.currentRoute.value.path === '/admin/automations'">
 | 
				
			||||||
      <div class="flex justify-between mb-5">
 | 
					      <div class="flex justify-between mb-5">
 | 
				
			||||||
    <PageHeader title="Automations" description="Manage automations and time triggers" />
 | 
					        <div class="ml-auto">
 | 
				
			||||||
    <div>
 | 
					          <Button @click="newRule">New rule</Button>
 | 
				
			||||||
      <Button size="sm" @click="newRule">New rule</Button>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <AutomationTabs v-model="selectedTab" />
 | 
					        <AutomationTabs v-model="selectedTab" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <router-view />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref } from 'vue'
 | 
					 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
 | 
					 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
 | 
				
			||||||
import PageHeader from '../common/PageHeader.vue'
 | 
					import PageHeader from '../common/PageHeader.vue'
 | 
				
			||||||
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const selectedTab = ref('new_conversation')
 | 
					const selectedTab = useStorage('automationsTab', 'new_conversation')
 | 
				
			||||||
 | 
					 | 
				
			||||||
const newRule = () => {
 | 
					const newRule = () => {
 | 
				
			||||||
  router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
 | 
					  router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,9 @@
 | 
				
			|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
					import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
				
			||||||
import RuleTab from './RuleTab.vue'
 | 
					import RuleTab from './RuleTab.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const selectedTab = defineModel('selectedTab', {
 | 
					const selectedTab = defineModel('automationsTab', {
 | 
				
			||||||
  default: 'new_conversation'
 | 
					  default: 'new_conversation',
 | 
				
			||||||
 | 
					  type: String,
 | 
				
			||||||
 | 
					  required: true
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -3,7 +3,8 @@
 | 
				
			|||||||
    <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
					    <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
  <Spinner v-if="isLoading"></Spinner>
 | 
					  <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
  <span>{{ formTitle }}</span>
 | 
					  <div class="space-y-4">
 | 
				
			||||||
 | 
					    <p>{{ formTitle }}</p>
 | 
				
			||||||
    <div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
 | 
					    <div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
 | 
				
			||||||
      <form @submit="onSubmit">
 | 
					      <form @submit="onSubmit">
 | 
				
			||||||
        <div class="space-y-5">
 | 
					        <div class="space-y-5">
 | 
				
			||||||
@@ -53,16 +54,19 @@
 | 
				
			|||||||
              </FormItem>
 | 
					              </FormItem>
 | 
				
			||||||
            </FormField>
 | 
					            </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <FormField v-slot="{ componentField }" name="events" v-if="form.values.type === 'conversation_update'">
 | 
					            <div :class="{ 'hidden': form.values.type !== 'conversation_update' }">
 | 
				
			||||||
 | 
					              <FormField v-slot="{ componentField }" name="events">
 | 
				
			||||||
                <FormItem>
 | 
					                <FormItem>
 | 
				
			||||||
                  <FormLabel>Events</FormLabel>
 | 
					                  <FormLabel>Events</FormLabel>
 | 
				
			||||||
                  <FormControl>
 | 
					                  <FormControl>
 | 
				
			||||||
                <SelectTag v-bind="componentField" :items="conversationEvents" placeholder="Select events"></SelectTag>
 | 
					                    <SelectTag v-bind="componentField" :items="conversationEvents || []" placeholder="Select events">
 | 
				
			||||||
 | 
					                    </SelectTag>
 | 
				
			||||||
                  </FormControl>
 | 
					                  </FormControl>
 | 
				
			||||||
                  <FormDescription>Evaluate rule on these events.</FormDescription>
 | 
					                  <FormDescription>Evaluate rule on these events.</FormDescription>
 | 
				
			||||||
                  <FormMessage></FormMessage>
 | 
					                  <FormMessage></FormMessage>
 | 
				
			||||||
                </FormItem>
 | 
					                </FormItem>
 | 
				
			||||||
              </FormField>
 | 
					              </FormField>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -90,10 +94,11 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
          <ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
 | 
					          <ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
 | 
				
			||||||
            @remove-action="handleRemoveAction" />
 | 
					            @remove-action="handleRemoveAction" />
 | 
				
			||||||
        <Button type="submit" :isLoading="isLoading" size="sm">Save</Button>
 | 
					          <Button type="submit" :isLoading="isLoading">Save</Button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </form>
 | 
					      </form>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -322,6 +327,9 @@ onMounted(async () => {
 | 
				
			|||||||
      isLoading.value = true
 | 
					      isLoading.value = true
 | 
				
			||||||
      let resp = await api.getAutomationRule(props.id)
 | 
					      let resp = await api.getAutomationRule(props.id)
 | 
				
			||||||
      rule.value = resp.data.data
 | 
					      rule.value = resp.data.data
 | 
				
			||||||
 | 
					      if (resp.data.data.type === 'conversation_update') {
 | 
				
			||||||
 | 
					        rule.value.rules.events = []
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      form.setValues(resp.data.data)
 | 
					      form.setValues(resp.data.data)
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,24 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const formSchema = z.object({
 | 
					export const formSchema = z
 | 
				
			||||||
 | 
					    .object({
 | 
				
			||||||
        name: z.string({
 | 
					        name: z.string({
 | 
				
			||||||
        required_error: 'Rule name is required.'
 | 
					            required_error: 'Rule name is required.',
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
        description: z.string({
 | 
					        description: z.string({
 | 
				
			||||||
        required_error: 'Rule description is required.'
 | 
					            required_error: 'Rule description is required.',
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
        type: z.string({
 | 
					        type: z.string({
 | 
				
			||||||
        required_error: 'Rule type is required.'
 | 
					            required_error: 'Rule type is required.',
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
    events: z.array(z.string()).min(1, 'Please select at least one event.'),
 | 
					        events: z.array(z.string()).optional(),
 | 
				
			||||||
})
 | 
					    })
 | 
				
			||||||
 | 
					    .superRefine((data, ctx) => {
 | 
				
			||||||
 | 
					        if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
 | 
				
			||||||
 | 
					            ctx.addIssue({
 | 
				
			||||||
 | 
					                path: ['events'],
 | 
				
			||||||
 | 
					                message: 'Please select at least one event.',
 | 
				
			||||||
 | 
					                code: z.ZodIssueCode.custom,
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <PageHeader title="Business hours" description="Manage business hours" />
 | 
				
			||||||
 | 
					    <div class="w-8/12">
 | 
				
			||||||
 | 
					        <template v-if="router.currentRoute.value.path === '/admin/business-hours'">
 | 
				
			||||||
 | 
					            <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					                <div></div>
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
 | 
					                    <Button @click="navigateToAddBusinessHour">New business hour</Button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					                <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
 | 
					                <DataTable :columns="columns" :data="businessHours" v-else />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
 | 
					        <template v-else>
 | 
				
			||||||
 | 
					            <router-view/>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
 | 
					import DataTable from '@/components/admin/DataTable.vue'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import PageHeader from '../common/PageHeader.vue'
 | 
				
			||||||
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
 | 
					import { columns } from '@/components/admin/business_hours/dataTableColumns.js'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const businessHours = ref([])
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					    fetchAll()
 | 
				
			||||||
 | 
					    emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					    emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const refreshList = (data) => {
 | 
				
			||||||
 | 
					    if (data?.model === 'business_hours') fetchAll()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fetchAll = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        isLoading.value = true
 | 
				
			||||||
 | 
					        const resp = await api.getAllBusinessHours()
 | 
				
			||||||
 | 
					        businessHours.value = resp.data.data
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					        isLoading.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const navigateToAddBusinessHour = () => {
 | 
				
			||||||
 | 
					    router.push('/admin/business-hours/new')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -0,0 +1,268 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <form @submit="onSubmit" class="space-y-8">
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>Name</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="General working hours" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="description">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>Description</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="General working hours for my company" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="is_always_open">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>
 | 
				
			||||||
 | 
					                    Set business hours
 | 
				
			||||||
 | 
					                </FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <RadioGroup v-bind="componentField">
 | 
				
			||||||
 | 
					                        <div class="flex flex-col space-y-2">
 | 
				
			||||||
 | 
					                            <div class="flex items-center space-x-3">
 | 
				
			||||||
 | 
					                                <RadioGroupItem id="r1" value="true" />
 | 
				
			||||||
 | 
					                                <Label for="r1">Always open (24x7)</Label>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                            <div class="flex items-center space-x-3">
 | 
				
			||||||
 | 
					                                <RadioGroupItem id="r2" value="false" />
 | 
				
			||||||
 | 
					                                <Label for="r2">Custom business hours</Label>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    </RadioGroup>
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div v-if="form.values.is_always_open === 'false'">
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					                <div v-for="day in WEEKDAYS" :key="day" class="flex items-center justify-between space-y-2">
 | 
				
			||||||
 | 
					                    <div class="flex items-center space-x-3">
 | 
				
			||||||
 | 
					                        <Checkbox :id="day" :checked="!!selectedDays[day]"
 | 
				
			||||||
 | 
					                            @update:checked="handleDayToggle(day, $event)" />
 | 
				
			||||||
 | 
					                        <Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <div class="flex space-x-2 items-center">
 | 
				
			||||||
 | 
					                        <div class="flex flex-col items-start">
 | 
				
			||||||
 | 
					                            <Input type="time" :defaultValue="hours[day]?.open || '09:00'"
 | 
				
			||||||
 | 
					                                @update:modelValue="(val) => updateHours(day, 'open', val)"
 | 
				
			||||||
 | 
					                                :disabled="!selectedDays[day]" />
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                        <span class="text-gray-500">to</span>
 | 
				
			||||||
 | 
					                        <div class="flex flex-col items-start">
 | 
				
			||||||
 | 
					                            <Input type="time" :defaultValue="hours[day]?.close || '17:00'"
 | 
				
			||||||
 | 
					                                @update:modelValue="(val) => updateHours(day, 'close', val)"
 | 
				
			||||||
 | 
					                                :disabled="!selectedDays[day]" />
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Dialog >
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					                <div class="flex justify-between items-center mb-4">
 | 
				
			||||||
 | 
					                    <div></div>
 | 
				
			||||||
 | 
					                    <DialogTrigger as-child>
 | 
				
			||||||
 | 
					                        <Button>New holiday</Button>
 | 
				
			||||||
 | 
					                    </DialogTrigger>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <SimpleTable :headers="['Name', 'Date']" :keys="['name', 'date']" :data="holidays" @deleteItem="deleteHoliday" />
 | 
				
			||||||
 | 
					            <DialogContent class="sm:max-w-[425px]">
 | 
				
			||||||
 | 
					                <DialogHeader>
 | 
				
			||||||
 | 
					                    <DialogTitle>New holiday</DialogTitle>
 | 
				
			||||||
 | 
					                    <DialogDescription>
 | 
				
			||||||
 | 
					                    </DialogDescription>
 | 
				
			||||||
 | 
					                </DialogHeader>
 | 
				
			||||||
 | 
					                <div class="grid gap-4 py-4">
 | 
				
			||||||
 | 
					                    <div class="grid grid-cols-4 items-center gap-4">
 | 
				
			||||||
 | 
					                        <Label for="holiday_name" class="text-right">
 | 
				
			||||||
 | 
					                            Name
 | 
				
			||||||
 | 
					                        </Label>
 | 
				
			||||||
 | 
					                        <Input id="holiday_name" v-model="holidayName" class="col-span-3" />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <div class="grid grid-cols-4 items-center gap-4">
 | 
				
			||||||
 | 
					                        <Label for="date" class="text-right">
 | 
				
			||||||
 | 
					                            Date
 | 
				
			||||||
 | 
					                        </Label>
 | 
				
			||||||
 | 
					                        <Popover>
 | 
				
			||||||
 | 
					                            <PopoverTrigger as-child>
 | 
				
			||||||
 | 
					                                <Button variant="outline" :class="cn(
 | 
				
			||||||
 | 
					                                    'w-[280px] justify-start text-left font-normal',
 | 
				
			||||||
 | 
					                                    !holidayDate && 'text-muted-foreground',
 | 
				
			||||||
 | 
					                                )">
 | 
				
			||||||
 | 
					                                    <CalendarIcon class="mr-2 h-4 w-4" />
 | 
				
			||||||
 | 
					                                    {{ holidayDate && !isNaN(new Date(holidayDate).getTime()) ? format(new
 | 
				
			||||||
 | 
					                                        Date(holidayDate), 'MMMM dd, yyyy') : "Pick a date" }}
 | 
				
			||||||
 | 
					                                </Button>
 | 
				
			||||||
 | 
					                            </PopoverTrigger>
 | 
				
			||||||
 | 
					                            <PopoverContent class="w-auto p-0">
 | 
				
			||||||
 | 
					                                <Calendar v-model="holidayDate" />
 | 
				
			||||||
 | 
					                            </PopoverContent>
 | 
				
			||||||
 | 
					                        </Popover>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <DialogFooter>
 | 
				
			||||||
 | 
					                    <Button  :disabled="!holidayName || !holidayDate"
 | 
				
			||||||
 | 
					                        @click="saveHoliday">
 | 
				
			||||||
 | 
					                        Save changes
 | 
				
			||||||
 | 
					                    </Button>
 | 
				
			||||||
 | 
					                </DialogFooter>
 | 
				
			||||||
 | 
					            </DialogContent>
 | 
				
			||||||
 | 
					        </Dialog>
 | 
				
			||||||
 | 
					        <Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, watch, reactive } from 'vue'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
 | 
					import { formSchema } from './formSchema.js'
 | 
				
			||||||
 | 
					import { Checkbox } from '@/components/ui/checkbox'
 | 
				
			||||||
 | 
					import { Label } from '@/components/ui/label'
 | 
				
			||||||
 | 
					import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
				
			||||||
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
				
			||||||
 | 
					import { Calendar } from '@/components/ui/calendar'
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
 | 
					import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 | 
				
			||||||
 | 
					import { cn } from '@/lib/utils'
 | 
				
			||||||
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
 | 
					import { WEEKDAYS } from '@/constants/date'
 | 
				
			||||||
 | 
					import { Calendar as CalendarIcon } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import SimpleTable from '@/components/common/SimpleTable.vue'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    Dialog,
 | 
				
			||||||
 | 
					    DialogContent,
 | 
				
			||||||
 | 
					    DialogDescription,
 | 
				
			||||||
 | 
					    DialogFooter,
 | 
				
			||||||
 | 
					    DialogHeader,
 | 
				
			||||||
 | 
					    DialogTitle,
 | 
				
			||||||
 | 
					    DialogTrigger,
 | 
				
			||||||
 | 
					} from '@/components/ui/dialog'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    initialValues: {
 | 
				
			||||||
 | 
					        type: Object,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    submitForm: {
 | 
				
			||||||
 | 
					        type: Function,
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    submitLabel: {
 | 
				
			||||||
 | 
					        type: String,
 | 
				
			||||||
 | 
					        required: false,
 | 
				
			||||||
 | 
					        default: () => 'Save'
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    isNewForm: {
 | 
				
			||||||
 | 
					        type: Boolean
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    isLoading: {
 | 
				
			||||||
 | 
					        type: Boolean,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let holidays = reactive([])
 | 
				
			||||||
 | 
					const holidayName = ref('')
 | 
				
			||||||
 | 
					const holidayDate = ref(null)
 | 
				
			||||||
 | 
					const selectedDays = ref({})
 | 
				
			||||||
 | 
					const hours = ref({})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const form = useForm({
 | 
				
			||||||
 | 
					    validationSchema: toTypedSchema(formSchema),
 | 
				
			||||||
 | 
					    initialValues: props.initialValues
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const saveHoliday = () => {
 | 
				
			||||||
 | 
					    holidays.push({
 | 
				
			||||||
 | 
					        name: holidayName.value,
 | 
				
			||||||
 | 
					        date: new Date(holidayDate.value).toISOString().split('T')[0]
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    holidayName.value = ''
 | 
				
			||||||
 | 
					    holidayDate.value = null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteHoliday = (item) => {
 | 
				
			||||||
 | 
					    holidays.splice(holidays.findIndex(h => h.name === item.name), 1)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleDayToggle = (day, checked) => {
 | 
				
			||||||
 | 
					    selectedDays.value = {
 | 
				
			||||||
 | 
					        ...selectedDays.value,
 | 
				
			||||||
 | 
					        [day]: checked
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (checked && !hours.value[day]) {
 | 
				
			||||||
 | 
					        hours.value[day] = { open: '09:00', close: '17:00' }
 | 
				
			||||||
 | 
					    } else if (!checked) {
 | 
				
			||||||
 | 
					        const newHours = { ...hours.value }
 | 
				
			||||||
 | 
					        delete newHours[day]
 | 
				
			||||||
 | 
					        hours.value = newHours
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const updateHours = (day, type, value) => {
 | 
				
			||||||
 | 
					    if (!hours.value[day]) {
 | 
				
			||||||
 | 
					        hours.value[day] = { open: '09:00', close: '17:00' }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    hours.value[day][type] = value
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const onSubmit = form.handleSubmit((values) => {
 | 
				
			||||||
 | 
					    values.is_always_open = values.is_always_open === 'true'
 | 
				
			||||||
 | 
					    const businessHours = values.is_always_open === true
 | 
				
			||||||
 | 
					        ? {}
 | 
				
			||||||
 | 
					        :
 | 
				
			||||||
 | 
					        Object.keys(selectedDays.value)
 | 
				
			||||||
 | 
					            .filter(day => selectedDays.value[day])
 | 
				
			||||||
 | 
					            .reduce((acc, day) => {
 | 
				
			||||||
 | 
					                acc[day] = hours.value[day]
 | 
				
			||||||
 | 
					                return acc
 | 
				
			||||||
 | 
					            }, {})
 | 
				
			||||||
 | 
					    const finalValues = {
 | 
				
			||||||
 | 
					        ...values,
 | 
				
			||||||
 | 
					        hours: businessHours,
 | 
				
			||||||
 | 
					        holidays: holidays
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    props.submitForm(finalValues)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Watch for initial values
 | 
				
			||||||
 | 
					watch(
 | 
				
			||||||
 | 
					    () => props.initialValues,
 | 
				
			||||||
 | 
					    (newValues) => {
 | 
				
			||||||
 | 
					        if (!newValues || Object.keys(newValues).length === 0) return
 | 
				
			||||||
 | 
					        // Set business hours if provided
 | 
				
			||||||
 | 
					        newValues.is_always_open = newValues.is_always_open.toString()
 | 
				
			||||||
 | 
					        if (newValues.is_always_open === 'false') {
 | 
				
			||||||
 | 
					            hours.value = newValues.hours || {}
 | 
				
			||||||
 | 
					            selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
 | 
				
			||||||
 | 
					                acc[day] = true
 | 
				
			||||||
 | 
					                return acc
 | 
				
			||||||
 | 
					            }, {})
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // Set other form values
 | 
				
			||||||
 | 
					        form.setValues(newValues)
 | 
				
			||||||
 | 
					        holidays.length = 0
 | 
				
			||||||
 | 
					        holidays.push(...(newValues.holidays || []))
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    { deep: true }
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <div class="mb-5">
 | 
				
			||||||
 | 
					        <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
 | 
					    <BusinessHoursForm :initial-values="businessHours" :submitForm="submitForm" :isNewForm="isNewForm"
 | 
				
			||||||
 | 
					        :class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { onMounted, ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import BusinessHoursForm from './BusinessHoursForm.vue'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
 | 
					import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const businessHours = ref({})
 | 
				
			||||||
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const formLoading = ref(false)
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    id: {
 | 
				
			||||||
 | 
					        type: String,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const submitForm = async (values) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        formLoading.value = true
 | 
				
			||||||
 | 
					        if (props.id) {
 | 
				
			||||||
 | 
					            await api.updateBusinessHours(props.id, values)
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Success',
 | 
				
			||||||
 | 
					                description: 'Business hours updated successfully',
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            await api.createBusinessHours(values)
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Success',
 | 
				
			||||||
 | 
					                description: 'Business hours created successfully',
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            router.push('/admin/business-hours')
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					            title: 'Could not save business hours',
 | 
				
			||||||
 | 
					            variant: 'destructive',
 | 
				
			||||||
 | 
					            description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					        formLoading.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const breadCrumLabel = () => {
 | 
				
			||||||
 | 
					    return props.id ? 'Edit' : 'New'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isNewForm = computed(() => {
 | 
				
			||||||
 | 
					    return props.id ? false : true
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
 | 
					    { path: '/admin/business-hours', label: 'Business hours' },
 | 
				
			||||||
 | 
					    { path: '#', label: breadCrumLabel() }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
					    if (props.id) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            isLoading.value = true
 | 
				
			||||||
 | 
					            const resp = await api.getBusinessHours(props.id)
 | 
				
			||||||
 | 
					            businessHours.value = resp.data.data
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Could not fetch business hours',
 | 
				
			||||||
 | 
					                variant: 'destructive',
 | 
				
			||||||
 | 
					                description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        } finally {
 | 
				
			||||||
 | 
					            isLoading.value = false
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					import { h } from 'vue'
 | 
				
			||||||
 | 
					import dropdown from './dataTableDropdown.vue'
 | 
				
			||||||
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const columns = [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'name',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Name')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'created_at',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Created at')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'updated_at',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        id: 'actions',
 | 
				
			||||||
 | 
					        enableHiding: false,
 | 
				
			||||||
 | 
					        cell: ({ row }) => {
 | 
				
			||||||
 | 
					            const role = row.original
 | 
				
			||||||
 | 
					            return h(
 | 
				
			||||||
 | 
					                'div',
 | 
				
			||||||
 | 
					                { class: 'relative' },
 | 
				
			||||||
 | 
					                h(dropdown, {
 | 
				
			||||||
 | 
					                    role
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
@@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { MoreHorizontal } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    DropdownMenu,
 | 
				
			||||||
 | 
					    DropdownMenuContent,
 | 
				
			||||||
 | 
					    DropdownMenuItem,
 | 
				
			||||||
 | 
					    DropdownMenuTrigger
 | 
				
			||||||
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    role: {
 | 
				
			||||||
 | 
					        type: Object,
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        default: () => ({
 | 
				
			||||||
 | 
					            id: ''
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function edit (id) {
 | 
				
			||||||
 | 
					    router.push({ name: 'edit-business-hours', params: { id } })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function deleteBusinessHours (id) {
 | 
				
			||||||
 | 
					    await api.deleteBusinessHours(id)
 | 
				
			||||||
 | 
					    emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
 | 
				
			||||||
 | 
					        model: 'business_hours'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <DropdownMenu>
 | 
				
			||||||
 | 
					        <DropdownMenuTrigger as-child>
 | 
				
			||||||
 | 
					            <Button variant="ghost" class="w-8 h-8 p-0">
 | 
				
			||||||
 | 
					                <span class="sr-only">Open menu</span>
 | 
				
			||||||
 | 
					                <MoreHorizontal class="w-4 h-4" />
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					        </DropdownMenuTrigger>
 | 
				
			||||||
 | 
					        <DropdownMenuContent>
 | 
				
			||||||
 | 
					            <DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
 | 
				
			||||||
 | 
					            <DropdownMenuItem @click="deleteBusinessHours(props.role.id)"> Delete </DropdownMenuItem>
 | 
				
			||||||
 | 
					        </DropdownMenuContent>
 | 
				
			||||||
 | 
					    </DropdownMenu>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
							
								
								
									
										13
									
								
								frontend/src/components/admin/business_hours/formSchema.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/admin/business_hours/formSchema.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					import * as z from 'zod'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const formSchema = z.object({
 | 
				
			||||||
 | 
					    name: z
 | 
				
			||||||
 | 
					        .string({
 | 
				
			||||||
 | 
					            required_error: 'Name is required.'
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .min(1, {
 | 
				
			||||||
 | 
					            message: 'Name must be at least 1 character.'
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    description: z.string().optional(),
 | 
				
			||||||
 | 
					    is_always_open: z.string().default('true').optional(),
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -1,13 +1,12 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div
 | 
					  <div
 | 
				
			||||||
    class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted max-w-80"
 | 
					    class="flex-1 rounded-xl px-6 py-4 border border-muted shadow-md hover:shadow-lg transition-transform duration-200 transform hover:scale-105 cursor-pointer bg-white max-w-80"
 | 
				
			||||||
    @click="handleClick"
 | 
					    @click="handleClick">
 | 
				
			||||||
  >
 | 
					    <div class="flex items-center mb-3">
 | 
				
			||||||
    <div class="flex items-center mb-4">
 | 
					      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
				
			||||||
      <component :is="icon" size="25" class="mr-2" />
 | 
					      <p class="text-lg font-semibold text-gray-700">{{ title }}</p>
 | 
				
			||||||
      <p class="text-lg">{{ title }}</p>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <p class="text-sm text-muted-foreground">{{ subTitle }}</p>
 | 
					    <p class="text-sm text-gray-500">{{ subTitle }}</p>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -15,18 +14,9 @@
 | 
				
			|||||||
import { defineProps, defineEmits } from 'vue'
 | 
					import { defineProps, defineEmits } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  title: {
 | 
					  title: String,
 | 
				
			||||||
    type: String,
 | 
					  subTitle: String,
 | 
				
			||||||
    required: true
 | 
					  icon: Function,
 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  subTitle: {
 | 
					 | 
				
			||||||
    type: String,
 | 
					 | 
				
			||||||
    required: true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  icon: {
 | 
					 | 
				
			||||||
    type: Function,
 | 
					 | 
				
			||||||
    required: true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  onClick: {
 | 
					  onClick: {
 | 
				
			||||||
    type: Function,
 | 
					    type: Function,
 | 
				
			||||||
    default: null
 | 
					    default: null
 | 
				
			||||||
@@ -36,9 +26,7 @@ const props = defineProps({
 | 
				
			|||||||
const emit = defineEmits(['click'])
 | 
					const emit = defineEmits(['click'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleClick = () => {
 | 
					const handleClick = () => {
 | 
				
			||||||
  if (props.onClick) {
 | 
					  if (props.onClick) props.onClick()
 | 
				
			||||||
    props.onClick()
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  emit('click')
 | 
					  emit('click')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex flex-col space-y-1">
 | 
					  <div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
 | 
				
			||||||
    <span class="text-2xl">{{ title }}</span>
 | 
					    <span class="font-semibold text-2xl">{{ title }}</span>
 | 
				
			||||||
    <p class="text-muted-foreground text-lg">{{ description }}</p>
 | 
					    <p class="text-muted-foreground text-lg">{{ description }}</p>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,55 +0,0 @@
 | 
				
			|||||||
<template>
 | 
					 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <div class="mb-5">
 | 
					 | 
				
			||||||
      <PageHeader title="Conversation" description="Manage conversation settings" />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <div class="flex space-x-5">
 | 
					 | 
				
			||||||
      <AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
 | 
					 | 
				
			||||||
        :subTitle="card.subTitle" :icon="card.icon">
 | 
					 | 
				
			||||||
      </AdminMenuCard>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
  <router-view></router-view>
 | 
					 | 
				
			||||||
</template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<script setup>
 | 
					 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					 | 
				
			||||||
import { Tag, TrendingUp, MessageCircleReply } from 'lucide-vue-next'
 | 
					 | 
				
			||||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
 | 
					 | 
				
			||||||
import PageHeader from '../common/PageHeader.vue'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const router = useRouter()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToTags = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/conversations/tags')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToStatus = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/conversations/statuses')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToCannedResponse = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/conversations/canned-responses')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const cards = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Tags',
 | 
					 | 
				
			||||||
    subTitle: 'Manage conversation tags.',
 | 
					 | 
				
			||||||
    onClick: navigateToTags,
 | 
					 | 
				
			||||||
    icon: Tag
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Canned response',
 | 
					 | 
				
			||||||
    subTitle: 'Manage canned responses.',
 | 
					 | 
				
			||||||
    onClick: navigateToCannedResponse,
 | 
					 | 
				
			||||||
    icon: MessageCircleReply
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Status',
 | 
					 | 
				
			||||||
    subTitle: 'Manage conversation statuses.',
 | 
					 | 
				
			||||||
    onClick: navigateToStatus,
 | 
					 | 
				
			||||||
    icon: TrendingUp
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
@@ -1,11 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <div class="flex justify-between mb-5">
 | 
					 | 
				
			||||||
  <PageHeader title="Canned responses" description="Manage canned responses" />
 | 
					  <PageHeader title="Canned responses" description="Manage canned responses" />
 | 
				
			||||||
      <div class="flex justify-end mb-4">
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					      <div class="flex justify-end mb-4 w-full">
 | 
				
			||||||
        <Dialog v-model:open="dialogOpen">
 | 
					        <Dialog v-model:open="dialogOpen">
 | 
				
			||||||
          <DialogTrigger as-child>
 | 
					          <DialogTrigger as-child>
 | 
				
			||||||
            <Button size="sm">New canned response</Button>
 | 
					            <Button class="ml-auto">New canned response</Button>
 | 
				
			||||||
          </DialogTrigger>
 | 
					          </DialogTrigger>
 | 
				
			||||||
          <DialogContent class="sm:max-w-[625px]">
 | 
					          <DialogContent class="sm:max-w-[625px]">
 | 
				
			||||||
            <DialogHeader>
 | 
					            <DialogHeader>
 | 
				
			||||||
@@ -15,7 +15,7 @@
 | 
				
			|||||||
            <CannedResponsesForm @submit="onSubmit">
 | 
					            <CannedResponsesForm @submit="onSubmit">
 | 
				
			||||||
              <template #footer>
 | 
					              <template #footer>
 | 
				
			||||||
                <DialogFooter class="mt-7">
 | 
					                <DialogFooter class="mt-7">
 | 
				
			||||||
                  <Button type="submit" size="sm">Save Changes</Button>
 | 
					                  <Button type="submit">Save Changes</Button>
 | 
				
			||||||
                </DialogFooter>
 | 
					                </DialogFooter>
 | 
				
			||||||
              </template>
 | 
					              </template>
 | 
				
			||||||
            </CannedResponsesForm>
 | 
					            </CannedResponsesForm>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@
 | 
				
			|||||||
      <CannedResponsesForm @submit="onSubmit">
 | 
					      <CannedResponsesForm @submit="onSubmit">
 | 
				
			||||||
        <template #footer>
 | 
					        <template #footer>
 | 
				
			||||||
          <DialogFooter class="mt-7">
 | 
					          <DialogFooter class="mt-7">
 | 
				
			||||||
            <Button type="submit" size="sm">Save Changes</Button>
 | 
					            <Button type="submit">Save Changes</Button>
 | 
				
			||||||
          </DialogFooter>
 | 
					          </DialogFooter>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
      </CannedResponsesForm>
 | 
					      </CannedResponsesForm>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <div class="flex justify-between mb-5">
 | 
					 | 
				
			||||||
  <PageHeader title="Status" description="Manage conversation statuses" />
 | 
					  <PageHeader title="Status" description="Manage conversation statuses" />
 | 
				
			||||||
      <div class="flex justify-end mb-4">
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					      <div class="flex justify-end mb-4 w-full">
 | 
				
			||||||
      <Dialog v-model:open="dialogOpen">
 | 
					      <Dialog v-model:open="dialogOpen">
 | 
				
			||||||
        <DialogTrigger as-child>
 | 
					        <DialogTrigger as-child>
 | 
				
			||||||
            <Button size="sm">New Status</Button>
 | 
					        <Button class="ml-auto">New Status</Button>
 | 
				
			||||||
        </DialogTrigger>
 | 
					        </DialogTrigger>
 | 
				
			||||||
          <DialogContent class="sm:max-w-[425px]">
 | 
					          <DialogContent class="sm:max-w-[425px]">
 | 
				
			||||||
            <DialogHeader>
 | 
					            <DialogHeader>
 | 
				
			||||||
@@ -15,7 +15,7 @@
 | 
				
			|||||||
            <StatusForm @submit.prevent="onSubmit">
 | 
					            <StatusForm @submit.prevent="onSubmit">
 | 
				
			||||||
              <template #footer>
 | 
					              <template #footer>
 | 
				
			||||||
                <DialogFooter class="mt-10">
 | 
					                <DialogFooter class="mt-10">
 | 
				
			||||||
                  <Button type="submit" size="sm"> Save changes </Button>
 | 
					                  <Button type="submit"> Save changes </Button>
 | 
				
			||||||
                </DialogFooter>
 | 
					                </DialogFooter>
 | 
				
			||||||
              </template>
 | 
					              </template>
 | 
				
			||||||
            </StatusForm>
 | 
					            </StatusForm>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@
 | 
				
			|||||||
      <StatusForm @submit.prevent="onSubmit">
 | 
					      <StatusForm @submit.prevent="onSubmit">
 | 
				
			||||||
        <template #footer>
 | 
					        <template #footer>
 | 
				
			||||||
          <DialogFooter class="mt-10">
 | 
					          <DialogFooter class="mt-10">
 | 
				
			||||||
            <Button type="submit" size="sm"> Save changes </Button>
 | 
					            <Button type="submit"> Save changes </Button>
 | 
				
			||||||
          </DialogFooter>
 | 
					          </DialogFooter>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
      </StatusForm>
 | 
					      </StatusForm>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex justify-between mb-5">
 | 
					 | 
				
			||||||
  <PageHeader title="Tags" description="Manage conversation tags" />
 | 
					  <PageHeader title="Tags" description="Manage conversation tags" />
 | 
				
			||||||
    <div class="flex justify-end mb-4">
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					      <div class="flex justify-end mb-4 w-full">
 | 
				
			||||||
        <Dialog v-model:open="dialogOpen">
 | 
					        <Dialog v-model:open="dialogOpen">
 | 
				
			||||||
          <DialogTrigger as-child>
 | 
					          <DialogTrigger as-child>
 | 
				
			||||||
          <Button size="sm">New Tag</Button>
 | 
					            <Button class="ml-auto">New Tag</Button>
 | 
				
			||||||
          </DialogTrigger>
 | 
					          </DialogTrigger>
 | 
				
			||||||
          <DialogContent class="sm:max-w-[425px]">
 | 
					          <DialogContent class="sm:max-w-[425px]">
 | 
				
			||||||
            <DialogHeader>
 | 
					            <DialogHeader>
 | 
				
			||||||
@@ -14,7 +15,7 @@
 | 
				
			|||||||
            <TagsForm @submit.prevent="onSubmit">
 | 
					            <TagsForm @submit.prevent="onSubmit">
 | 
				
			||||||
              <template #footer>
 | 
					              <template #footer>
 | 
				
			||||||
                <DialogFooter class="mt-10">
 | 
					                <DialogFooter class="mt-10">
 | 
				
			||||||
                <Button type="submit" size="sm"> Save changes </Button>
 | 
					                  <Button type="submit"> Save changes </Button>
 | 
				
			||||||
                </DialogFooter>
 | 
					                </DialogFooter>
 | 
				
			||||||
              </template>
 | 
					              </template>
 | 
				
			||||||
            </TagsForm>
 | 
					            </TagsForm>
 | 
				
			||||||
@@ -26,6 +27,7 @@
 | 
				
			|||||||
    <div v-else>
 | 
					    <div v-else>
 | 
				
			||||||
      <DataTable :columns="columns" :data="tags" />
 | 
					      <DataTable :columns="columns" :data="tags" />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@
 | 
				
			|||||||
      <TagsForm @submit.prevent="onSubmit">
 | 
					      <TagsForm @submit.prevent="onSubmit">
 | 
				
			||||||
        <template #footer>
 | 
					        <template #footer>
 | 
				
			||||||
          <DialogFooter class="mt-10">
 | 
					          <DialogFooter class="mt-10">
 | 
				
			||||||
            <Button type="submit" size="sm"> Save changes </Button>
 | 
					            <Button type="submit"> Save changes </Button>
 | 
				
			||||||
          </DialogFooter>
 | 
					          </DialogFooter>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
      </TagsForm>
 | 
					      </TagsForm>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,10 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					  <div>
 | 
				
			||||||
    <PageHeader title="General" description="General app settings" />
 | 
					    <PageHeader title="General" description="Manage general app settings"  />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div class="flex justify-center items-center flex-col w-8/12">
 | 
				
			||||||
    <GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
 | 
					    <GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Spinner v-if="formLoading"></Spinner>
 | 
					  <Spinner v-if="formLoading"></Spinner>
 | 
				
			||||||
  <form @submit="onSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }">
 | 
					  <form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }">
 | 
				
			||||||
    <FormField v-slot="{ field }" name="site_name">
 | 
					    <FormField v-slot="{ field }" name="site_name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Site Name</FormLabel>
 | 
					        <FormLabel>Site Name</FormLabel>
 | 
				
			||||||
@@ -12,11 +12,11 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ field }" name="lang">
 | 
					    <FormField v-slot="{ componentField }" name="lang">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Language</FormLabel>
 | 
					        <FormLabel>Language</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="field" :modelValue="field.value">
 | 
					          <Select v-bind="componentField" :modelValue="componentField.modelValue">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue placeholder="Select a language" />
 | 
					              <SelectValue placeholder="Select a language" />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
@@ -32,6 +32,50 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    <FormField v-slot="{ componentField }" name="timezone">
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>Timezone</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
 | 
					            <SelectTrigger>
 | 
				
			||||||
 | 
					              <SelectValue placeholder="Select a timezone" />
 | 
				
			||||||
 | 
					            </SelectTrigger>
 | 
				
			||||||
 | 
					            <SelectContent>
 | 
				
			||||||
 | 
					              <SelectGroup>
 | 
				
			||||||
 | 
					                <SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
 | 
				
			||||||
 | 
					                  {{ timezone }}
 | 
				
			||||||
 | 
					                </SelectItem>
 | 
				
			||||||
 | 
					              </SelectGroup>
 | 
				
			||||||
 | 
					            </SelectContent>
 | 
				
			||||||
 | 
					          </Select>
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormDescription>Default timezone.</FormDescription>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <FormField v-slot="{ componentField }" name="business_hours_id">
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>Business hours</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
 | 
					            <SelectTrigger>
 | 
				
			||||||
 | 
					              <SelectValue placeholder="Select business hours" />
 | 
				
			||||||
 | 
					            </SelectTrigger>
 | 
				
			||||||
 | 
					            <SelectContent>
 | 
				
			||||||
 | 
					              <SelectGroup>
 | 
				
			||||||
 | 
					                <SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
 | 
				
			||||||
 | 
					                  {{ bh.name }}
 | 
				
			||||||
 | 
					                </SelectItem>
 | 
				
			||||||
 | 
					              </SelectGroup>
 | 
				
			||||||
 | 
					            </SelectContent>
 | 
				
			||||||
 | 
					          </Select>
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormDescription>Default business hours.</FormDescription>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ field }" name="root_url">
 | 
					    <FormField v-slot="{ field }" name="root_url">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Root URL</FormLabel>
 | 
					        <FormLabel>Root URL</FormLabel>
 | 
				
			||||||
@@ -92,12 +136,12 @@
 | 
				
			|||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch, ref } from 'vue'
 | 
					import { watch, ref, onMounted } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
@@ -130,10 +174,13 @@ import { Input } from '@/components/ui/input'
 | 
				
			|||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const timezones = Intl.supportedValuesOf('timeZone')
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
const formLoading = ref(true)
 | 
					const formLoading = ref(true)
 | 
				
			||||||
 | 
					const businessHours = ref({})
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  initialValues: {
 | 
					  initialValues: {
 | 
				
			||||||
    type: Object,
 | 
					    type: Object,
 | 
				
			||||||
@@ -154,6 +201,36 @@ const form = useForm({
 | 
				
			|||||||
  validationSchema: toTypedSchema(formSchema)
 | 
					  validationSchema: toTypedSchema(formSchema)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  fetchBusinessHours()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fetchBusinessHours = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await api.getAllBusinessHours()
 | 
				
			||||||
 | 
					    // Convert business hours id to string
 | 
				
			||||||
 | 
					    response.data.data.forEach(bh => {
 | 
				
			||||||
 | 
					      bh.id = bh.id.toString()
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    businessHours.value = response.data.data
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    // If unauthorized (no permission), show a toast message.
 | 
				
			||||||
 | 
					    if (error.response.status === 403) {
 | 
				
			||||||
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					        title: 'Unauthorized',
 | 
				
			||||||
 | 
					        variant: 'destructive',
 | 
				
			||||||
 | 
					        description: 'You do not have permission to view business hours.'
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					        title: 'Could not fetch business hours',
 | 
				
			||||||
 | 
					        variant: 'destructive',
 | 
				
			||||||
 | 
					        description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit(async (values) => {
 | 
					const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    isLoading.value = true
 | 
					    isLoading.value = true
 | 
				
			||||||
@@ -176,9 +253,15 @@ const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			|||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.initialValues,
 | 
					  () => props.initialValues,
 | 
				
			||||||
  (newValues) => {
 | 
					  (newValues) => {
 | 
				
			||||||
 | 
					    if (Object.keys(newValues).length === 0) {
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // Convert business hours id to string
 | 
				
			||||||
 | 
					    if (newValues.business_hours_id)
 | 
				
			||||||
 | 
					      newValues.business_hours_id = newValues.business_hours_id.toString()
 | 
				
			||||||
    form.setValues(newValues)
 | 
					    form.setValues(newValues)
 | 
				
			||||||
    formLoading.value = false
 | 
					    formLoading.value = false
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  { deep: true }
 | 
					  { deep: true, immediate: true }
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,20 +9,25 @@ export const formSchema = z.object({
 | 
				
			|||||||
      message: 'Site name must be at least 1 characters.'
 | 
					      message: 'Site name must be at least 1 characters.'
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
  lang: z.string().optional(),
 | 
					  lang: z.string().optional(),
 | 
				
			||||||
 | 
					  timezone: z.string().optional(),
 | 
				
			||||||
 | 
					  business_hours_id: z.string().optional(),
 | 
				
			||||||
 | 
					  logo_url: z.string().url({
 | 
				
			||||||
 | 
					    message: 'Logo URL must be a valid URL.'
 | 
				
			||||||
 | 
					  }).url().optional(),
 | 
				
			||||||
  root_url: z
 | 
					  root_url: z
 | 
				
			||||||
    .string({
 | 
					    .string({
 | 
				
			||||||
      required_error: 'Root URL is required.'
 | 
					      required_error: 'Root URL is required.'
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    .url({
 | 
					    .url({
 | 
				
			||||||
      message: 'Root URL must be a valid URL.'
 | 
					      message: 'Root URL must be a valid URL.'
 | 
				
			||||||
    }),
 | 
					    }).url(),
 | 
				
			||||||
  favicon_url: z
 | 
					  favicon_url: z
 | 
				
			||||||
    .string({
 | 
					    .string({
 | 
				
			||||||
      required_error: 'Favicon URL is required.'
 | 
					      required_error: 'Favicon URL is required.'
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    .url({
 | 
					    .url({
 | 
				
			||||||
      message: 'Favicon URL must be a valid URL.'
 | 
					      message: 'Favicon URL must be a valid URL.'
 | 
				
			||||||
    }),
 | 
					    }).url(),
 | 
				
			||||||
  max_file_upload_size: z
 | 
					  max_file_upload_size: z
 | 
				
			||||||
    .number({
 | 
					    .number({
 | 
				
			||||||
      required_error: 'Max upload file size is required.'
 | 
					      required_error: 'Max upload file size is required.'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -59,7 +59,7 @@
 | 
				
			|||||||
    }"
 | 
					    }"
 | 
				
			||||||
    @submit="submitForm"
 | 
					    @submit="submitForm"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <Button type="submit" size="sm" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
 | 
					    <Button type="submit" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
 | 
				
			||||||
  </AutoForm>
 | 
					  </AutoForm>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,18 +1,20 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <div class="flex justify-between mb-5">
 | 
					 | 
				
			||||||
  <PageHeader title="Inboxes" description="Manage your inboxes" />
 | 
					  <PageHeader title="Inboxes" description="Manage your inboxes" />
 | 
				
			||||||
      <div class="flex justify-end mb-4">
 | 
					  <div class="w-8/12">
 | 
				
			||||||
        <Button @click="navigateToAddInbox" size="sm"> New inbox </Button>
 | 
					    <template v-if="router.currentRoute.value.path === '/admin/inboxes'">
 | 
				
			||||||
 | 
					      <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					        <div class="flex justify-end w-full mb-4">
 | 
				
			||||||
 | 
					          <Button @click="navigateToAddInbox"> New inbox </Button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Spinner v-if="isLoading"></Spinner>
 | 
					        <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
        <DataTable :columns="columns" :data="data" v-else />
 | 
					        <DataTable :columns="columns" :data="data" v-else />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
  </div>
 | 
					    </template>
 | 
				
			||||||
  <div>
 | 
					    <template v-else>
 | 
				
			||||||
    <router-view></router-view>
 | 
					      <router-view/>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -99,7 +101,7 @@ const columns = [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, 'Modified at')
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -42,13 +42,13 @@ export const formSchema = z.object({
 | 
				
			|||||||
    }),
 | 
					    }),
 | 
				
			||||||
  smtp: z
 | 
					  smtp: z
 | 
				
			||||||
    .object({
 | 
					    .object({
 | 
				
			||||||
      host: z.string().describe('Host').default('smtp.yourmailserver.com'),
 | 
					      host: z.string().describe('Host').default('smtp.google.com'),
 | 
				
			||||||
      port: z
 | 
					      port: z
 | 
				
			||||||
        .number({ invalid_type_error: 'Port must be a number.' })
 | 
					        .number({ invalid_type_error: 'Port must be a number.' })
 | 
				
			||||||
        .min(1, { message: 'Port must be at least 1.' })
 | 
					        .min(1, { message: 'Port must be at least 1.' })
 | 
				
			||||||
        .max(65535, { message: 'Port must be at most 65535.' })
 | 
					        .max(65535, { message: 'Port must be at most 65535.' })
 | 
				
			||||||
        .describe('Port')
 | 
					        .describe('Port')
 | 
				
			||||||
        .default(25),
 | 
					        .default(587),
 | 
				
			||||||
      username: z.string().describe('Username'),
 | 
					      username: z.string().describe('Username'),
 | 
				
			||||||
      password: z.string().describe('Password'),
 | 
					      password: z.string().describe('Password'),
 | 
				
			||||||
      max_conns: z
 | 
					      max_conns: z
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <div>
 | 
					    <PageHeader title="Notifications" description="Manage your email notification settings" />
 | 
				
			||||||
        <PageHeader title="Notification" description="Manage notification settings" />
 | 
					    <div class="w-8/12">
 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
            <Spinner v-if="formLoading"></Spinner>
 | 
					            <Spinner v-if="formLoading"></Spinner>
 | 
				
			||||||
            <NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
 | 
					            <NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,5 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <form @submit="onSmtpSubmit" class="space-y-6"
 | 
					    <form @submit="onSmtpSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
 | 
				
			||||||
        :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <!-- Enabled Field -->
 | 
					 | 
				
			||||||
        <FormField name="enabled" v-slot="{ value, handleChange }">
 | 
					 | 
				
			||||||
            <FormItem>
 | 
					 | 
				
			||||||
                <FormControl>
 | 
					 | 
				
			||||||
                    <div class="flex items-center space-x-2">
 | 
					 | 
				
			||||||
                        <Checkbox :checked="value" @update:checked="handleChange" />
 | 
					 | 
				
			||||||
                        <Label>Enabled</Label>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                </FormControl>
 | 
					 | 
				
			||||||
                <FormMessage />
 | 
					 | 
				
			||||||
            </FormItem>
 | 
					 | 
				
			||||||
        </FormField>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <!-- SMTP Host Field -->
 | 
					        <!-- SMTP Host Field -->
 | 
				
			||||||
        <FormField v-slot="{ componentField }" name="host">
 | 
					        <FormField v-slot="{ componentField }" name="host">
 | 
				
			||||||
@@ -120,7 +106,8 @@
 | 
				
			|||||||
            <FormItem>
 | 
					            <FormItem>
 | 
				
			||||||
                <FormLabel>From Email Address</FormLabel>
 | 
					                <FormLabel>From Email Address</FormLabel>
 | 
				
			||||||
                <FormControl>
 | 
					                <FormControl>
 | 
				
			||||||
                    <Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" />
 | 
					                    <Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>"
 | 
				
			||||||
 | 
					                        v-bind="componentField" />
 | 
				
			||||||
                </FormControl>
 | 
					                </FormControl>
 | 
				
			||||||
                <FormMessage />
 | 
					                <FormMessage />
 | 
				
			||||||
                <FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
 | 
					                <FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
 | 
				
			||||||
@@ -138,7 +125,20 @@
 | 
				
			|||||||
            </FormItem>
 | 
					            </FormItem>
 | 
				
			||||||
        </FormField>
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					        <!-- Enabled Field -->
 | 
				
			||||||
 | 
					        <FormField name="enabled" v-slot="{ value, handleChange }">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <div class="flex items-center space-x-2">
 | 
				
			||||||
 | 
					                        <Checkbox :checked="value" @update:checked="handleChange" />
 | 
				
			||||||
 | 
					                        <Label>Enabled</Label>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,7 +50,6 @@ const submitForm = async (values) => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
    formLoading.value = false
 | 
					    formLoading.value = false
 | 
				
			||||||
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,22 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
 | 
					  <PageHeader title="OpenID Connect" description="Manage OpenID Connect configurations" />
 | 
				
			||||||
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <template v-if="router.currentRoute.value.path === '/admin/oidc'">
 | 
				
			||||||
      <div class="flex justify-between mb-5">
 | 
					      <div class="flex justify-between mb-5">
 | 
				
			||||||
    <PageHeader title="OpenID Connect SSO" description="Manage OpenID SSO configurations" />
 | 
					        <div></div>
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
      <Button size="sm" @click="navigateToAddOIDC">New OIDC</Button>
 | 
					          <Button @click="navigateToAddOIDC">New OIDC</Button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Spinner v-if="isLoading"></Spinner>
 | 
					        <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
        <DataTable :columns="columns" :data="oidc" v-else />
 | 
					        <DataTable :columns="columns" :data="oidc" v-else />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					    <template v-else>
 | 
				
			||||||
 | 
					      <router-view/>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -88,7 +88,7 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,7 @@ export const columns = [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, 'Modified at')
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										91
									
								
								frontend/src/components/admin/sla/CreateEditSLA.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								frontend/src/components/admin/sla/CreateEditSLA.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <div class="mb-5">
 | 
				
			||||||
 | 
					        <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
 | 
					    <SLAForm :initial-values="slaData" :submitForm="submitForm" :isNewForm="isNewForm"
 | 
				
			||||||
 | 
					        :class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { onMounted, ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import SLAForm from './SLAForm.vue'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
 | 
					import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const slaData = ref({})
 | 
				
			||||||
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const formLoading = ref(false)
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    id: {
 | 
				
			||||||
 | 
					        type: String,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const submitForm = async (values) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        formLoading.value = true
 | 
				
			||||||
 | 
					        if (props.id) {
 | 
				
			||||||
 | 
					            await api.updateSLA(props.id, values)
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Success',
 | 
				
			||||||
 | 
					                description: 'SLA updated successfully',
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            await api.createSLA(values)
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Success',
 | 
				
			||||||
 | 
					                description: 'SLA created successfully',
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            router.push('/admin/sla')
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					            title: 'Could not save SLA',
 | 
				
			||||||
 | 
					            variant: 'destructive',
 | 
				
			||||||
 | 
					            description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					        formLoading.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const breadCrumLabel = () => {
 | 
				
			||||||
 | 
					    return props.id ? 'Edit' : 'New'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isNewForm = computed(() => {
 | 
				
			||||||
 | 
					    return props.id ? false : true
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
 | 
					    { path: '/admin/sla', label: 'SLA' },
 | 
				
			||||||
 | 
					    { path: '#', label: breadCrumLabel() }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
					    if (props.id) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            isLoading.value = true
 | 
				
			||||||
 | 
					            const resp = await api.getSLA(props.id)
 | 
				
			||||||
 | 
					            slaData.value = resp.data.data
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					                title: 'Could not fetch SLA',
 | 
				
			||||||
 | 
					                variant: 'destructive',
 | 
				
			||||||
 | 
					                description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        } finally {
 | 
				
			||||||
 | 
					            isLoading.value = false
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										65
									
								
								frontend/src/components/admin/sla/SLA.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								frontend/src/components/admin/sla/SLA.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <PageHeader title="SLA" description="Manage service level agreements" />
 | 
				
			||||||
 | 
					    <div class="w-8/12">
 | 
				
			||||||
 | 
					        <template v-if="router.currentRoute.value.path === '/admin/sla'">
 | 
				
			||||||
 | 
					            <div class="flex justify-between mb-5">
 | 
				
			||||||
 | 
					                <div></div>
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
 | 
					                    <Button @click="navigateToAddSLA">New SLA</Button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					                <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
 | 
					                <DataTable :columns="columns" :data="slas" v-else />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
 | 
					        <template v-else>
 | 
				
			||||||
 | 
					            <router-view/>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
 | 
					import DataTable from '@/components/admin/DataTable.vue'
 | 
				
			||||||
 | 
					import { columns } from './dataTableColumns.js'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import PageHeader from '../common/PageHeader.vue'
 | 
				
			||||||
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const slas = ref([])
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					    fetchAll()
 | 
				
			||||||
 | 
					    emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					    emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const refreshList = (data) => {
 | 
				
			||||||
 | 
					    if (data?.model === 'sla') fetchAll()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fetchAll = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        isLoading.value = true
 | 
				
			||||||
 | 
					        const resp = await api.getAllSLAs()
 | 
				
			||||||
 | 
					        slas.value = resp.data.data
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					        isLoading.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const navigateToAddSLA = () => {
 | 
				
			||||||
 | 
					    router.push('/admin/sla/new')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										100
									
								
								frontend/src/components/admin/sla/SLAForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								frontend/src/components/admin/sla/SLAForm.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,100 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <form @submit="onSubmit" class="space-y-8">
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>Name</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="SLA Name" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="description">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>Description</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="Describe the SLA" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="first_response_time">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>First response time</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="6h" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormDescription>
 | 
				
			||||||
 | 
					                    Duration in hours or minutes to respond to a conversation.
 | 
				
			||||||
 | 
					                </FormDescription>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <FormField v-slot="{ componentField }" name="resolution_time">
 | 
				
			||||||
 | 
					            <FormItem>
 | 
				
			||||||
 | 
					                <FormLabel>Resolution time</FormLabel>
 | 
				
			||||||
 | 
					                <FormControl>
 | 
				
			||||||
 | 
					                    <Input type="text" placeholder="4h" v-bind="componentField" />
 | 
				
			||||||
 | 
					                </FormControl>
 | 
				
			||||||
 | 
					                <FormDescription>
 | 
				
			||||||
 | 
					                    Duration in hours or minutes to resolve a conversation.
 | 
				
			||||||
 | 
					                </FormDescription>
 | 
				
			||||||
 | 
					                <FormMessage />
 | 
				
			||||||
 | 
					            </FormItem>
 | 
				
			||||||
 | 
					        </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { watch } from 'vue'
 | 
				
			||||||
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
 | 
					import { formSchema } from './formSchema.js'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form'
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    initialValues: {
 | 
				
			||||||
 | 
					        type: Object,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    submitForm: {
 | 
				
			||||||
 | 
					        type: Function,
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    submitLabel: {
 | 
				
			||||||
 | 
					        type: String,
 | 
				
			||||||
 | 
					        required: false,
 | 
				
			||||||
 | 
					        default: () => 'Save'
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    isLoading: {
 | 
				
			||||||
 | 
					        type: Boolean,
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const form = useForm({
 | 
				
			||||||
 | 
					    validationSchema: toTypedSchema(formSchema),
 | 
				
			||||||
 | 
					    initialValues: props.initialValues
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const onSubmit = form.handleSubmit((values) => {
 | 
				
			||||||
 | 
					    props.submitForm(values)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watch(
 | 
				
			||||||
 | 
					    () => props.initialValues,
 | 
				
			||||||
 | 
					    (newValues) => {
 | 
				
			||||||
 | 
					        if (!newValues || Object.keys(newValues).length === 0) return
 | 
				
			||||||
 | 
					        form.setValues(newValues)
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    { deep: true, immediate: true }
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										47
									
								
								frontend/src/components/admin/sla/dataTableColumns.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/components/admin/sla/dataTableColumns.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					import { h } from 'vue'
 | 
				
			||||||
 | 
					import dropdown from './dataTableDropdown.vue'
 | 
				
			||||||
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const columns = [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'name',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Name')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'created_at',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Created at')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        accessorKey: 'updated_at',
 | 
				
			||||||
 | 
					        header: function () {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
 | 
					            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        id: 'actions',
 | 
				
			||||||
 | 
					        enableHiding: false,
 | 
				
			||||||
 | 
					        cell: ({ row }) => {
 | 
				
			||||||
 | 
					            const role = row.original
 | 
				
			||||||
 | 
					            return h(
 | 
				
			||||||
 | 
					                'div',
 | 
				
			||||||
 | 
					                { class: 'relative' },
 | 
				
			||||||
 | 
					                h(dropdown, {
 | 
				
			||||||
 | 
					                    role
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
							
								
								
									
										52
									
								
								frontend/src/components/admin/sla/dataTableDropdown.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend/src/components/admin/sla/dataTableDropdown.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { MoreHorizontal } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    DropdownMenu,
 | 
				
			||||||
 | 
					    DropdownMenuContent,
 | 
				
			||||||
 | 
					    DropdownMenuItem,
 | 
				
			||||||
 | 
					    DropdownMenuTrigger
 | 
				
			||||||
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					    role: {
 | 
				
			||||||
 | 
					        type: Object,
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        default: () => ({
 | 
				
			||||||
 | 
					            id: ''
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function edit (id) {
 | 
				
			||||||
 | 
					    router.push({ path: `/admin/sla/${id}/edit` })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function deleteSLA (id) {
 | 
				
			||||||
 | 
					    await api.deleteSLA(id)
 | 
				
			||||||
 | 
					    emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
 | 
				
			||||||
 | 
					        model: 'sla'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <DropdownMenu>
 | 
				
			||||||
 | 
					        <DropdownMenuTrigger as-child>
 | 
				
			||||||
 | 
					            <Button variant="ghost" class="w-8 h-8 p-0">
 | 
				
			||||||
 | 
					                <span class="sr-only">Open menu</span>
 | 
				
			||||||
 | 
					                <MoreHorizontal class="w-4 h-4" />
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					        </DropdownMenuTrigger>
 | 
				
			||||||
 | 
					        <DropdownMenuContent>
 | 
				
			||||||
 | 
					            <DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
 | 
				
			||||||
 | 
					            <DropdownMenuItem @click="deleteSLA(props.role.id)"> Delete </DropdownMenuItem>
 | 
				
			||||||
 | 
					        </DropdownMenuContent>
 | 
				
			||||||
 | 
					    </DropdownMenu>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
							
								
								
									
										26
									
								
								frontend/src/components/admin/sla/formSchema.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/admin/sla/formSchema.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					import * as z from 'zod'
 | 
				
			||||||
 | 
					import { isGoHourMinuteDuration } from '@/utils/strings'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const formSchema = z.object({
 | 
				
			||||||
 | 
					    name: z
 | 
				
			||||||
 | 
					        .string({
 | 
				
			||||||
 | 
					            required_error: 'Name is required.'
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .max(255, {
 | 
				
			||||||
 | 
					            message: 'Name must be at most 255 characters.'
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    description: z
 | 
				
			||||||
 | 
					        .string()
 | 
				
			||||||
 | 
					        .max(255, {
 | 
				
			||||||
 | 
					            message: 'Description must be at most 255 characters.'
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .optional(),
 | 
				
			||||||
 | 
					    first_response_time: z.string().optional().refine(isGoHourMinuteDuration, {
 | 
				
			||||||
 | 
					        message:
 | 
				
			||||||
 | 
					            'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    resolution_time: z.string().optional().refine(isGoHourMinuteDuration, {
 | 
				
			||||||
 | 
					        message:
 | 
				
			||||||
 | 
					            'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -1,61 +0,0 @@
 | 
				
			|||||||
<template>
 | 
					 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <div class="mb-5">
 | 
					 | 
				
			||||||
      <PageHeader title="Teams" description="Manage teams, users and roles" />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <div class="flex space-x-5">
 | 
					 | 
				
			||||||
      <AdminMenuCard
 | 
					 | 
				
			||||||
        v-for="card in cards"
 | 
					 | 
				
			||||||
        :key="card.title"
 | 
					 | 
				
			||||||
        :onClick="card.onClick"
 | 
					 | 
				
			||||||
        :title="card.title"
 | 
					 | 
				
			||||||
        :subTitle="card.subTitle"
 | 
					 | 
				
			||||||
        :icon="card.icon"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
      </AdminMenuCard>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
  <router-view></router-view>
 | 
					 | 
				
			||||||
</template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<script setup>
 | 
					 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					 | 
				
			||||||
import { Users, UserRoundCog, User } from 'lucide-vue-next'
 | 
					 | 
				
			||||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
 | 
					 | 
				
			||||||
import PageHeader from '../common/PageHeader.vue'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const router = useRouter()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToUsers = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/teams/users')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToTeams = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/teams/teams')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const navigateToRoles = () => {
 | 
					 | 
				
			||||||
  router.push('/admin/teams/roles')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const cards = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Users',
 | 
					 | 
				
			||||||
    subTitle: 'Create and manage users.',
 | 
					 | 
				
			||||||
    onClick: navigateToUsers,
 | 
					 | 
				
			||||||
    icon: User
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Teams',
 | 
					 | 
				
			||||||
    subTitle: 'Create and manage teams.',
 | 
					 | 
				
			||||||
    onClick: navigateToTeams,
 | 
					 | 
				
			||||||
    icon: Users
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    title: 'Roles',
 | 
					 | 
				
			||||||
    subTitle: 'Create and manage roles.',
 | 
					 | 
				
			||||||
    onClick: navigateToRoles,
 | 
					 | 
				
			||||||
    icon: UserRoundCog
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
@@ -45,7 +45,7 @@ onMounted(async () => {
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					  
 | 
				
			||||||
  { path: '/admin/teams/roles', label: 'Roles' },
 | 
					  { path: '/admin/teams/roles', label: 'Roles' },
 | 
				
			||||||
  { path: '#', label: 'Edit role' }
 | 
					  { path: '#', label: 'Edit role' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@ const emitter = useEmitter()
 | 
				
			|||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const formLoading = ref(false)
 | 
					const formLoading = ref(false)
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					  
 | 
				
			||||||
  { path: '/admin/teams/roles', label: 'Roles' },
 | 
					  { path: '/admin/teams/roles', label: 'Roles' },
 | 
				
			||||||
  { path: '#', label: 'Add role' }
 | 
					  { path: '#', label: 'Add role' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,7 @@
 | 
				
			|||||||
        </FormField>
 | 
					        </FormField>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading">{{ submitLabel }}</Button>
 | 
					    <Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,17 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="mb-5">
 | 
					  <PageHeader title="Roles" description="Manage roles" />
 | 
				
			||||||
    <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
					  <div class="w-8/12">
 | 
				
			||||||
  </div>
 | 
					    <div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
 | 
				
			||||||
      <div class="flex justify-end mb-5">
 | 
					      <div class="flex justify-end mb-5">
 | 
				
			||||||
    <Button @click="navigateToAddRole" size="sm"> New role </Button>
 | 
					        <Button @click="navigateToAddRole"> New role </Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Spinner v-if="isLoading"></Spinner>
 | 
					        <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
        <DataTable :columns="columns" :data="roles" v-else />
 | 
					        <DataTable :columns="columns" :data="roles" v-else />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
    <router-view></router-view>
 | 
					    <router-view></router-view>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -25,6 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
				
			|||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import PageHeader from '@/components/admin/common/PageHeader.vue'
 | 
				
			||||||
const { toast } = useToast()
 | 
					const { toast } = useToast()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = useEmitter()
 | 
					const emit = useEmitter()
 | 
				
			||||||
@@ -32,7 +35,7 @@ const router = useRouter()
 | 
				
			|||||||
const roles = ref([])
 | 
					const roles = ref([])
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					
 | 
				
			||||||
  { path: '#', label: 'Roles' }
 | 
					  { path: '#', label: 'Roles' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,6 @@ const formLoading = ref(false)
 | 
				
			|||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					 | 
				
			||||||
  { path: '/admin/teams/teams', label: 'Teams' },
 | 
					  { path: '/admin/teams/teams', label: 'Teams' },
 | 
				
			||||||
  { path: '/admin/teams/teams/new', label: 'New team' }
 | 
					  { path: '/admin/teams/teams/new', label: 'New team' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
@@ -39,7 +38,7 @@ const createTeam = async (values) => {
 | 
				
			|||||||
    router.push('/admin/teams/teams')
 | 
					    router.push('/admin/teams/teams')
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Something went wrong',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -22,11 +22,18 @@ const formLoading = ref(false)
 | 
				
			|||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					
 | 
				
			||||||
  { path: '/admin/teams/teams', label: 'Teams' },
 | 
					  { path: '/admin/teams/teams', label: 'Teams' },
 | 
				
			||||||
  { path: '#', label: 'Edit team' }
 | 
					  { path: '#', label: 'Edit team' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  id: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    required: true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const submitForm = (values) => {
 | 
					const submitForm = (values) => {
 | 
				
			||||||
  updateTeam(values)
 | 
					  updateTeam(values)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -41,7 +48,7 @@ const updateTeam = async (payload) => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Could not update team',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -57,7 +64,7 @@ onMounted(async () => {
 | 
				
			|||||||
    team.value = resp.data.data
 | 
					    team.value = resp.data.data
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Could not fetch team',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -65,11 +72,4 @@ onMounted(async () => {
 | 
				
			|||||||
    isLoading.value = false
 | 
					    isLoading.value = false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					 | 
				
			||||||
const props = defineProps({
 | 
					 | 
				
			||||||
  id: {
 | 
					 | 
				
			||||||
    type: String,
 | 
					 | 
				
			||||||
    required: true
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,9 +8,13 @@ import {
 | 
				
			|||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  team: {
 | 
					  team: {
 | 
				
			||||||
    type: Object,
 | 
					    type: Object,
 | 
				
			||||||
@@ -21,9 +25,28 @@ const props = defineProps({
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function editTeam(id) {
 | 
					function editTeam (id) {
 | 
				
			||||||
  router.push({ path: `/admin/teams/teams/${id}/edit` })
 | 
					  router.push({ path: `/admin/teams/teams/${id}/edit` })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function deleteTeam (id) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await api.deleteTeam(id)
 | 
				
			||||||
 | 
					    emitRefreshTeamList()
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      title: 'Error',
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emitRefreshTeamList = () => {
 | 
				
			||||||
 | 
					  emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
 | 
				
			||||||
 | 
					    model: 'team'
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -36,6 +59,7 @@ function editTeam(id) {
 | 
				
			|||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent>
 | 
					    <DropdownMenuContent>
 | 
				
			||||||
      <DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
 | 
					      <DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
 | 
				
			||||||
 | 
					      <DropdownMenuItem @click="deleteTeam(props.team.id)"> Delete </DropdownMenuItem>
 | 
				
			||||||
    </DropdownMenuContent>
 | 
					    </DropdownMenuContent>
 | 
				
			||||||
  </DropdownMenu>
 | 
					  </DropdownMenu>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,32 +11,93 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField name="auto_assign_conversations" v-slot="{ value, handleChange }">
 | 
					    <FormField name="conversation_assignment_type" v-slot="{ componentField }">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <div class="flex items-center space-x-2">
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
            <Checkbox :checked="value" @update:checked="handleChange" />
 | 
					            <SelectTrigger>
 | 
				
			||||||
            <Label>Auto assign conversations</Label>
 | 
					              <SelectValue placeholder="Select a assignment type" />
 | 
				
			||||||
          </div>
 | 
					            </SelectTrigger>
 | 
				
			||||||
 | 
					            <SelectContent>
 | 
				
			||||||
 | 
					              <SelectGroup>
 | 
				
			||||||
 | 
					                <SelectItem v-for="at in assignmentTypes" :key="at" :value="at">
 | 
				
			||||||
 | 
					                  {{ at }}
 | 
				
			||||||
 | 
					                </SelectItem>
 | 
				
			||||||
 | 
					              </SelectGroup>
 | 
				
			||||||
 | 
					            </SelectContent>
 | 
				
			||||||
 | 
					          </Select>
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormDescription>Automatically assign new conversations to agents in this team in a round-robin fashion.</FormDescription>
 | 
					        <FormDescription>
 | 
				
			||||||
 | 
					          Round robin: Conversations are assigned to team members in a round-robin fashion. <br>
 | 
				
			||||||
 | 
					          Manual: Conversations are manually assigned to team members.
 | 
				
			||||||
 | 
					        </FormDescription>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					    <FormField v-slot="{ componentField }" name="timezone">
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>Timezone</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
 | 
					            <SelectTrigger>
 | 
				
			||||||
 | 
					              <SelectValue placeholder="Select a timezone" />
 | 
				
			||||||
 | 
					            </SelectTrigger>
 | 
				
			||||||
 | 
					            <SelectContent>
 | 
				
			||||||
 | 
					              <SelectGroup>
 | 
				
			||||||
 | 
					                <SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
 | 
				
			||||||
 | 
					                  {{ timezone }}
 | 
				
			||||||
 | 
					                </SelectItem>
 | 
				
			||||||
 | 
					              </SelectGroup>
 | 
				
			||||||
 | 
					            </SelectContent>
 | 
				
			||||||
 | 
					          </Select>
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormDescription>Team's timezone will be used to calculate SLA.</FormDescription>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <FormField v-slot="{ componentField }" name="business_hours_id">
 | 
				
			||||||
 | 
					      <FormItem>
 | 
				
			||||||
 | 
					        <FormLabel>Business hours</FormLabel>
 | 
				
			||||||
 | 
					        <FormControl>
 | 
				
			||||||
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
 | 
					            <SelectTrigger>
 | 
				
			||||||
 | 
					              <SelectValue placeholder="Select business hours" />
 | 
				
			||||||
 | 
					            </SelectTrigger>
 | 
				
			||||||
 | 
					            <SelectContent>
 | 
				
			||||||
 | 
					              <SelectGroup>
 | 
				
			||||||
 | 
					                <SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
 | 
				
			||||||
 | 
					                  {{ bh.name }}
 | 
				
			||||||
 | 
					                </SelectItem>
 | 
				
			||||||
 | 
					              </SelectGroup>
 | 
				
			||||||
 | 
					            </SelectContent>
 | 
				
			||||||
 | 
					          </Select>
 | 
				
			||||||
 | 
					        </FormControl>
 | 
				
			||||||
 | 
					        <FormDescription>Default business hours.</FormDescription>
 | 
				
			||||||
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					      </FormItem>
 | 
				
			||||||
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch } from 'vue'
 | 
					import { watch, computed, ref, onMounted } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { teamFormSchema } from './teamFormSchema.js'
 | 
					import { teamFormSchema } from './teamFormSchema.js'
 | 
				
			||||||
import { Checkbox } from '@/components/ui/checkbox'
 | 
					 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					 | 
				
			||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
					import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Select,
 | 
				
			||||||
 | 
					  SelectContent,
 | 
				
			||||||
 | 
					  SelectGroup,
 | 
				
			||||||
 | 
					  SelectItem,
 | 
				
			||||||
 | 
					  SelectTrigger,
 | 
				
			||||||
 | 
					  SelectValue
 | 
				
			||||||
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  FormControl,
 | 
					  FormControl,
 | 
				
			||||||
  FormField,
 | 
					  FormField,
 | 
				
			||||||
@@ -45,8 +106,18 @@ import {
 | 
				
			|||||||
  FormMessage,
 | 
					  FormMessage,
 | 
				
			||||||
  FormDescription
 | 
					  FormDescription
 | 
				
			||||||
} from '@/components/ui/form'
 | 
					} from '@/components/ui/form'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					const timezones = computed(() => {
 | 
				
			||||||
 | 
					  return Intl.supportedValuesOf('timeZone')
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					const assignmentTypes = ['Round robin', 'Manual']
 | 
				
			||||||
 | 
					const businessHours = ref([])
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  initialValues: {
 | 
					  initialValues: {
 | 
				
			||||||
    type: Object,
 | 
					    type: Object,
 | 
				
			||||||
@@ -71,6 +142,32 @@ const form = useForm({
 | 
				
			|||||||
  validationSchema: toTypedSchema(teamFormSchema)
 | 
					  validationSchema: toTypedSchema(teamFormSchema)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  fetchBusinessHours()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fetchBusinessHours = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await api.getAllBusinessHours()
 | 
				
			||||||
 | 
					    businessHours.value = response.data.data
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    // If unauthorized (no permission), show a toast message.
 | 
				
			||||||
 | 
					    if (error.response.status === 403) {
 | 
				
			||||||
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					        title: 'Unauthorized',
 | 
				
			||||||
 | 
					        variant: 'destructive',
 | 
				
			||||||
 | 
					        description: 'You do not have permission to view business hours.'
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					        title: 'Could not fetch business hours',
 | 
				
			||||||
 | 
					        variant: 'destructive',
 | 
				
			||||||
 | 
					        description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit((values) => {
 | 
					const onSubmit = form.handleSubmit((values) => {
 | 
				
			||||||
  props.submitForm(values)
 | 
					  props.submitForm(values)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -79,6 +176,7 @@ const onSubmit = form.handleSubmit((values) => {
 | 
				
			|||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.initialValues,
 | 
					  () => props.initialValues,
 | 
				
			||||||
  (newValues) => {
 | 
					  (newValues) => {
 | 
				
			||||||
 | 
					    if (Object.keys(newValues).length === 0) return
 | 
				
			||||||
    form.setValues(newValues)
 | 
					    form.setValues(newValues)
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  { immediate: true }
 | 
					  { immediate: true }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,9 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="mb-5">
 | 
					  <PageHeader title="Teams" description="Manage teams" />
 | 
				
			||||||
    <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
					  <div class="w-8/12">
 | 
				
			||||||
  </div>
 | 
					    <div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
 | 
				
			||||||
      <div class="flex justify-end mb-5">
 | 
					      <div class="flex justify-end mb-5">
 | 
				
			||||||
    <Button @click="navigateToAddTeam" size="sm"> New team </Button>
 | 
					        <Button @click="navigateToAddTeam"> New team </Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
@@ -11,13 +11,15 @@
 | 
				
			|||||||
          <DataTable :columns="columns" :data="data" v-else />
 | 
					          <DataTable :columns="columns" :data="data" v-else />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
  <div>
 | 
					    </div>
 | 
				
			||||||
 | 
					    <template v-else>
 | 
				
			||||||
        <router-view></router-view>
 | 
					        <router-view></router-view>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted } from 'vue'
 | 
					import { ref, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
 | 
					import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
 | 
				
			||||||
import { useToast } from '@/components/ui/toast/use-toast'
 | 
					import { useToast } from '@/components/ui/toast/use-toast'
 | 
				
			||||||
@@ -25,14 +27,18 @@ import { Button } from '@/components/ui/button'
 | 
				
			|||||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
					import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
				
			||||||
import DataTable from '@/components/admin/DataTable.vue'
 | 
					import DataTable from '@/components/admin/DataTable.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import PageHeader from '@/components/admin/common/PageHeader.vue'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					  
 | 
				
			||||||
  { path: '/admin/teams/', label: 'Teams' }
 | 
					  { path: '/admin/teams/', label: 'Teams' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = useEmitter()
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const data = ref([])
 | 
					const data = ref([])
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
@@ -45,7 +51,7 @@ const getData = async () => {
 | 
				
			|||||||
    data.value = response.data.data
 | 
					    data.value = response.data.data
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    toast({
 | 
					    toast({
 | 
				
			||||||
      title: 'Could not fetch teams.',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -58,7 +64,24 @@ const navigateToAddTeam = () => {
 | 
				
			|||||||
  router.push('/admin/teams/teams/new')
 | 
					  router.push('/admin/teams/teams/new')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const listenForRefresh = () => {
 | 
				
			||||||
 | 
					  emit.on(EMITTER_EVENTS.REFRESH_LIST, (event) => {
 | 
				
			||||||
 | 
					    if (event.model === 'team') {
 | 
				
			||||||
 | 
					      getData()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const removeListeners = () => {
 | 
				
			||||||
 | 
					  emit.off(EMITTER_EVENTS.REFRESH_LIST)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(async () => {
 | 
					onMounted(async () => {
 | 
				
			||||||
  getData()
 | 
					  getData()
 | 
				
			||||||
 | 
					  listenForRefresh()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  removeListeners()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@ export const columns = [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, 'Modified at')
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,5 +8,7 @@ export const teamFormSchema = z.object({
 | 
				
			|||||||
    .min(2, {
 | 
					    .min(2, {
 | 
				
			||||||
      message: 'Team name must be at least 2 characters.'
 | 
					      message: 'Team name must be at least 2 characters.'
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
  auto_assign_conversations: z.boolean().optional()
 | 
					  conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
 | 
				
			||||||
 | 
					  business_hours_id : z.number({ required_error: 'Business hours is required.' }),
 | 
				
			||||||
 | 
					  timezone: z.string().optional(),
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,6 @@ const { toast } = useToast()
 | 
				
			|||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const formLoading = ref(false)
 | 
					const formLoading = ref(false)
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					 | 
				
			||||||
  { path: '/admin/teams/users', label: 'Users' },
 | 
					  { path: '/admin/teams/users', label: 'Users' },
 | 
				
			||||||
  { path: '#', label: 'Add user' }
 | 
					  { path: '#', label: 'Add user' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@ const formLoading = ref(false)
 | 
				
			|||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					  
 | 
				
			||||||
  { path: '/admin/teams/users', label: 'Users' },
 | 
					  { path: '/admin/teams/users', label: 'Users' },
 | 
				
			||||||
  { path: '#', label: 'Edit user' }
 | 
					  { path: '#', label: 'Edit user' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -73,7 +73,7 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,19 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="mb-5">
 | 
					  <PageHeader title="Users" description="Manage users" />
 | 
				
			||||||
    <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
					  <div class="w-8/12">
 | 
				
			||||||
  </div>
 | 
					    <div v-if="router.currentRoute.value.path === '/admin/teams/users'">
 | 
				
			||||||
      <div class="flex justify-end mb-5">
 | 
					      <div class="flex justify-end mb-5">
 | 
				
			||||||
    <Button @click="navigateToAddUser" size="sm"> New user </Button>
 | 
					        <Button @click="navigateToAddUser"> New user </Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Spinner v-if="isLoading"></Spinner>
 | 
					        <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
        <DataTable :columns="columns" :data="data" v-else />
 | 
					        <DataTable :columns="columns" :data="data" v-else />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <template v-else>
 | 
				
			||||||
      <router-view></router-view>
 | 
					      <router-view></router-view>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -22,6 +26,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
 | 
				
			|||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
					import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
 | 
				
			||||||
 | 
					import PageHeader from '@/components/admin/common/PageHeader.vue'
 | 
				
			||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
@@ -32,7 +37,7 @@ const isLoading = ref(false)
 | 
				
			|||||||
const data = ref([])
 | 
					const data = ref([])
 | 
				
			||||||
const emit = useEmitter()
 | 
					const emit = useEmitter()
 | 
				
			||||||
const breadcrumbLinks = [
 | 
					const breadcrumbLinks = [
 | 
				
			||||||
  { path: '/admin/teams', label: 'Teams' },
 | 
					  
 | 
				
			||||||
  { path: '#', label: 'Users' }
 | 
					  { path: '#', label: 'Users' }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,7 +33,7 @@ export const columns = [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, 'Modified at')
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,24 +4,37 @@
 | 
				
			|||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>Name</FormLabel>
 | 
					        <FormLabel>Name</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="Template name" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="Template name" v-bind="componentField" :disabled="!isOutgoingTemplate" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div v-if="!isOutgoingTemplate">
 | 
				
			||||||
 | 
					      <FormField v-slot="{ componentField }" name="subject">
 | 
				
			||||||
 | 
					        <FormItem>
 | 
				
			||||||
 | 
					          <FormLabel>Subject</FormLabel>
 | 
				
			||||||
 | 
					          <FormControl>
 | 
				
			||||||
 | 
					            <Input type="text" placeholder="Subject for email" v-bind="componentField" />
 | 
				
			||||||
 | 
					          </FormControl>
 | 
				
			||||||
 | 
					          <FormMessage />
 | 
				
			||||||
 | 
					        </FormItem>
 | 
				
			||||||
 | 
					      </FormField>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField, handleChange }" name="body">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="body">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Body</FormLabel>
 | 
					        <FormLabel>Body</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
 | 
					          <CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormDescription>{{ `Make sure the template has \{\{ template "content" . \}\}` }}</FormDescription>
 | 
					        <FormDescription v-if="isOutgoingTemplate">{{ `Make sure the template has \{\{ template "content" . \}\}` }}
 | 
				
			||||||
 | 
					        </FormDescription>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField name="is_default" v-slot="{ value, handleChange }">
 | 
					    <FormField name="is_default" v-slot="{ value, handleChange }" v-if="isOutgoingTemplate">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <div class="flex items-center space-x-2">
 | 
					          <div class="flex items-center space-x-2">
 | 
				
			||||||
@@ -34,12 +47,12 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch } from 'vue'
 | 
					import { watch, computed } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
@@ -85,6 +98,10 @@ const onSubmit = form.handleSubmit((values) => {
 | 
				
			|||||||
  props.submitForm(values)
 | 
					  props.submitForm(values)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isOutgoingTemplate = computed(() => {
 | 
				
			||||||
 | 
					  return props.initialValues?.type === 'email_outgoing'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Watch for changes in initialValues and update the form.
 | 
					// Watch for changes in initialValues and update the form.
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.initialValues,
 | 
					  () => props.initialValues,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,30 +1,55 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div>
 | 
					  <PageHeader title="Email templates" description="Manage email templates" />
 | 
				
			||||||
 | 
					  <div class="w-8/12">
 | 
				
			||||||
 | 
					    <template v-if="router.currentRoute.value.path === '/admin/templates'">
 | 
				
			||||||
      <div class="flex justify-between mb-5">
 | 
					      <div class="flex justify-between mb-5">
 | 
				
			||||||
      <PageHeader title="Email Templates" description="Manage outgoing email templates" />
 | 
					        <div></div>
 | 
				
			||||||
        <div class="flex justify-end mb-4">
 | 
					        <div class="flex justify-end mb-4">
 | 
				
			||||||
        <Button @click="navigateToAddTemplate" size="sm"> New template </Button>
 | 
					          <Button @click="navigateToAddTemplate"> New template </Button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Spinner v-if="isLoading"></Spinner>
 | 
					        <Spinner v-if="isLoading"></Spinner>
 | 
				
			||||||
      <DataTable :columns="columns" :data="templates" />
 | 
					        <Tabs default-value="email_outgoing" v-model="templateType">
 | 
				
			||||||
 | 
					          <TabsList class="grid w-full grid-cols-2 mb-5">
 | 
				
			||||||
 | 
					            <TabsTrigger value="email_outgoing">Outgoing email templates</TabsTrigger>
 | 
				
			||||||
 | 
					            <TabsTrigger value="email_notification">Email notification templates</TabsTrigger>
 | 
				
			||||||
 | 
					          </TabsList>
 | 
				
			||||||
 | 
					          <TabsContent value="email_outgoing">
 | 
				
			||||||
 | 
					            <DataTable :columns="outgoingEmailTemplatesColumns" :data="templates" />
 | 
				
			||||||
 | 
					          </TabsContent>
 | 
				
			||||||
 | 
					          <TabsContent value="email_notification">
 | 
				
			||||||
 | 
					            <DataTable :columns="emailNotificationTemplates" :data="templates" />
 | 
				
			||||||
 | 
					          </TabsContent>
 | 
				
			||||||
 | 
					        </Tabs>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					    <template v-else>
 | 
				
			||||||
 | 
					      <router-view/>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, onUnmounted } from 'vue'
 | 
					import { ref, onMounted, onUnmounted, watch } from 'vue'
 | 
				
			||||||
import DataTable from '@/components/admin/DataTable.vue'
 | 
					import DataTable from '@/components/admin/DataTable.vue'
 | 
				
			||||||
import { columns } from '@/components/admin/templates/dataTableColumns.js'
 | 
					import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
 | 
					import PageHeader from '@/components/admin/common/PageHeader.vue'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Tabs,
 | 
				
			||||||
 | 
					  TabsContent,
 | 
				
			||||||
 | 
					  TabsList,
 | 
				
			||||||
 | 
					  TabsTrigger,
 | 
				
			||||||
 | 
					} from '@/components/ui/tabs'
 | 
				
			||||||
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const templateType = useStorage('templateType', 'email_outgoing')
 | 
				
			||||||
const templates = ref([])
 | 
					const templates = ref([])
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
@@ -42,7 +67,7 @@ onUnmounted(() => {
 | 
				
			|||||||
const fetchAll = async () => {
 | 
					const fetchAll = async () => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    isLoading.value = true
 | 
					    isLoading.value = true
 | 
				
			||||||
    const resp = await api.getTemplates()
 | 
					    const resp = await api.getTemplates(templateType.value)
 | 
				
			||||||
    templates.value = resp.data.data
 | 
					    templates.value = resp.data.data
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
    isLoading.value = false
 | 
					    isLoading.value = false
 | 
				
			||||||
@@ -56,4 +81,8 @@ const refreshList = (data) => {
 | 
				
			|||||||
const navigateToAddTemplate = () => {
 | 
					const navigateToAddTemplate = () => {
 | 
				
			||||||
  router.push('/admin/templates/new')
 | 
					  router.push('/admin/templates/new')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watch(templateType, () => {
 | 
				
			||||||
 | 
					  fetchAll()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@ import { h } from 'vue'
 | 
				
			|||||||
import dropdown from './dataTableDropdown.vue'
 | 
					import dropdown from './dataTableDropdown.vue'
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const columns = [
 | 
					export const outgoingEmailTemplatesColumns = [
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'name',
 | 
					    accessorKey: 'name',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
@@ -30,7 +30,44 @@ export const columns = [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    header: function () {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, 'Modified at')
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    id: 'actions',
 | 
				
			||||||
 | 
					    enableHiding: false,
 | 
				
			||||||
 | 
					    cell: ({ row }) => {
 | 
				
			||||||
 | 
					      const template = row.original
 | 
				
			||||||
 | 
					      return h(
 | 
				
			||||||
 | 
					        'div',
 | 
				
			||||||
 | 
					        { class: 'relative' },
 | 
				
			||||||
 | 
					        h(dropdown, {
 | 
				
			||||||
 | 
					          template
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const emailNotificationTemplates = [
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    accessorKey: 'name',
 | 
				
			||||||
 | 
					    header: function () {
 | 
				
			||||||
 | 
					      return h('div', { class: 'text-center' }, 'Name')
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
 | 
					      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
 | 
					    header: function () {
 | 
				
			||||||
 | 
					      return h('div', { class: 'text-center' }, 'Updated at')
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,7 +37,7 @@ const deleteTemplate = async (id) => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Could not delete template',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(error).message
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,23 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const formSchema = z.object({
 | 
					export const formSchema = z
 | 
				
			||||||
 | 
					  .object({
 | 
				
			||||||
    name: z.string({
 | 
					    name: z.string({
 | 
				
			||||||
    required_error: 'Template name is required.'
 | 
					      required_error: 'Template name is required.',
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
    body: z.string({
 | 
					    body: z.string({
 | 
				
			||||||
    required_error: 'Template content is required.'
 | 
					      required_error: 'Template content is required.',
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
  is_default: z.boolean().optional()
 | 
					    type: z.string().optional(),
 | 
				
			||||||
})
 | 
					    subject: z.string().optional(),
 | 
				
			||||||
 | 
					    is_default: z.boolean().optional(),
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .superRefine((data, ctx) => {
 | 
				
			||||||
 | 
					    if (data.type !== 'email_outgoing' && !data.subject) {
 | 
				
			||||||
 | 
					      ctx.addIssue({
 | 
				
			||||||
 | 
					        path: ['subject'],
 | 
				
			||||||
 | 
					        message: 'Subject is required.',
 | 
				
			||||||
 | 
					        code: z.ZodIssueCode.custom,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,7 +34,7 @@
 | 
				
			|||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
    <Button type="submit" size="sm"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -121,7 +121,7 @@
 | 
				
			|||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
    <Button type="submit" size="sm"> {{ submitLabel }} </Button>
 | 
					    <Button type="submit"> {{ submitLabel }} </Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,75 +1,75 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div v-for="(filter, index) in modelValue" :key="index">
 | 
					  <div class="space-y-4">
 | 
				
			||||||
    <div class="flex items-center space-x-2 mb-2 flex-row justify-between">
 | 
					    <div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
 | 
				
			||||||
      <div class="w-1/3">
 | 
					      <div class="grid grid-cols-3 gap-2 w-full">
 | 
				
			||||||
        <Select v-model="filter.field" @update:modelValue="updateFieldModel(filter, $event)">
 | 
					        <!-- Field -->
 | 
				
			||||||
          <SelectTrigger class="w-full">
 | 
					        <Select v-model="modelFilter.field">
 | 
				
			||||||
            <SelectValue placeholder="Select Field" />
 | 
					          <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
 | 
				
			||||||
 | 
					            <SelectValue placeholder="Field" />
 | 
				
			||||||
          </SelectTrigger>
 | 
					          </SelectTrigger>
 | 
				
			||||||
          <SelectContent>
 | 
					          <SelectContent>
 | 
				
			||||||
            <SelectGroup>
 | 
					            <SelectGroup>
 | 
				
			||||||
              <SelectItem v-for="field in fields" :key="field.value" :value="field.value">
 | 
					              <SelectItem v-for="field in fields" :key="field.field" :value="field.field">
 | 
				
			||||||
                {{ field.label }}
 | 
					                {{ field.label }}
 | 
				
			||||||
              </SelectItem>
 | 
					              </SelectItem>
 | 
				
			||||||
            </SelectGroup>
 | 
					            </SelectGroup>
 | 
				
			||||||
          </SelectContent>
 | 
					          </SelectContent>
 | 
				
			||||||
        </Select>
 | 
					        </Select>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="w-1/3">
 | 
					        <!-- Operator -->
 | 
				
			||||||
        <Select v-model="filter.operator">
 | 
					        <Select v-model="modelFilter.operator" v-if="modelFilter.field">
 | 
				
			||||||
          <SelectTrigger class="w-full">
 | 
					          <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
 | 
				
			||||||
            <SelectValue placeholder="Select Operator" />
 | 
					            <SelectValue placeholder="Operator" />
 | 
				
			||||||
          </SelectTrigger>
 | 
					          </SelectTrigger>
 | 
				
			||||||
          <SelectContent>
 | 
					          <SelectContent>
 | 
				
			||||||
            <SelectGroup>
 | 
					            <SelectGroup>
 | 
				
			||||||
              <SelectItem v-for="operator in getFieldOperators(filter.field)" :key="operator.value"
 | 
					              <SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
 | 
				
			||||||
                :value="operator.value">
 | 
					                {{ op }}
 | 
				
			||||||
                {{ operator.label }}
 | 
					 | 
				
			||||||
              </SelectItem>
 | 
					              </SelectItem>
 | 
				
			||||||
            </SelectGroup>
 | 
					            </SelectGroup>
 | 
				
			||||||
          </SelectContent>
 | 
					          </SelectContent>
 | 
				
			||||||
        </Select>
 | 
					        </Select>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div v-if="getFieldType(filter.field) === 'text'" class="w-1/3">
 | 
					        <!-- Value -->
 | 
				
			||||||
        <Input v-model="filter.value" type="text" placeholder="Value" class="w-full" />
 | 
					        <div class="w-full" v-if="modelFilter.field && modelFilter.operator">
 | 
				
			||||||
      </div>
 | 
					          <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
				
			||||||
      <div v-else-if="getFieldType(filter.field) === 'select'" class="w-1/3">
 | 
					            <Select v-if="getFieldOptions(modelFilter).length > 0" v-model="modelFilter.value">
 | 
				
			||||||
        <Select v-model="filter.value">
 | 
					              <SelectTrigger class="bg-transparent hover:bg-slate-100">
 | 
				
			||||||
          <SelectTrigger class="w-full">
 | 
					                <SelectValue placeholder="Select value" />
 | 
				
			||||||
            <SelectValue placeholder="Select Value" />
 | 
					 | 
				
			||||||
              </SelectTrigger>
 | 
					              </SelectTrigger>
 | 
				
			||||||
              <SelectContent>
 | 
					              <SelectContent>
 | 
				
			||||||
                <SelectGroup>
 | 
					                <SelectGroup>
 | 
				
			||||||
              <SelectItem v-for="option in getFieldOptions(filter.field)" :key="option.value" :value="option.value">
 | 
					                  <SelectItem v-for="opt in getFieldOptions(modelFilter)" :key="opt.value" :value="opt.value">
 | 
				
			||||||
                {{ option.label }}
 | 
					                    {{ opt.label }}
 | 
				
			||||||
                  </SelectItem>
 | 
					                  </SelectItem>
 | 
				
			||||||
                </SelectGroup>
 | 
					                </SelectGroup>
 | 
				
			||||||
              </SelectContent>
 | 
					              </SelectContent>
 | 
				
			||||||
            </Select>
 | 
					            </Select>
 | 
				
			||||||
 | 
					            <Input v-else v-model="modelFilter.value" class="bg-transparent hover:bg-slate-100" placeholder="Value"
 | 
				
			||||||
 | 
					              type="text" />
 | 
				
			||||||
 | 
					          </template>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      <div v-else-if="getFieldType(filter.field) === 'number'" class="w-1/3">
 | 
					 | 
				
			||||||
        <Input v-model="filter.value" type="number" placeholder="Value" class="w-full" />
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <button v-if="modelValue.length > 1" @click="removeFilter(index)"
 | 
					
 | 
				
			||||||
        class="flex items-center justify-center w-3 h-3 rounded-full bg-red-100 hover:bg-red-200 transition-colors">
 | 
					      <button v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
 | 
				
			||||||
        <X class="text-slate-400" />
 | 
					        <X class="w-4 h-4 text-slate-500" />
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="flex items-center justify-between pt-3">
 | 
				
			||||||
 | 
					      <Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
 | 
				
			||||||
 | 
					        <Plus class="w-3 h-3 mr-1" /> Add filter
 | 
				
			||||||
 | 
					      </Button>
 | 
				
			||||||
 | 
					      <div class="flex gap-2" v-if="showButtons">
 | 
				
			||||||
 | 
					        <Button  variant="ghost" @click="clearFilters">Reset</Button>
 | 
				
			||||||
 | 
					        <Button  @click="applyFilters">Apply</Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
  <div class="flex justify-between mt-4">
 | 
					 | 
				
			||||||
    <Button size="sm" @click="addFilter">Add Filter</Button>
 | 
					 | 
				
			||||||
    <div class="flex justify-end space-x-4">
 | 
					 | 
				
			||||||
      <Button size="sm" @click="applyFilters">Apply</Button>
 | 
					 | 
				
			||||||
      <Button size="sm" @click="clearFilters">Clear</Button>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed, onMounted } from 'vue'
 | 
					import { computed, onMounted, watch, onUnmounted } from 'vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -78,7 +78,7 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue,
 | 
					  SelectValue,
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					import { Plus, X } 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'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -87,29 +87,16 @@ const props = defineProps({
 | 
				
			|||||||
    type: Array,
 | 
					    type: Array,
 | 
				
			||||||
    required: true,
 | 
					    required: true,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  showButtons: {
 | 
				
			||||||
 | 
					    type: Boolean,
 | 
				
			||||||
 | 
					    default: true,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits(['apply', 'clear'])
 | 
					const emit = defineEmits(['apply', 'clear'])
 | 
				
			||||||
const modelValue = defineModel('modelValue', { required: true })
 | 
					const modelValue = defineModel('modelValue', { required: false, default: () => [] })
 | 
				
			||||||
const operatorsByType = {
 | 
					
 | 
				
			||||||
  text: [
 | 
					const createFilter = () => ({ field: '', operator: '', value: '' })
 | 
				
			||||||
    { label: 'Equals', value: '=' },
 | 
					 | 
				
			||||||
    { label: 'Not Equals', value: '!=' },
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  select: [
 | 
					 | 
				
			||||||
    { label: 'Equals', value: '=' },
 | 
					 | 
				
			||||||
    { label: 'Not Equals', value: '!=' },
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  number: [
 | 
					 | 
				
			||||||
    { label: 'Equals', value: '=' },
 | 
					 | 
				
			||||||
    { label: 'Not Equals', value: '!=' },
 | 
					 | 
				
			||||||
    { label: 'Greater Than', value: '>' },
 | 
					 | 
				
			||||||
    { label: 'Less Than', value: '<' },
 | 
					 | 
				
			||||||
    { label: 'Greater Than or Equal', value: '>=' },
 | 
					 | 
				
			||||||
    { label: 'Less Than or Equal', value: '<=' },
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
const createFilter = () => ({ model: '', field: '', operator: '', value: '' })
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  if (modelValue.value.length === 0) {
 | 
					  if (modelValue.value.length === 0) {
 | 
				
			||||||
@@ -117,46 +104,42 @@ onMounted(() => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const addFilter = () => {
 | 
					onUnmounted(() => {
 | 
				
			||||||
  modelValue.value.push(createFilter())
 | 
					  modelValue.value = []
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const removeFilter = (index) => {
 | 
					 | 
				
			||||||
  modelValue.value.splice(index, 1)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const applyFilters = () => {
 | 
					 | 
				
			||||||
  if (validFilters.value.length > 0) emit('apply', validFilters.value)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const validFilters = computed(() => {
 | 
					 | 
				
			||||||
  return modelValue.value.filter(filter => filter.field !== "" && filter.operator != "" && filter.value != "")
 | 
					 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getModel = (field) => {
 | 
				
			||||||
 | 
					  const fieldConfig = props.fields.find(f => f.field === field)
 | 
				
			||||||
 | 
					  return fieldConfig?.model || ''
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					watch(() => modelValue.value, (filters) => {
 | 
				
			||||||
 | 
					  filters.forEach(filter => {
 | 
				
			||||||
 | 
					    if (filter.field && !filter.model) {
 | 
				
			||||||
 | 
					      filter.model = getModel(filter.field)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}, { deep: true })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const addFilter = () => modelValue.value.push(createFilter())
 | 
				
			||||||
 | 
					const removeFilter = (index) => modelValue.value.splice(index, 1)
 | 
				
			||||||
 | 
					const applyFilters = () => emit('apply', validFilters.value)
 | 
				
			||||||
const clearFilters = () => {
 | 
					const clearFilters = () => {
 | 
				
			||||||
  modelValue.value = []
 | 
					  modelValue.value = []
 | 
				
			||||||
  emit('clear')
 | 
					  emit('clear')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getFieldOperators = computed(() => (fieldValue) => {
 | 
					const validFilters = computed(() => {
 | 
				
			||||||
  const field = props.fields.find(f => f.value === fieldValue)
 | 
					  return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
 | 
				
			||||||
  return field ? operatorsByType[field.type] : []
 | 
					 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getFieldType = computed(() => (fieldValue) => {
 | 
					const getFieldOptions = (fieldValue) => {
 | 
				
			||||||
  const field = props.fields.find(f => f.value === fieldValue)
 | 
					  const field = props.fields.find(f => f.field === fieldValue.field)
 | 
				
			||||||
  return field ? field.type : 'text'
 | 
					  return field?.options || []
 | 
				
			||||||
})
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getFieldOptions = computed(() => (fieldValue) => {
 | 
					const getFieldOperators = (modelFilter) => {
 | 
				
			||||||
  const field = props.fields.find(f => f.value === fieldValue)
 | 
					  const field = props.fields.find(f => f.field === modelFilter.field)
 | 
				
			||||||
  return field && field.options ? field.options : []
 | 
					  return field?.operators || []
 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const updateFieldModel = (filter, fieldValue) => {
 | 
					 | 
				
			||||||
  const field = props.fields.find(f => f.value === fieldValue)
 | 
					 | 
				
			||||||
  if (field) {
 | 
					 | 
				
			||||||
    filter.model = field.model
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										53
									
								
								frontend/src/components/common/SimpleTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								frontend/src/components/common/SimpleTable.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <table class="min-w-full divide-y divide-gray-200">
 | 
				
			||||||
 | 
					        <thead class="bg-gray-50">
 | 
				
			||||||
 | 
					            <tr>
 | 
				
			||||||
 | 
					                <th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
 | 
				
			||||||
 | 
					                    {{ header }}
 | 
				
			||||||
 | 
					                </th>
 | 
				
			||||||
 | 
					                <th scope="col" class="relative px-6 py-3"></th>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					        </thead>
 | 
				
			||||||
 | 
					        <tbody class="bg-white divide-y divide-gray-200">
 | 
				
			||||||
 | 
					            <tr v-for="(item, index) in data" :key="index">
 | 
				
			||||||
 | 
					                <td v-for="key in keys" :key="key" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
 | 
				
			||||||
 | 
					                    {{ item[key] }}
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
 | 
				
			||||||
 | 
					                    <Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
 | 
				
			||||||
 | 
					                        <Trash2 class="h-4 w-4" />
 | 
				
			||||||
 | 
					                    </Button>
 | 
				
			||||||
 | 
					                </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					        </tbody>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { Trash2 } from 'lucide-vue-next';
 | 
				
			||||||
 | 
					import { defineProps, defineEmits } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineProps({
 | 
				
			||||||
 | 
					    headers: {
 | 
				
			||||||
 | 
					        type: Array,
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        default: () => []
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    keys: {
 | 
				
			||||||
 | 
					        type: Array,
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        default: () => []
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    data: {
 | 
				
			||||||
 | 
					        type: Array,
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        default: () => []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits(['deleteItem']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function deleteItem(item) {
 | 
				
			||||||
 | 
					    emit('deleteItem', item);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -2,18 +2,19 @@
 | 
				
			|||||||
  <div class="relative" v-if="conversationStore.messages.data">
 | 
					  <div class="relative" v-if="conversationStore.messages.data">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Header -->
 | 
					    <!-- Header -->
 | 
				
			||||||
    <div class="px-4 border-b h-[47px] flex items-center justify-between shadow shadow-gray-100">
 | 
					    <div class="px-4 border-b h-[44px] flex items-center justify-between">
 | 
				
			||||||
      <div class="flex items-center space-x-3 text-sm">
 | 
					      <div class="flex items-center space-x-3 text-sm">
 | 
				
			||||||
        <div class="font-semibold">
 | 
					        <div class="font-medium">
 | 
				
			||||||
          {{ conversationStore.current.subject }}
 | 
					          {{ conversationStore.current.subject }}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <DropdownMenu>
 | 
					        <DropdownMenu>
 | 
				
			||||||
          <DropdownMenuTrigger>
 | 
					          <DropdownMenuTrigger>
 | 
				
			||||||
            <Badge variant="primary">
 | 
					            <div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
 | 
				
			||||||
              {{ conversationStore.current.status }}
 | 
					              <GalleryVerticalEnd size="14" class="text-secondary" />
 | 
				
			||||||
            </Badge>
 | 
					                <span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
          </DropdownMenuTrigger>
 | 
					          </DropdownMenuTrigger>
 | 
				
			||||||
          <DropdownMenuContent>
 | 
					          <DropdownMenuContent>
 | 
				
			||||||
            <DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
 | 
					            <DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
 | 
				
			||||||
@@ -37,13 +38,16 @@
 | 
				
			|||||||
import { ref, onMounted } from 'vue'
 | 
					import { ref, onMounted } from 'vue'
 | 
				
			||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
					import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import { Badge } from '@/components/ui/badge'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  GalleryVerticalEnd,
 | 
				
			||||||
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import MessageList from '@/components/message/MessageList.vue'
 | 
					import MessageList from '@/components/message/MessageList.vue'
 | 
				
			||||||
import ReplyBox from './ReplyBox.vue'
 | 
					import ReplyBox from './ReplyBox.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,44 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="h-screen flex flex-col">
 | 
					  <div class="h-screen flex flex-col">
 | 
				
			||||||
    <!-- Filters -->
 | 
					
 | 
				
			||||||
    <div class="shrink-0">
 | 
					    <div class="flex justify-between px-2 py-2 w-full">
 | 
				
			||||||
      <ConversationListFilters @updateFilters="handleUpdateFilters" />
 | 
					      <DropdownMenu>
 | 
				
			||||||
 | 
					        <DropdownMenuTrigger class="cursor-pointer">
 | 
				
			||||||
 | 
					          <Button variant="ghost">
 | 
				
			||||||
 | 
					            {{ conversationStore.getListStatus }}
 | 
				
			||||||
 | 
					            <ChevronDown class="w-4 h-4 ml-2" />
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </DropdownMenuTrigger>
 | 
				
			||||||
 | 
					        <DropdownMenuContent>
 | 
				
			||||||
 | 
					          <DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
 | 
				
			||||||
 | 
					            @click="handleStatusChange(status)">
 | 
				
			||||||
 | 
					            {{ status.label }}
 | 
				
			||||||
 | 
					          </DropdownMenuItem>
 | 
				
			||||||
 | 
					        </DropdownMenuContent>
 | 
				
			||||||
 | 
					      </DropdownMenu>
 | 
				
			||||||
 | 
					      <DropdownMenu>
 | 
				
			||||||
 | 
					        <DropdownMenuTrigger class="cursor-pointer">
 | 
				
			||||||
 | 
					          <Button variant="ghost">
 | 
				
			||||||
 | 
					            {{ conversationStore.getListSortField }}
 | 
				
			||||||
 | 
					            <ChevronDown class="w-4 h-4 ml-2" />
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </DropdownMenuTrigger>
 | 
				
			||||||
 | 
					        <DropdownMenuContent>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('started_first')">Started first</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('started_last')">Started last</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('waiting_longest')">Waiting longest</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('next_sla_target')">Next SLA target</DropdownMenuItem>
 | 
				
			||||||
 | 
					          <DropdownMenuItem @click="handleSortChange('priority_first')">Priority first</DropdownMenuItem>
 | 
				
			||||||
 | 
					        </DropdownMenuContent>
 | 
				
			||||||
 | 
					      </DropdownMenu>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Empty list -->
 | 
					    <!-- Empty -->
 | 
				
			||||||
    <EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
 | 
					    <EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
 | 
				
			||||||
      message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
 | 
					      message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    <!-- List -->
 | 
					    <!-- List -->
 | 
				
			||||||
    <div class="flex-grow overflow-y-auto">
 | 
					    <div class="flex-grow overflow-y-auto">
 | 
				
			||||||
      <EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
 | 
					      <EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
 | 
				
			||||||
@@ -17,12 +46,15 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <!-- Items -->
 | 
					      <!-- Items -->
 | 
				
			||||||
      <div v-else>
 | 
					      <div v-else>
 | 
				
			||||||
        <ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
 | 
					        <div class="space-y-5 px-2">
 | 
				
			||||||
          v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
 | 
					          <ConversationListItem class="mt-2" :conversation="conversation"
 | 
				
			||||||
 | 
					            :currentConversation="conversationStore.current"
 | 
				
			||||||
 | 
					            v-for="conversation in conversationStore.conversationsList" :key="conversation.uuid"
 | 
				
			||||||
            :contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
 | 
					            :contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- List skeleton -->
 | 
					      <!-- skeleton -->
 | 
				
			||||||
      <div v-if="isLoading">
 | 
					      <div v-if="isLoading">
 | 
				
			||||||
        <ConversationListItemSkeleton v-for="index in 10" :key="index" />
 | 
					        <ConversationListItemSkeleton v-for="index in 10" :key="index" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
@@ -46,40 +78,47 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { onMounted, computed, onUnmounted } from 'vue'
 | 
					import { onMounted, computed, onUnmounted } from 'vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import { MessageCircleQuestion, MessageCircleWarning } from 'lucide-vue-next'
 | 
					import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  DropdownMenu,
 | 
				
			||||||
 | 
					  DropdownMenuContent,
 | 
				
			||||||
 | 
					  DropdownMenuItem,
 | 
				
			||||||
 | 
					  DropdownMenuTrigger,
 | 
				
			||||||
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
 | 
					import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
 | 
				
			||||||
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
 | 
					import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
 | 
				
			||||||
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
 | 
					import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
 | 
				
			||||||
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const conversationStore = useConversationStore()
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
let listRefreshInterval = null
 | 
					let reFetchInterval = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Re-fetch conversations list every 30 seconds for any missed updates.
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  conversationStore.fetchConversationsList()
 | 
					  reFetchInterval = setInterval(() => {
 | 
				
			||||||
  // Refresh list every min.
 | 
					    conversationStore.reFetchConversationsList(false)
 | 
				
			||||||
  listRefreshInterval = setInterval(() => {
 | 
					  }, 30000)
 | 
				
			||||||
    conversationStore.fetchConversationsList(false)
 | 
					 | 
				
			||||||
  }, 60000)
 | 
					 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onUnmounted(() => {
 | 
					onUnmounted(() => {
 | 
				
			||||||
  clearInterval(listRefreshInterval)
 | 
					  clearInterval(reFetchInterval)
 | 
				
			||||||
  conversationStore.clearListReRenderInterval()
 | 
					  conversationStore.clearListReRenderInterval()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleStatusChange = (status) => {
 | 
				
			||||||
 | 
					  conversationStore.setListStatus(status.label)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleSortChange = (order) => {
 | 
				
			||||||
 | 
					  conversationStore.setListSortField(order)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const loadNextPage = () => {
 | 
					const loadNextPage = () => {
 | 
				
			||||||
  conversationStore.fetchNextConversations()
 | 
					  conversationStore.fetchNextConversations()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleUpdateFilters = (filters) => {
 | 
					 | 
				
			||||||
  console.log("setting ", filters)
 | 
					 | 
				
			||||||
  conversationStore.setConversationListFilters(filters)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const hasConversations = computed(() => {
 | 
					const hasConversations = computed(() => {
 | 
				
			||||||
  return conversationStore.sortedConversations.length !== 0
 | 
					  return conversationStore.conversationsList.length !== 0
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const hasErrored = computed(() => {
 | 
					const hasErrored = computed(() => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,5 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex justify-between px-2 py-2 border-b w-full">
 | 
					  <div class="flex justify-end px-2 py-2 border-b w-full">
 | 
				
			||||||
    <Tabs v-model="conversationStore.conversations.type">
 | 
					 | 
				
			||||||
      <TabsList class="w-full flex justify-evenly">
 | 
					 | 
				
			||||||
        <TabsTrigger value="assigned" class="w-full">Assigned</TabsTrigger>
 | 
					 | 
				
			||||||
        <TabsTrigger value="unassigned" class="w-full">Unassigned</TabsTrigger>
 | 
					 | 
				
			||||||
        <TabsTrigger value="all" class="w-full">All</TabsTrigger>
 | 
					 | 
				
			||||||
      </TabsList>
 | 
					 | 
				
			||||||
    </Tabs>
 | 
					 | 
				
			||||||
    <Popover v-model:open="open">
 | 
					    <Popover v-model:open="open">
 | 
				
			||||||
      <PopoverTrigger as-child>
 | 
					      <PopoverTrigger as-child>
 | 
				
			||||||
        <div class="flex items-center mr-2 relative">
 | 
					        <div class="flex items-center mr-2 relative">
 | 
				
			||||||
@@ -25,10 +18,25 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted } from 'vue'
 | 
					import { ref, onMounted } from 'vue'
 | 
				
			||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
					import {
 | 
				
			||||||
 | 
					  DropdownMenu,
 | 
				
			||||||
 | 
					  DropdownMenuContent,
 | 
				
			||||||
 | 
					  DropdownMenuGroup,
 | 
				
			||||||
 | 
					  DropdownMenuItem,
 | 
				
			||||||
 | 
					  DropdownMenuLabel,
 | 
				
			||||||
 | 
					  DropdownMenuPortal,
 | 
				
			||||||
 | 
					  DropdownMenuSeparator,
 | 
				
			||||||
 | 
					  DropdownMenuShortcut,
 | 
				
			||||||
 | 
					  DropdownMenuSub,
 | 
				
			||||||
 | 
					  DropdownMenuSubContent,
 | 
				
			||||||
 | 
					  DropdownMenuSubTrigger,
 | 
				
			||||||
 | 
					  DropdownMenuTrigger,
 | 
				
			||||||
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 | 
					import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 | 
				
			||||||
import { ListFilter } from 'lucide-vue-next'
 | 
					import { ListFilter, ChevronDown } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import { SidebarTrigger } from '@/components/ui/sidebar'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import Filter from '@/components/common/Filter.vue'
 | 
					import Filter from '@/components/common/Filter.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -44,6 +52,14 @@ onMounted(() => {
 | 
				
			|||||||
  localFilters.value = [...conversationStore.conversations.filters]
 | 
					  localFilters.value = [...conversationStore.conversations.filters]
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleStatusChange = (status) => {
 | 
				
			||||||
 | 
					  console.log('status', status)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleSortChange = (order) => {
 | 
				
			||||||
 | 
					  console.log('order', order)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const fetchInitialData = async () => {
 | 
					const fetchInitialData = async () => {
 | 
				
			||||||
  const [statusesResp, prioritiesResp] = await Promise.all([
 | 
					  const [statusesResp, prioritiesResp] = await Promise.all([
 | 
				
			||||||
    api.getStatuses(),
 | 
					    api.getStatuses(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
 | 
					  <div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
 | 
				
			||||||
    :class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
 | 
					    :class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
 | 
				
			||||||
    @click="router.push('/conversations/' + conversation.uuid)">
 | 
					    @click="router.push('/conversations/' + conversation.uuid)">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="pl-3">
 | 
					    <div class="pl-3">
 | 
				
			||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
      </Avatar>
 | 
					      </Avatar>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="ml-3 w-full border-b pb-2">
 | 
					    <div class="ml-3 w-full pb-2">
 | 
				
			||||||
      <div class="flex justify-between pt-2 pr-3">
 | 
					      <div class="flex justify-between pt-2 pr-3">
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <p class="text-xs text-gray-600 flex gap-x-1">
 | 
					          <p class="text-xs text-gray-600 flex gap-x-1">
 | 
				
			||||||
@@ -42,6 +42,10 @@
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="flex space-x-2 mt-2">
 | 
				
			||||||
 | 
					        <SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
 | 
				
			||||||
 | 
					        <SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -52,6 +56,7 @@ import { useRouter } from 'vue-router'
 | 
				
			|||||||
import { formatTime } from '@/utils/datetime'
 | 
					import { formatTime } from '@/utils/datetime'
 | 
				
			||||||
import { Mail, CheckCheck } from 'lucide-vue-next'
 | 
					import { Mail, CheckCheck } from 'lucide-vue-next'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			||||||
 | 
					import SlaDisplay from '@/components/sla/SlaDisplay.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,17 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="flex flex-col gap-1 mb-5">
 | 
				
			||||||
 | 
					        <p class="font-medium">SLA policy</p>
 | 
				
			||||||
 | 
					        <p v-if="conversation.sla_policy_name">
 | 
				
			||||||
 | 
					            {{ conversation.sla_policy_name }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					        <p v-else>-</p>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="flex flex-col gap-1 mb-5">
 | 
					    <div class="flex flex-col gap-1 mb-5">
 | 
				
			||||||
        <p class="font-medium">Reference number</p>
 | 
					        <p class="font-medium">Reference number</p>
 | 
				
			||||||
        <p>
 | 
					        <p>
 | 
				
			||||||
            #{{ conversation.reference_number }}
 | 
					            {{ conversation.reference_number }}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div class="flex flex-col gap-1 mb-5">
 | 
					    <div class="flex flex-col gap-1 mb-5">
 | 
				
			||||||
@@ -14,6 +23,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <div class="flex flex-col gap-1 mb-5">
 | 
					    <div class="flex flex-col gap-1 mb-5">
 | 
				
			||||||
        <p class="font-medium">First reply at</p>
 | 
					        <p class="font-medium">First reply at</p>
 | 
				
			||||||
 | 
					        <SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" />
 | 
				
			||||||
        <p v-if="conversation.first_reply_at">
 | 
					        <p v-if="conversation.first_reply_at">
 | 
				
			||||||
            {{ format(conversation.first_reply_at, 'PPpp') }}
 | 
					            {{ format(conversation.first_reply_at, 'PPpp') }}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
@@ -22,6 +32,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <div class="flex flex-col gap-1 mb-5">
 | 
					    <div class="flex flex-col gap-1 mb-5">
 | 
				
			||||||
        <p class="font-medium">Resolved at</p>
 | 
					        <p class="font-medium">Resolved at</p>
 | 
				
			||||||
 | 
					        <SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
 | 
				
			||||||
        <p v-if="conversation.resolved_at">
 | 
					        <p v-if="conversation.resolved_at">
 | 
				
			||||||
            {{ format(conversation.resolved_at, 'PPpp') }}
 | 
					            {{ format(conversation.resolved_at, 'PPpp') }}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
@@ -35,10 +46,12 @@
 | 
				
			|||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
        <p v-else>-</p>
 | 
					        <p v-else>-</p>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
 | 
					import SlaDisplay from '@/components/sla/SlaDisplay.vue'
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
    conversation: Object
 | 
					    conversation: Object
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,29 +1,27 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex gap-x-5">
 | 
					  <div class="flex">
 | 
				
			||||||
    <Card class="w-1/6 box" v-for="(value, key) in counts" :key="key">
 | 
					    <div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5">
 | 
				
			||||||
      <CardHeader>
 | 
					      <div class="flex items-center space-x-2">
 | 
				
			||||||
        <CardTitle class="text-2xl">
 | 
					        <p class="text-2xl">{{title}}</p>
 | 
				
			||||||
          {{ value }}
 | 
					        <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
 | 
				
			||||||
        </CardTitle>
 | 
					          <span class="blinking-dot"></span>
 | 
				
			||||||
        <CardDescription>
 | 
					          <strong class="uppercase tracking-wider">Live</strong>
 | 
				
			||||||
          {{ labels[key] }}
 | 
					        </div>
 | 
				
			||||||
        </CardDescription>
 | 
					      </div>
 | 
				
			||||||
      </CardHeader>
 | 
					      <div class="flex">
 | 
				
			||||||
    </Card>
 | 
					        <div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
 | 
				
			||||||
 | 
					          <span class="text-muted-foreground">{{ labels[key] }}</span>
 | 
				
			||||||
 | 
					          <span class="text-2xl font-medium">{{ value }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
  counts: {
 | 
					  counts: { type: Object, required: true },
 | 
				
			||||||
    type: Object,
 | 
					  labels: { type: Object, required: true },
 | 
				
			||||||
    required: true
 | 
					  title: { type: String, required: true }
 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  labels: {
 | 
					 | 
				
			||||||
    type: Object,
 | 
					 | 
				
			||||||
    required: true
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,9 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <div class="flex flex-col space-y-6" v-if="userStore.getFullName">
 | 
					    <div class="flex flex-col space-y-6" v-if="userStore.getFullName">
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
            <span class="font-medium text-3xl space-y-1">
 | 
					            <span class="font-medium text-xl space-y-1">
 | 
				
			||||||
                <p>Hi, {{ userStore.getFullName }}</p>
 | 
					                <p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
 | 
				
			||||||
                <p class="text-sm-muted">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
 | 
					                <p class="text-muted-foreground text-lg">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
 | 
				
			||||||
            </span>
 | 
					            </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user