WIP: MVP with shadcn sidebar

- csat
- SLA
- email notification templates
This commit is contained in:
Abhinav Raut
2025-01-06 02:39:44 +05:30
parent 48e89dc4b9
commit caf8e7d34d
212 changed files with 9141 additions and 2222 deletions

View File

@@ -3,8 +3,8 @@ package main
import (
"strconv"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -55,7 +55,12 @@ func handleOIDCCallback(r *fastglue.Request) error {
}
// 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
}

104
cmd/business_hours.go Normal file
View 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)
}

View File

@@ -3,12 +3,15 @@ package main
import (
"encoding/json"
"strconv"
"time"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/automation/models"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/envelope"
umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
@@ -23,13 +26,23 @@ func handleGetAllConversations(r *fastglue.Request) error {
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
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 {
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,
@@ -43,21 +56,29 @@ func handleGetAllConversations(r *fastglue.Request) error {
func handleGetAssignedConversations(r *fastglue.Request) error {
var (
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"))
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")))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
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 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
}
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,
@@ -71,21 +92,130 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
func handleGetUnassignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
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")))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
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 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
}
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,
})
}
// 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{
Results: conversations,
Total: total,
@@ -98,29 +228,55 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
// handleGetConversation retrieves a single conversation by UUID with permission checks.
func handleGetConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
conversation, err := enforceConversationAccess(app, uuid, user)
user, err := app.user.Get(auser.ID)
if err != nil {
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)
}
// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation.
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
_, err := enforceConversationAccess(app, uuid, user)
user, err := app.user.Get(auser.ID)
if err != nil {
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 {
return sendErrorEnvelope(r, err)
}
@@ -130,14 +286,25 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
// handleGetConversationParticipants retrieves participants of a conversation.
func handleGetConversationParticipants(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
_, err := enforceConversationAccess(app, uuid, user)
user, err := app.user.Get(auser.ID)
if err != nil {
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)
if err != nil {
return sendErrorEnvelope(r, err)
@@ -148,21 +315,33 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateConversationUserAssignee updates the user assigned to a conversation.
func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
if assigneeID == 0 {
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 {
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 {
return sendErrorEnvelope(r, err)
}
@@ -176,18 +355,31 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
// handleUpdateTeamAssignee updates the team assigned to a conversation.
func handleUpdateTeamAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
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 {
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 {
return sendErrorEnvelope(r, err)
}
@@ -204,12 +396,23 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
p = r.RequestCtx.PostArgs()
priority = p.Peek("priority")
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 {
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 {
return sendErrorEnvelope(r, err)
}
@@ -223,23 +426,33 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
// handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
status = p.Peek("status")
uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
status = p.Peek("status")
snoozedUntil = p.Peek("snoozed_until")
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
_, err := enforceConversationAccess(app, uuid, user)
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
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)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
return r.SendEnvelope(true)
}
@@ -250,7 +463,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
p = r.RequestCtx.PostArgs()
tagIDs = []int{}
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)
)
@@ -261,11 +474,24 @@ func handleAddConversationTags(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
}
_, err = enforceConversationAccess(app, uuid, user)
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
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 {
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.
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 {
return nil, err
}
@@ -311,3 +537,15 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
}
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
View 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")
}

View File

@@ -12,129 +12,158 @@ import (
"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.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/login", handleLogin)
g.POST("/api/v1/login", handleLogin)
g.GET("/logout", handleLogout)
g.GET("/api/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/oidc/finish", handleOIDCCallback)
// Health check.
g.GET("/health", handleHealthCheck)
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/finish", handleOIDCCallback)
// Serve media files.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
// Settings.
g.GET("/api/settings/general", handleGetGeneralSettings)
g.PUT("/api/settings/general", authPerm(handleUpdateGeneralSettings, "settings_general", "write"))
g.GET("/api/settings/notifications/email", authPerm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
g.PUT("/api/settings/notifications/email", authPerm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
// OpenID SSO.
g.GET("/api/oidc", handleGetAllOIDC)
g.GET("/api/oidc/{id}", authPerm(handleGetOIDC, "oidc", "read"))
g.POST("/api/oidc", authPerm(handleCreateOIDC, "oidc", "write"))
g.PUT("/api/oidc/{id}", authPerm(handleUpdateOIDC, "oidc", "write"))
g.DELETE("/api/oidc/{id}", authPerm(handleDeleteOIDC, "oidc", "delete"))
// OpenID connect single sign-on.
g.GET("/api/v1/oidc", handleGetAllOIDC)
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc", "write"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
// Conversation and message.
g.GET("/api/conversations/all", authPerm(handleGetAllConversations, "conversations", "read_all"))
g.GET("/api/conversations/unassigned", authPerm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
g.GET("/api/conversations/assigned", authPerm(handleGetAssignedConversations, "conversations", "read_assigned"))
g.GET("/api/conversations/{uuid}", authPerm(handleGetConversation, "conversations", "read"))
g.GET("/api/conversations/{uuid}/participants", authPerm(handleGetConversationParticipants, "conversations", "read"))
g.PUT("/api/conversations/{uuid}/assignee/user", authPerm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
g.PUT("/api/conversations/{uuid}/assignee/team", authPerm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
g.PUT("/api/conversations/{uuid}/priority", authPerm(handleUpdateConversationPriority, "conversations", "update_priority"))
g.PUT("/api/conversations/{uuid}/status", authPerm(handleUpdateConversationStatus, "conversations", "update_status"))
g.PUT("/api/conversations/{uuid}/last-seen", authPerm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
g.POST("/api/conversations/{uuid}/tags", authPerm(handleAddConversationTags, "conversations", "update_tags"))
g.POST("/api/conversations/{cuuid}/messages", authPerm(handleSendMessage, "messages", "write"))
g.GET("/api/conversations/{uuid}/messages", authPerm(handleGetMessages, "messages", "read"))
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authPerm(handleRetryMessage, "messages", "write"))
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authPerm(handleGetMessage, "messages", "read"))
// All.
g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
// Not assigned to any user or team.
g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
// Assigned to logged in user.
g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
// Unassigned conversations assigned to a team.
g.GET("/api/v1/teams/{team_id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations", "read_assigned"))
// Filtered by view.
g.GET("/api/v1/views/{view_id}/conversations", perm(handleGetViewConversations, "conversations", "read"))
g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
g.GET("/api/v1/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
g.PUT("/api/v1/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
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.
g.GET("/api/statuses", auth(handleGetStatuses))
g.POST("/api/statuses", authPerm(handleCreateStatus, "status", "write"))
g.PUT("/api/statuses/{id}", authPerm(handleUpdateStatus, "status", "write"))
g.DELETE("/api/statuses/{id}", authPerm(handleDeleteStatus, "status", "delete"))
g.GET("/api/priorities", auth(handleGetPriorities))
g.GET("/api/v1/statuses", auth(handleGetStatuses))
g.POST("/api/v1/statuses", perm(handleCreateStatus, "status", "write"))
g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
g.GET("/api/v1/priorities", auth(handleGetPriorities))
// Tag.
g.GET("/api/tags", auth(handleGetTags))
g.POST("/api/tags", authPerm(handleCreateTag, "tags", "write"))
g.PUT("/api/tags/{id}", authPerm(handleUpdateTag, "tags", "write"))
g.DELETE("/api/tags/{id}", authPerm(handleDeleteTag, "tags", "delete"))
g.GET("/api/v1/tags", auth(handleGetTags))
g.POST("/api/v1/tags", perm(handleCreateTag, "tags", "write"))
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags", "write"))
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
// Media.
g.POST("/api/media", auth(handleMediaUpload))
g.POST("/api/v1/media", auth(handleMediaUpload))
// Canned response.
g.GET("/api/canned-responses", auth(handleGetCannedResponses))
g.POST("/api/canned-responses", authPerm(handleCreateCannedResponse, "canned_responses", "write"))
g.PUT("/api/canned-responses/{id}", authPerm(handleUpdateCannedResponse, "canned_responses", "write"))
g.DELETE("/api/canned-responses/{id}", authPerm(handleDeleteCannedResponse, "canned_responses", "delete"))
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
// User.
g.GET("/api/users/me", auth(handleGetCurrentUser))
g.PUT("/api/users/me", auth(handleUpdateCurrentUser))
g.DELETE("/api/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/users/compact", auth(handleGetUsersCompact))
g.GET("/api/users", authPerm(handleGetUsers, "users", "read"))
g.GET("/api/users/{id}", authPerm(handleGetUser, "users", "read"))
g.POST("/api/users", authPerm(handleCreateUser, "users", "write"))
g.PUT("/api/users/{id}", authPerm(handleUpdateUser, "users", "write"))
g.DELETE("/api/users/{id}", authPerm(handleDeleteUser, "users", "delete"))
g.POST("/api/users/reset-password", tryAuth(handleResetPassword))
g.POST("/api/users/set-password", tryAuth(handleSetPassword))
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users", "read"))
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users", "read"))
g.POST("/api/v1/users", perm(handleCreateUser, "users", "write"))
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users", "write"))
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users", "delete"))
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
// Team.
g.GET("/api/teams/compact", auth(handleGetTeamsCompact))
g.GET("/api/teams", authPerm(handleGetTeams, "teams", "read"))
g.POST("/api/teams", authPerm(handleCreateTeam, "teams", "write"))
g.GET("/api/teams/{id}", authPerm(handleGetTeam, "teams", "read"))
g.PUT("/api/teams/{id}", authPerm(handleUpdateTeam, "teams", "write"))
g.DELETE("/api/teams/{id}", authPerm(handleDeleteTeam, "teams", "delete"))
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
g.GET("/api/v1/teams", perm(handleGetTeams, "teams", "read"))
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams", "read"))
g.POST("/api/v1/teams", perm(handleCreateTeam, "teams", "write"))
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams", "delete"))
// i18n.
g.GET("/api/lang/{lang}", handleGetI18nLang)
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Automation.
g.GET("/api/automation/rules", authPerm(handleGetAutomationRules, "automations", "read"))
g.GET("/api/automation/rules/{id}", authPerm(handleGetAutomationRule, "automations", "read"))
g.POST("/api/automation/rules", authPerm(handleCreateAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}/toggle", authPerm(handleToggleAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}", authPerm(handleUpdateAutomationRule, "automations", "write"))
g.DELETE("/api/automation/rules/{id}", authPerm(handleDeleteAutomationRule, "automations", "delete"))
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
// Inbox.
g.GET("/api/inboxes", authPerm(handleGetInboxes, "inboxes", "read"))
g.GET("/api/inboxes/{id}", authPerm(handleGetInbox, "inboxes", "read"))
g.POST("/api/inboxes", authPerm(handleCreateInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}/toggle", authPerm(handleToggleInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}", authPerm(handleUpdateInbox, "inboxes", "write"))
g.DELETE("/api/inboxes/{id}", authPerm(handleDeleteInbox, "inboxes", "delete"))
g.GET("/api/v1/inboxes", perm(handleGetInboxes, "inboxes", "read"))
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes", "write"))
g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
// Role.
g.GET("/api/roles", authPerm(handleGetRoles, "roles", "read"))
g.GET("/api/roles/{id}", authPerm(handleGetRole, "roles", "read"))
g.POST("/api/roles", authPerm(handleCreateRole, "roles", "write"))
g.PUT("/api/roles/{id}", authPerm(handleUpdateRole, "roles", "write"))
g.DELETE("/api/roles/{id}", authPerm(handleDeleteRole, "roles", "delete"))
g.GET("/api/v1/roles", perm(handleGetRoles, "roles", "read"))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles", "read"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles", "write"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles", "write"))
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
// Dashboard.
g.GET("/api/dashboard/global/counts", authPerm(handleDashboardCounts, "dashboard_global", "read"))
g.GET("/api/dashboard/global/charts", authPerm(handleDashboardCharts, "dashboard_global", "read"))
g.GET("/api/v1/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
g.GET("/api/v1/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
// Template.
g.GET("/api/templates", authPerm(handleGetTemplates, "templates", "read"))
g.GET("/api/templates/{id}", authPerm(handleGetTemplate, "templates", "read"))
g.POST("/api/templates", authPerm(handleCreateTemplate, "templates", "write"))
g.PUT("/api/templates/{id}", authPerm(handleUpdateTemplate, "templates", "write"))
g.DELETE("/api/templates/{id}", authPerm(handleDeleteTemplate, "templates", "delete"))
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates", "read"))
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates", "read"))
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates", "write"))
g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
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.
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("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage))
g.GET("/assets/{all:*}", serveStaticFiles)
g.GET("/images/{all:*}", serveStaticFiles)
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
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.
@@ -186,6 +223,29 @@ func serveStaticFiles(r *fastglue.Request) error {
// Get the requested file 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.
finalPath := filepath.Join(frontendDir, filePath)
file, err := app.fs.Get(finalPath)

View File

@@ -16,11 +16,12 @@ import (
"github.com/abhinavxd/artemis/internal/authz"
"github.com/abhinavxd/artemis/internal/autoassigner"
"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/contact"
"github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/priority"
"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/channel/email"
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
@@ -32,10 +33,13 @@ import (
"github.com/abhinavxd/artemis/internal/oidc"
"github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/sla"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
tmpl "github.com/abhinavxd/artemis/internal/template"
"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/jmoiron/sqlx"
"github.com/knadh/go-i18n"
@@ -189,9 +193,19 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
}
// initConversations inits conversation manager.
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sqlx.DB, contactStore *contact.Manager,
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, contactStore, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
func initConversations(
i18n *i18n.I18n,
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,
Lo: initLogger("conversation_manager"),
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
}
// initTags inits tag manager.
func initTags(db *sqlx.DB) *tag.Manager {
// initTag inits tag manager.
func initTag(db *sqlx.DB) *tag.Manager {
var lo = initLogger("tag_manager")
mgr, err := tag.New(tag.Opts{
DB: db,
@@ -216,6 +230,19 @@ func initTags(db *sqlx.DB) *tag.Manager {
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.
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
var lo = initLogger("canned-response")
@@ -229,26 +256,62 @@ func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
return c
}
func initContact(db *sqlx.DB) *contact.Manager {
var lo = initLogger("contact-manager")
m, err := contact.New(contact.Opts{
// initBusinessHours inits business hours manager.
func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
var lo = initLogger("business-hours")
m, err := businesshours.New(businesshours.Opts{
DB: db,
Lo: lo,
})
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
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
lo := initLogger("template")
tpls, err := stuffbin.ParseTemplatesGlob(getTmplFuncs(consts), fs, "/static/email-templates/*.html")
var (
lo = initLogger("template")
funcMap = getTmplFuncs(consts)
)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil {
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 {
log.Fatalf("error initializing template manager: %v", err)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"log"
"strings"
@@ -12,13 +13,13 @@ import (
)
// 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)
if err != nil {
log.Fatalf("error checking db schema: %v", err)
}
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)? ")
var ok string
fmt.Scanf("%s", &ok)
@@ -35,15 +36,15 @@ func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
log.Println("Schema installed successfully")
// 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)
}
return nil
}
// setSystemUserPass prompts for pass and sets system user password.
func setSystemUserPass(db *sqlx.DB) {
user.ChangeSystemUserPassword(db)
func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
user.ChangeSystemUserPassword(ctx, db)
}
// checkSchema verifies if the DB schema is already installed by querying a table.

View File

@@ -1,12 +1,13 @@
package main
import (
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleLogin logs in the user.
// handleLogin logs a user in.
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -14,11 +15,16 @@ func handleLogin(r *fastglue.Request) error {
email = string(p.Peek("email"))
password = p.Peek("password")
)
user, err := app.user.Login(email, password)
user, err := app.user.VerifyPassword(email, password)
if err != nil {
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)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
}

View File

@@ -10,11 +10,15 @@ import (
auth_ "github.com/abhinavxd/artemis/internal/auth"
"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"
"github.com/abhinavxd/artemis/internal/sla"
"github.com/abhinavxd/artemis/internal/view"
"github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/priority"
"github.com/abhinavxd/artemis/internal/conversation/status"
@@ -45,33 +49,37 @@ var (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
consts constants
fs stuffbin.FileSystem
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
contact *contact.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
cannedResp *cannedresp.Manager
conversation *conversation.Manager
automation *automation.Engine
notifier *notifier.Service
consts constants
fs stuffbin.FileSystem
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
cannedResp *cannedresp.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
notifier *notifier.Service
}
func main() {
// 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.
initFlags()
@@ -95,13 +103,13 @@ func main() {
// Installer.
if ko.Bool("install") {
install(db, fs)
install(ctx, db, fs)
os.Exit(0)
}
// Set system user password.
if ko.Bool("set-system-user-password") {
setSystemUserPass(db)
setSystemUserPass(ctx, db)
os.Exit(0)
}
@@ -132,19 +140,20 @@ func main() {
auth = initAuth(oidc, rdb)
template = initTemplate(db, fs, constants)
media = initMedia(db)
contact = initContact(db)
inbox = initInbox(db)
team = initTeam(db)
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
notifier = initNotifier(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)
)
// Set stores.
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation)
automation.SetConversationStore(conversation, sla)
// Start inbox receivers.
startInboxes(ctx, inbox, conversation)
@@ -161,37 +170,45 @@ func main() {
// Start notifier.
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)
// Init the app
var app = &App{
lo: lo,
auth: auth,
fs: fs,
i18n: i18n,
media: media,
setting: settings,
contact: contact,
inbox: inbox,
user: user,
team: team,
tmpl: template,
conversation: conversation,
automation: automation,
oidc: oidc,
consts: constants,
notifier: notifier,
authz: initAuthz(),
status: initStatus(db),
priority: initPriority(db),
role: initRole(db),
tag: initTags(db),
cannedResp: initCannedResponse(db),
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
tmpl: template,
notifier: notifier,
consts: constants,
conversation: conversation,
automation: automation,
businessHours: businessHours,
view: initView(db),
csat: initCSAT(db),
authz: initAuthz(),
status: initStatus(db),
priority: initPriority(db),
role: initRole(db),
tag: initTag(db),
cannedResp: initCannedResponse(db),
}
// Init fastglue and set app in ctx.
g := fastglue.NewGlue()
// Set the app in context.
g.SetContext(app)
// Init HTTP handlers.
@@ -206,24 +223,37 @@ func main() {
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() {
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
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()
log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m")
colorlog.Red("Shutting down the server. Please wait....")
// Shutdown HTTP server.
s.Shutdown()
colorlog.Red("Server shutdown complete.")
colorlog.Red("Shutting down services. Please wait....")
// Shutdown services.
inbox.Close()
colorlog.Red("Inbox shutdown complete.")
automation.Close()
colorlog.Red("Automation shutdown complete.")
autoassigner.Close()
colorlog.Red("Autoassigner shutdown complete.")
notifier.Close()
colorlog.Red("Notifier shutdown complete.")
conversation.Close()
colorlog.Red("Conversation shutdown complete.")
sla.Close()
colorlog.Red("SLA shutdown complete.")
db.Close()
colorlog.Red("Database shutdown complete.")
rdb.Close()
colorlog.Red("Redis shutdown complete.")
colorlog.Green("Shutdown complete.")
}

View File

@@ -10,10 +10,10 @@ import (
"slices"
"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/image"
"github.com/abhinavxd/artemis/internal/stringutil"
umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/google/uuid"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -145,11 +145,16 @@ func handleMediaUpload(r *fastglue.Request) error {
// handleServeMedia serves uploaded media.
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {

View File

@@ -3,10 +3,10 @@ package main
import (
"strconv"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/automation/models"
"github.com/abhinavxd/artemis/internal/envelope"
medModels "github.com/abhinavxd/artemis/internal/media/models"
umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -22,14 +22,19 @@ func handleGetMessages(r *fastglue.Request) error {
var (
app = r.Context.(*App)
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")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err := enforceConversationAccess(app, uuid, user)
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -60,11 +65,15 @@ func handleGetMessage(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(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
_, err := enforceConversationAccess(app, cuuid, user)
_, err = enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -87,11 +96,16 @@ func handleRetryMessage(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(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
_, err := enforceConversationAccess(app, cuuid, user)
_, err = enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -106,15 +120,20 @@ func handleRetryMessage(r *fastglue.Request) error {
// handleSendMessage sends a message in a conversation.
func handleSendMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
req = messageReq{}
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
req = messageReq{}
media = []medModels.Media{}
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err := enforceConversationAccess(app, cuuid, user)
_, err = enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"net/http"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -27,7 +28,12 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// 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)
}
@@ -52,25 +58,30 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if err != nil {
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)
}
}
// authPerm does session validation, CSRF, and permission enforcement.
func authPerm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
// perm does session validation, CSRF, and permission enforcement.
func perm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
app = r.Context.(*App)
// cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
// hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
}
// if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
// app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
// return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
// }
// Validate session and fetch user.
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.
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)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetGeneralSettings fetches general settings.
func handleGetGeneralSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -22,6 +23,7 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
return r.SendEnvelope(out)
}
// handleUpdateGeneralSettings updates general settings.
func handleUpdateGeneralSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -35,9 +37,10 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
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 {
var (
app = r.Context.(*App)
@@ -59,6 +62,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
return r.SendEnvelope(notif)
}
// handleUpdateEmailNotificationSettings updates email notification settings.
func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -86,5 +90,5 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope("Settings updated successfully")
}

106
cmd/sla.go Normal file
View 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)
}

View File

@@ -1,15 +1,14 @@
package main
import (
"fmt"
"strconv"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/team/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetTeams returns a list of all teams.
func handleGetTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -21,6 +20,7 @@ func handleGetTeams(r *fastglue.Request) error {
return r.SendEnvelope(teams)
}
// handleGetTeamsCompact returns a list of all teams in a compact format.
func handleGetTeamsCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -32,6 +32,7 @@ func handleGetTeamsCompact(r *fastglue.Request) error {
return r.SendEnvelope(teams)
}
// handleGetTeam returns a single team.
func handleGetTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -41,35 +42,38 @@ func handleGetTeam(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid team `id`.", nil, envelope.InputError)
}
team, err := app.team.GetTeam(id)
team, err := app.team.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(team)
}
// handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = models.Team{}
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
)
if _, err := fastglue.ScanArgs(r.RequestCtx.PostArgs(), &req, `json`); err != nil {
app.lo.Error("error scanning args", "error", err)
return envelope.NewError(envelope.InputError,
fmt.Sprintf("Invalid request (%s)", err.Error()), nil)
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
if err != nil || businessHrsID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
}
err := app.team.CreateTeam(req)
if err != nil {
if err := app.team.Create(name, timezone, conversationAssignmentType, businessHrsID); err != nil {
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 {
var (
app = r.Context.(*App)
req = models.Team{}
app = r.Context.(*App)
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))
if err != nil || id == 0 {
@@ -77,11 +81,12 @@ func handleUpdateTeam(r *fastglue.Request) error {
"Invalid team `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return envelope.NewError(envelope.InputError, "Bad request", nil)
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
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 r.SendEnvelope(true)
@@ -97,9 +102,9 @@ func handleDeleteTeam(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid team `id`.", nil, envelope.InputError)
}
err = app.team.DeleteTeam(id)
err = app.team.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope("Team deleted successfully.")
}

View File

@@ -9,17 +9,23 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetTemplates returns all templates.
func handleGetTemplates(r *fastglue.Request) error {
var (
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 {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(t)
}
// handleGetTemplate returns a template by id.
func handleGetTemplate(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -36,6 +42,7 @@ func handleGetTemplate(r *fastglue.Request) error {
return r.SendEnvelope(t)
}
// handleCreateTemplate creates a new template.
func handleCreateTemplate(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -44,14 +51,13 @@ func handleCreateTemplate(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
err := app.tmpl.Create(req)
if err != nil {
if err := app.tmpl.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateTemplate updates a template.
func handleUpdateTemplate(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -62,17 +68,16 @@ func handleUpdateTemplate(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
if err = app.tmpl.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteTemplate deletes a template.
func handleDeleteTemplate(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -83,11 +88,9 @@ func handleDeleteTemplate(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
if err = app.tmpl.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -8,13 +8,14 @@ import (
"strconv"
"strings"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/image"
mmodels "github.com/abhinavxd/artemis/internal/media/models"
notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/abhinavxd/artemis/internal/stringutil"
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/zerodha/fastglue"
)
@@ -23,6 +24,7 @@ const (
maxAvatarSizeMB = 5
)
// handleGetUsers returns all users.
func handleGetUsers(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -61,11 +63,33 @@ func handleGetUser(r *fastglue.Request) error {
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 {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
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)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
@@ -144,17 +168,17 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
func handleCreateUser(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = umodels.User{}
user = models.User{}
)
if err := r.Decode(&user, "json"); err != nil {
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)
}
err := app.user.Create(&user)
err := app.user.CreateAgent(&user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -172,7 +196,7 @@ func handleCreateUser(r *fastglue.Request) error {
}
// 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,
"Email": user.Email,
})
@@ -198,7 +222,7 @@ func handleCreateUser(r *fastglue.Request) error {
func handleUpdateUser(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = umodels.User{}
user = models.User{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
@@ -252,9 +276,13 @@ func handleDeleteUser(r *fastglue.Request) error {
// handleGetCurrentUser returns the current logged in user.
func handleGetCurrentUser(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
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)
}
u, err := app.user.Get(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
@@ -265,12 +293,12 @@ func handleGetCurrentUser(r *fastglue.Request) error {
// handleDeleteAvatar deletes a user avatar.
func handleDeleteAvatar(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
// Get user
user, err := app.user.Get(user.ID)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -296,13 +324,12 @@ func handleDeleteAvatar(r *fastglue.Request) error {
// handleResetPassword generates a reset password token and sends an email to the user.
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
user, ok = r.RequestCtx.UserValue("user").(umodels.User)
email = string(p.Peek("email"))
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
email = string(p.Peek("email"))
)
if ok && user.ID > 0 {
if ok && auser.ID > 0 {
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.
content, err := app.tmpl.Render(tmpl.TmplResetPassword,
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
map[string]string{
"ResetToken": token,
})
@@ -347,7 +374,7 @@ func handleResetPassword(r *fastglue.Request) error {
func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user, ok = r.RequestCtx.UserValue("user").(umodels.User)
user, ok = r.RequestCtx.UserValue("user").(amodels.User)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))

139
cmd/views.go Normal file
View 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)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
umodels "github.com/abhinavxd/artemis/internal/user/models"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/ws"
wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
"github.com/fasthttp/websocket"
@@ -26,10 +26,14 @@ var upgrader = websocket.FastHTTPUpgrader{
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
var (
user = r.RequestCtx.UserValue("user").(umodels.User)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
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{
ID: user.ID,
Hub: hub,

View File

@@ -35,7 +35,7 @@
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^11.2.0",
"@vueuse/core": "^12.2.0",
"add": "^2.0.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -59,6 +59,7 @@
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",
"vue-router": "^4.2.5",
"vue-sonner": "^1.3.0",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex">
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
class="shadow shadow-gray-300 h-screen" />
<Toaster />
<Sidebar :isLoading="false" :open="sidebarOpen" :userTeams="userStore.teams" :userViews="userViews" @update:open="sidebarOpen = $event"
@create-view="openCreateViewForm = true" @edit-view="editView" @delete-view="deleteView">
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2">
@@ -9,75 +9,94 @@
<RouterView />
</div>
</ResizablePanel>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</ResizablePanelGroup>
</div>
</Sidebar>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
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 emitter = useEmitter()
const isCollapsed = 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 sidebarOpen = ref(true)
const userStore = useUserStore()
const conversationStore = useConversationStore()
const router = useRouter()
initWS()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
initWS()
onMounted(() => {
initToaster()
listenViewRefresh()
getCurrentUser()
getUserViews()
intiStores()
})
onUnmounted(() => {
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 = () => {
userStore.getCurrentUser().catch((err) => {
if (err.response && err.response.status === 401) {
@@ -90,9 +109,14 @@ const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
}
const navLinks = computed(() =>
allNavLinks.filter((link) =>
!link.permission || (userStore.permissions.includes(link.permission) && link.permission)
)
)
const listenViewRefresh = () => {
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
}
const refreshViews = (data) => {
openCreateViewForm.value = false
if (data?.model === 'view') {
getUserViews()
}
}
</script>

View File

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

View File

@@ -33,177 +33,214 @@ http.interceptors.request.use((request) => {
return request
})
const resetPassword = (data) => http.post('/api/users/reset-password', data)
const setPassword = (data) => http.post('/api/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/users/${id}`)
const getEmailNotificationSettings = () => http.get('/api/settings/notifications/email')
const updateEmailNotificationSettings = (data) => http.put('/api/settings/notifications/email', data)
const getPriorities = () => http.get('/api/priorities')
const getStatuses = () => http.get('/api/statuses')
const createStatus = (data) => http.post('/api/statuses', data)
const updateStatus = (id, data) => http.put(`/api/statuses/${id}`, data)
const deleteStatus = (id) => http.delete(`/api/statuses/${id}`)
const createTag = (data) => http.post('/api/tags', data)
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
const getTemplate = (id) => http.get(`/api/templates/${id}`)
const getTemplates = () => http.get('/api/templates')
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
const getPriorities = () => http.get('/api/v1/priorities')
const getStatuses = () => http.get('/api/v1/statuses')
const createStatus = (data) => http.post('/api/v1/statuses', data)
const updateStatus = (id, data) => http.put(`/api/v1/statuses/${id}`, data)
const deleteStatus = (id) => http.delete(`/api/v1/statuses/${id}`)
const createTag = (data) => http.post('/api/v1/tags', data)
const updateTag = (id, data) => http.put(`/api/v1/tags/${id}`, data)
const deleteTag = (id) => http.delete(`/api/v1/tags/${id}`)
const getTemplate = (id) => http.get(`/api/v1/templates/${id}`)
const getTemplates = (type) => http.get('/api/v1/templates', { params: { type: type } })
const createTemplate = (data) =>
http.post('/api/templates', data, {
http.post('/api/v1/templates', data, {
headers: {
'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) =>
http.put(`/api/templates/${id}`, data, {
http.put(`/api/v1/templates/${id}`, data, {
headers: {
'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) =>
http.post('/api/oidc', data, {
http.post('/api/v1/oidc', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAllOIDC = () => http.get('/api/oidc')
const getOIDC = (id) => http.get(`/api/oidc/${id}`)
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
const updateOIDC = (id, data) =>
http.put(`/api/oidc/${id}`, data, {
http.put(`/api/v1/oidc/${id}`, data, {
headers: {
'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) =>
http.put(`/api/settings/${key}`, data, {
http.put(`/api/v1/settings/${key}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getSettings = (key) => http.get(`/api/settings/${key}`)
const login = (data) => http.post(`/api/login`, data)
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
const getAutomationRules = (type) =>
http.get(`/api/automation/rules`, {
http.get(`/api/v1/automation/rules`, {
params: { type: type }
})
const toggleAutomationRule = (id) => http.put(`/api/automation/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/automation/rules/${id}`)
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
const updateAutomationRule = (id, data) =>
http.put(`/api/automation/rules/${id}`, data, {
http.put(`/api/v1/automation/rules/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createAutomationRule = (data) =>
http.post(`/api/automation/rules`, data, {
http.post(`/api/v1/automation/rules`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getRoles = () => http.get('/api/roles')
const getRole = (id) => http.get(`/api/roles/${id}`)
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
http.post('/api/roles', data, {
http.post('/api/v1/roles', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateRole = (id, data) =>
http.put(`/api/roles/${id}`, data, {
http.put(`/api/v1/roles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteRole = (id) => http.delete(`/api/roles/${id}`)
const deleteAutomationRule = (id) => http.delete(`/api/automation/rules/${id}`)
const getUser = (id) => http.get(`/api/users/${id}`)
const getTeam = (id) => http.get(`/api/teams/${id}`)
const getTeams = () => http.get('/api/teams')
const getTeamsCompact = () => http.get('/api/teams/compact')
const getUsers = () => http.get('/api/users')
const getUsersCompact = () => http.get('/api/users/compact')
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
const getUser = (id) => http.get(`/api/v1/users/${id}`)
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
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) =>
http.put('/api/users/me', data, {
http.put('/api/v1/users/me', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const deleteUserAvatar = () => http.delete('/api/users/me/avatar')
const getCurrentUser = () => http.get('/api/users/me')
const getTags = () => http.get('/api/tags')
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
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) =>
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
const updateConversationStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) => http.get(`/api/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) => http.put(`/api/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, page) =>
http.get(`/api/conversations/${uuid}/messages`, {
params: { page: page }
})
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const sendMessage = (uuid, data) =>
http.post(`/api/conversations/${uuid}/messages`, data, {
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/canned-responses')
const createCannedResponse = (data) => http.post('/api/canned-responses', data)
const updateCannedResponse = (id, data) => http.put(`/api/canned-responses/${id}`, data)
const deleteCannedResponse = (id) => http.delete(`/api/canned-responses/${id}`)
const getAssignedConversations = (params) =>
http.get('/api/conversations/assigned', { params })
const getUnassignedConversations = (params) =>
http.get('/api/conversations/unassigned', { params })
const getAllConversations = (params) =>
http.get('/api/conversations/all', { params })
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/v1/canned-responses')
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
const uploadMedia = (data) =>
http.post('/api/media', data, {
http.post('/api/v1/media', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts')
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts')
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`)
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`)
const getLanguage = (lang) => http.get(`/api/lang/${lang}`)
const getGlobalDashboardCounts = () => http.get('/api/v1/dashboard/global/counts')
const getGlobalDashboardCharts = () => http.get('/api/v1/dashboard/global/charts')
const getUserDashboardCounts = () => http.get(`/api/v1/dashboard/me/counts`)
const getUserDashboardCharts = () => http.get(`/api/v1/dashboard/me/charts`)
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createUser = (data) =>
http.post('/api/users', data, {
http.post('/api/v1/users', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateUser = (id, data) =>
http.put(`/api/users/${id}`, data, {
http.put(`/api/v1/users/${id}`, data, {
headers: {
'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) =>
http.post('/api/inboxes', data, {
http.post('/api/v1/inboxes', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getInboxes = () => http.get('/api/inboxes')
const getInbox = (id) => http.get(`/api/inboxes/${id}`)
const toggleInbox = (id) => http.put(`/api/inboxes/${id}/toggle`)
const getInboxes = () => http.get('/api/v1/inboxes')
const getInbox = (id) => http.get(`/api/v1/inboxes/${id}`)
const toggleInbox = (id) => http.put(`/api/v1/inboxes/${id}/toggle`)
const updateInbox = (id, data) =>
http.put(`/api/inboxes/${id}`, data, {
http.put(`/api/v1/inboxes/${id}`, data, {
headers: {
'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 {
login,
@@ -219,6 +256,7 @@ export default {
deleteRole,
updateRole,
getTeams,
deleteTeam,
getUsers,
getInbox,
getInboxes,
@@ -226,9 +264,21 @@ export default {
getConversation,
getAutomationRule,
getAutomationRules,
getAllBusinessHours,
getBusinessHours,
createBusinessHours,
updateBusinessHours,
deleteBusinessHours,
getAllSLAs,
getSLA,
createSLA,
updateSLA,
deleteSLA,
getAssignedConversations,
getUnassignedConversations,
getAllConversations,
getTeamUnassignedConversations,
getViewConversations,
getGlobalDashboardCharts,
getGlobalDashboardCounts,
getUserDashboardCounts,
@@ -237,6 +287,7 @@ export default {
getConversationMessage,
getConversationMessages,
getCurrentUser,
getCurrentUserTeams,
getCannedResponses,
createCannedResponse,
updateCannedResponse,
@@ -287,4 +338,8 @@ export default {
getUsersCompact,
getEmailNotificationSettings,
updateEmailNotificationSettings,
getCurrentUserViews,
createView,
updateView,
deleteView
}

View File

@@ -5,12 +5,10 @@
// App default font-size.
// Default: 16px, 15px looks wide.
:root {
font-size: 14px;
font-size: 16px;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
overflow-y: hidden;
}
@@ -176,27 +174,28 @@ body {
@apply p-0;
}
// Scrollbar
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
width: 8px; /* Adjust width */
height: 8px; /* Adjust height */
}
::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 4px;
border-radius: 10px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #555;
background-color: #555; /* Hover effect */
}
* {
scrollbar-width: thin;
::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 10px;
}
// End Scrollbar
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
@@ -212,3 +211,46 @@ body {
.ql-toolbar {
@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;
}
}

View 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>

View File

@@ -15,9 +15,6 @@ const sidebarNavItems = [
<div class="space-y-4 md:block page-content">
<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">
<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="space-y-6">
<slot></slot>

View File

@@ -14,8 +14,8 @@
<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"
@change="selectFile" />
<Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button>
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">Remove
<Button class="w-28" @click="selectAvatar"> Choose a file... </Button>
<Button class="w-28" @click="removeAvatar" variant="destructive">Remove
avatar</Button>
</div>
</div>

View File

@@ -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>
<div class="space-y-4 md:block 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>
</div>
</div>
</div>
<div class="overflow-y-auto ">
<slot></slot>
</div>
</template>

View File

@@ -1,39 +1,38 @@
<template>
<div class="box rounded-lg">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id">
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
<div class="w-full">
<div class="rounded-md border shadow">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" />
</TableHead>
</TableRow>
</template>
<template v-else>
<TableRow>
<TableCell :colspan="columns.length" class="h-24 text-center"> No results. </TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined" class="hover:bg-muted/50">
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</template>
<template v-else>
<TableRow>
<TableCell :colspan="columns.length" class="h-24 text-center">
<div class="text-muted-foreground">{{ emptyText }}</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</div>
</template>
<script setup>
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
@@ -48,14 +47,18 @@ import {
const props = defineProps({
columns: Array,
data: Array
data: Array,
emptyText: {
type: String,
default: 'No results.'
}
})
const table = useVueTable({
get data() {
get data () {
return props.data
},
get columns() {
get columns () {
return props.columns
},
getCoreRowModel: getCoreRowModel()

View File

@@ -49,7 +49,7 @@
</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>
</template>
@@ -83,6 +83,7 @@ const props = defineProps({
const { actions } = toRefs(props)
const emitter = useEmitter()
const slas = ref([])
const teams = ref([])
const users = ref([])
const statuses = ref([])
@@ -91,7 +92,8 @@ const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
onMounted(async () => {
try {
const [teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
const [slasResp, teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
api.getAllSLAs(),
api.getTeamsCompact(),
api.getUsersCompact(),
api.getStatuses(),
@@ -117,9 +119,14 @@ onMounted(async () => {
value: priority.name,
name: priority.name
}))
slas.value = slasResp.data.data.map(sla => ({
value: sla.id,
name: sla.name
}))
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Something went wrong',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -173,6 +180,10 @@ const conversationActions = {
reply: {
label: 'Send reply',
inputType: 'richtext',
},
set_sla: {
label: 'Set SLA',
inputType: 'select',
}
}
@@ -181,6 +192,7 @@ const actionDropdownValues = {
assign_user: users,
set_status: statuses,
set_priority: priorities,
set_sla: slas,
}
const getDropdownValues = (field) => {

View File

@@ -1,25 +1,29 @@
<template>
<div class="flex justify-between mb-5">
<PageHeader title="Automations" description="Manage automations and time triggers" />
<div>
<Button size="sm" @click="newRule">New rule</Button>
<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="ml-auto">
<Button @click="newRule">New rule</Button>
</div>
</div>
<div>
<AutomationTabs v-model="selectedTab" />
</div>
</div>
</div>
<div>
<AutomationTabs v-model="selectedTab" />
<router-view />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
import { useRouter } from 'vue-router'
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
import PageHeader from '../common/PageHeader.vue'
import { useStorage } from '@vueuse/core'
const router = useRouter()
const selectedTab = ref('new_conversation')
const selectedTab = useStorage('automationsTab', 'new_conversation')
const newRule = () => {
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
}

View File

@@ -21,7 +21,9 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import RuleTab from './RuleTab.vue'
const selectedTab = defineModel('selectedTab', {
default: 'new_conversation'
const selectedTab = defineModel('automationsTab', {
default: 'new_conversation',
type: String,
required: true
})
</script>

View File

@@ -3,96 +3,101 @@
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"></Spinner>
<span>{{ formTitle }}</span>
<div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<form @submit="onSubmit">
<div class="space-y-5">
<div class="space-y-4">
<p>{{ formTitle }}</p>
<div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<form @submit="onSubmit">
<div class="space-y-5">
<div class="space-y-5">
<FormField v-slot="{ field }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="My new rule" v-bind="field" />
</FormControl>
<FormDescription>Name for the rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="My new rule" v-bind="field" />
</FormControl>
<FormDescription>Name for the rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="Description for new rule" v-bind="field" />
</FormControl>
<FormDescription>Description for the rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="Description for new rule" v-bind="field" />
</FormControl>
<FormDescription>Description for the rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleInput }" name="type">
<FormItem>
<FormLabel>Type</FormLabel>
<FormControl>
<Select v-bind="componentField" @update:modelValue="handleInput">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="new_conversation"> New conversation </SelectItem>
<SelectItem value="conversation_update"> Conversation update </SelectItem>
<SelectItem value="time_trigger"> Time trigger </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Type of rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleInput }" name="type">
<FormItem>
<FormLabel>Type</FormLabel>
<FormControl>
<Select v-bind="componentField" @update:modelValue="handleInput">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="new_conversation"> New conversation </SelectItem>
<SelectItem value="conversation_update"> Conversation update </SelectItem>
<SelectItem value="time_trigger"> Time trigger </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Type of rule.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="events" v-if="form.values.type === 'conversation_update'">
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<SelectTag v-bind="componentField" :items="conversationEvents" placeholder="Select events"></SelectTag>
</FormControl>
<FormDescription>Evaluate rule on these events.</FormDescription>
<FormMessage></FormMessage>
</FormItem>
</FormField>
<div :class="{ 'hidden': form.values.type !== 'conversation_update' }">
<FormField v-slot="{ componentField }" name="events">
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<SelectTag v-bind="componentField" :items="conversationEvents || []" placeholder="Select events">
</SelectTag>
</FormControl>
<FormDescription>Evaluate rule on these events.</FormDescription>
<FormMessage></FormMessage>
</FormItem>
</FormField>
</div>
</div>
<p class="font-semibold">Match these rules</p>
<RuleBox :ruleGroup="firstRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex="0" />
<div class="flex justify-center">
<div class="flex items-center space-x-2">
<Button :class="[groupOperator === 'AND' ? 'bg-black' : 'bg-gray-100 text-black']"
@click.prevent="toggleGroupOperator('AND')">
AND
</Button>
<Button :class="[groupOperator === 'OR' ? 'bg-black' : 'bg-gray-100 text-black']"
@click.prevent="toggleGroupOperator('OR')">
OR
</Button>
</div>
<p class="font-semibold">Match these rules</p>
<RuleBox :ruleGroup="firstRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex="0" />
<div class="flex justify-center">
<div class="flex items-center space-x-2">
<Button :class="[groupOperator === 'AND' ? 'bg-black' : 'bg-gray-100 text-black']"
@click.prevent="toggleGroupOperator('AND')">
AND
</Button>
<Button :class="[groupOperator === 'OR' ? 'bg-black' : 'bg-gray-100 text-black']"
@click.prevent="toggleGroupOperator('OR')">
OR
</Button>
</div>
</div>
<RuleBox :ruleGroup="secondRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex="1" />
<p class="font-semibold">Perform these actions</p>
<ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
@remove-action="handleRemoveAction" />
<Button type="submit" :isLoading="isLoading">Save</Button>
</div>
<RuleBox :ruleGroup="secondRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex="1" />
<p class="font-semibold">Perform these actions</p>
<ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
@remove-action="handleRemoveAction" />
<Button type="submit" :isLoading="isLoading" size="sm">Save</Button>
</div>
</form>
</form>
</div>
</div>
</template>
@@ -322,6 +327,9 @@ onMounted(async () => {
isLoading.value = true
let resp = await api.getAutomationRule(props.id)
rule.value = resp.data.data
if (resp.data.data.type === 'conversation_update') {
rule.value.rules.events = []
}
form.setValues(resp.data.data)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -1,14 +1,24 @@
import * as z from 'zod'
import * as z from 'zod';
export const formSchema = z.object({
name: z.string({
required_error: 'Rule name is required.'
}),
description: z.string({
required_error: 'Rule description is required.'
}),
type: z.string({
required_error: 'Rule type is required.'
}),
events: z.array(z.string()).min(1, 'Please select at least one event.'),
})
export const formSchema = z
.object({
name: z.string({
required_error: 'Rule name is required.',
}),
description: z.string({
required_error: 'Rule description is required.',
}),
type: z.string({
required_error: 'Rule type is required.',
}),
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,
});
}
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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
})
)
}
}
]

View 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({ 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>

View 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(),
})

View File

@@ -1,13 +1,12 @@
<template>
<div
class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted max-w-80"
@click="handleClick"
>
<div class="flex items-center mb-4">
<component :is="icon" size="25" class="mr-2" />
<p class="text-lg">{{ title }}</p>
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">
<div class="flex items-center mb-3">
<component :is="icon" size="24" class="mr-2 text-primary" />
<p class="text-lg font-semibold text-gray-700">{{ title }}</p>
</div>
<p class="text-sm text-muted-foreground">{{ subTitle }}</p>
<p class="text-sm text-gray-500">{{ subTitle }}</p>
</div>
</template>
@@ -15,18 +14,9 @@
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
title: {
type: String,
required: true
},
subTitle: {
type: String,
required: true
},
icon: {
type: Function,
required: true
},
title: String,
subTitle: String,
icon: Function,
onClick: {
type: Function,
default: null
@@ -36,9 +26,7 @@ const props = defineProps({
const emit = defineEmits(['click'])
const handleClick = () => {
if (props.onClick) {
props.onClick()
}
if (props.onClick) props.onClick()
emit('click')
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col space-y-1">
<span class="text-2xl">{{ title }}</span>
<div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
<span class="font-semibold text-2xl">{{ title }}</span>
<p class="text-muted-foreground text-lg">{{ description }}</p>
</div>
</template>

View File

@@ -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>

View File

@@ -1,11 +1,11 @@
<template>
<div>
<PageHeader title="Canned responses" description="Manage canned responses" />
<div class="w-8/12">
<div class="flex justify-between mb-5">
<PageHeader title="Canned responses" description="Manage canned responses" />
<div class="flex justify-end mb-4">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button size="sm">New canned response</Button>
<Button class="ml-auto">New canned response</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[625px]">
<DialogHeader>
@@ -15,7 +15,7 @@
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit" size="sm">Save Changes</Button>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>

View File

@@ -22,7 +22,7 @@
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit" size="sm">Save Changes</Button>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>

View File

@@ -1,12 +1,12 @@
<template>
<div>
<PageHeader title="Status" description="Manage conversation statuses" />
<div class="w-8/12">
<div class="flex justify-between mb-5">
<PageHeader title="Status" description="Manage conversation statuses" />
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button size="sm">New Status</Button>
</DialogTrigger>
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button class="ml-auto">New Status</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>New status</DialogTitle>
@@ -15,7 +15,7 @@
<StatusForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" size="sm"> Save changes </Button>
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</StatusForm>

View File

@@ -23,7 +23,7 @@
<StatusForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" size="sm"> Save changes </Button>
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</StatusForm>

View File

@@ -1,30 +1,32 @@
<template>
<div class="flex justify-between mb-5">
<PageHeader title="Tags" description="Manage conversation tags" />
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button size="sm">New Tag</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create new tag</DialogTitle>
<DialogDescription> Set tag name. Click save when you're done. </DialogDescription>
</DialogHeader>
<TagsForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" size="sm"> Save changes </Button>
</DialogFooter>
</template>
</TagsForm>
</DialogContent>
</Dialog>
<PageHeader title="Tags" description="Manage conversation tags" />
<div class="w-8/12">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button class="ml-auto">New Tag</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create new tag</DialogTitle>
<DialogDescription> Set tag name. Click save when you're done. </DialogDescription>
</DialogHeader>
<TagsForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</TagsForm>
</DialogContent>
</Dialog>
</div>
</div>
<Spinner v-if="isLoading"></Spinner>
<div v-else>
<DataTable :columns="columns" :data="tags" />
</div>
</div>
<Spinner v-if="isLoading"></Spinner>
<div v-else>
<DataTable :columns="columns" :data="tags" />
</div>
</template>

View File

@@ -22,7 +22,7 @@
<TagsForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" size="sm"> Save changes </Button>
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</TagsForm>

View File

@@ -1,8 +1,10 @@
<template>
<div>
<PageHeader title="General" description="General app settings" />
<PageHeader title="General" description="Manage general app settings" />
</div>
<div class="flex justify-center items-center flex-col w-8/12">
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
</div>
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
</template>
<script setup>

View File

@@ -1,6 +1,6 @@
<template>
<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">
<FormItem>
<FormLabel>Site Name</FormLabel>
@@ -12,11 +12,11 @@
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="lang">
<FormField v-slot="{ componentField }" name="lang">
<FormItem>
<FormLabel>Language</FormLabel>
<FormControl>
<Select v-bind="field" :modelValue="field.value">
<Select v-bind="componentField" :modelValue="componentField.modelValue">
<SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
@@ -32,6 +32,50 @@
</FormItem>
</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">
<FormItem>
<FormLabel>Root URL</FormLabel>
@@ -92,12 +136,12 @@
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch, ref } from 'vue'
import { watch, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
@@ -130,10 +174,13 @@ import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const emitter = useEmitter()
const timezones = Intl.supportedValuesOf('timeZone')
const isLoading = ref(false)
const formLoading = ref(true)
const businessHours = ref({})
const props = defineProps({
initialValues: {
type: Object,
@@ -154,6 +201,36 @@ const form = useForm({
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) => {
try {
isLoading.value = true
@@ -176,9 +253,15 @@ const onSubmit = form.handleSubmit(async (values) => {
watch(
() => props.initialValues,
(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)
formLoading.value = false
},
{ deep: true }
{ deep: true, immediate: true }
)
</script>

View File

@@ -9,20 +9,25 @@ export const formSchema = z.object({
message: 'Site name must be at least 1 characters.'
}),
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
.string({
required_error: 'Root URL is required.'
})
.url({
message: 'Root URL must be a valid URL.'
}),
}).url(),
favicon_url: z
.string({
required_error: 'Favicon URL is required.'
})
.url({
message: 'Favicon URL must be a valid URL.'
}),
}).url(),
max_file_upload_size: z
.number({
required_error: 'Max upload file size is required.'

View File

@@ -59,7 +59,7 @@
}"
@submit="submitForm"
>
<Button type="submit" size="sm" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
<Button type="submit" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
</AutoForm>
</template>

View File

@@ -1,18 +1,20 @@
<template>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Inboxes" description="Manage your inboxes" />
<div class="flex justify-end mb-4">
<Button @click="navigateToAddInbox" size="sm"> New inbox </Button>
<PageHeader title="Inboxes" description="Manage your inboxes" />
<div class="w-8/12">
<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>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
</div>
<div>
<router-view></router-view>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
</template>
<template v-else>
<router-view/>
</template>
</div>
</template>
@@ -99,7 +101,7 @@ const columns = [
{
accessorKey: 'updated_at',
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'))

View File

@@ -42,13 +42,13 @@ export const formSchema = z.object({
}),
smtp: z
.object({
host: z.string().describe('Host').default('smtp.yourmailserver.com'),
host: z.string().describe('Host').default('smtp.google.com'),
port: z
.number({ invalid_type_error: 'Port must be a number.' })
.min(1, { message: 'Port must be at least 1.' })
.max(65535, { message: 'Port must be at most 65535.' })
.describe('Port')
.default(25),
.default(587),
username: z.string().describe('Username'),
password: z.string().describe('Password'),
max_conns: z

View File

@@ -1,10 +1,10 @@
<template>
<div>
<PageHeader title="Notification" description="Manage notification settings" />
</div>
<div>
<Spinner v-if="formLoading"></Spinner>
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
<PageHeader title="Notifications" description="Manage your email notification settings" />
<div class="w-8/12">
<div>
<Spinner v-if="formLoading"></Spinner>
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
</div>
</div>
</template>

View File

@@ -1,19 +1,5 @@
<template>
<form @submit="onSmtpSubmit" class="space-y-6"
: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>
<form @submit="onSmtpSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<!-- SMTP Host Field -->
<FormField v-slot="{ componentField }" name="host">
@@ -120,7 +106,8 @@
<FormItem>
<FormLabel>From Email Address</FormLabel>
<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>
<FormMessage />
<FormDescription>From email address. e.g. My Support &lt;mysupport@example.com&gt;</FormDescription>
@@ -138,7 +125,20 @@
</FormItem>
</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>
</template>

View File

@@ -50,7 +50,6 @@ const submitForm = async (values) => {
})
} finally {
formLoading.value = false
}
}

View File

@@ -1,13 +1,21 @@
<template>
<div class="flex justify-between mb-5">
<PageHeader title="OpenID Connect SSO" description="Manage OpenID SSO configurations" />
<div>
<Button size="sm" @click="navigateToAddOIDC">New OIDC</Button>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="oidc" v-else />
<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></div>
<div>
<Button @click="navigateToAddOIDC">New OIDC</Button>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="oidc" v-else />
</div>
</template>
<template v-else>
<router-view/>
</template>
</div>
</template>

View File

@@ -88,7 +88,7 @@
</FormItem>
</FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>

View File

@@ -38,7 +38,7 @@ export const columns = [
{
accessorKey: 'updated_at',
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'))

View 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>

View 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>

View 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>

View 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
})
)
}
}
]

View 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>

View 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).'
}),
})

View File

@@ -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>

View File

@@ -45,7 +45,7 @@ onMounted(async () => {
})
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Edit role' }
]

View File

@@ -19,7 +19,7 @@ const emitter = useEmitter()
const router = useRouter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Add role' }
]

View File

@@ -38,7 +38,7 @@
</FormField>
</div>
</div>
<Button type="submit" size="sm" :isLoading="isLoading">{{ submitLabel }}</Button>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>

View File

@@ -1,15 +1,17 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
<PageHeader title="Roles" description="Manage roles" />
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddRole"> New role </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="roles" v-else />
</div>
</div>
<router-view></router-view>
</div>
<div class="flex justify-end mb-5">
<Button @click="navigateToAddRole" size="sm"> New role </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="roles" v-else />
</div>
<router-view></router-view>
</template>
<script setup>
@@ -25,6 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import PageHeader from '@/components/admin/common/PageHeader.vue'
const { toast } = useToast()
const emit = useEmitter()
@@ -32,7 +35,7 @@ const router = useRouter()
const roles = ref([])
const isLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Roles' }
]

View File

@@ -19,7 +19,6 @@ const formLoading = ref(false)
const router = useRouter()
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '/admin/teams/teams/new', label: 'New team' }
]
@@ -39,7 +38,7 @@ const createTeam = async (values) => {
router.push('/admin/teams/teams')
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Something went wrong',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -22,11 +22,18 @@ const formLoading = ref(false)
const isLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '#', label: 'Edit team' }
]
const props = defineProps({
id: {
type: String,
required: true
}
})
const submitForm = (values) => {
updateTeam(values)
}
@@ -41,7 +48,7 @@ const updateTeam = async (payload) => {
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update team',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -57,7 +64,7 @@ onMounted(async () => {
team.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch team',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -65,11 +72,4 @@ onMounted(async () => {
isLoading.value = false
}
})
const props = defineProps({
id: {
type: String,
required: true
}
})
</script>

View File

@@ -8,9 +8,13 @@ import {
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
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 emit = useEmitter()
const props = defineProps({
team: {
type: Object,
@@ -21,9 +25,28 @@ const props = defineProps({
}
})
function editTeam(id) {
function editTeam (id) {
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>
<template>
@@ -36,6 +59,7 @@ function editTeam(id) {
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
<DropdownMenuItem @click="deleteTeam(props.team.id)"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -11,32 +11,93 @@
</FormItem>
</FormField>
<FormField name="auto_assign_conversations" v-slot="{ value, handleChange }">
<FormField name="conversation_assignment_type" v-slot="{ componentField }">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Auto assign conversations</Label>
</div>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a assignment type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="at in assignmentTypes" :key="at" :value="at">
{{ at }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</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 />
</FormItem>
</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>
</template>
<script setup>
import { watch } from 'vue'
import { watch, computed, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
FormControl,
FormField,
@@ -45,8 +106,18 @@ import {
FormMessage,
FormDescription
} from '@/components/ui/form'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
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({
initialValues: {
type: Object,
@@ -71,6 +142,32 @@ const form = useForm({
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) => {
props.submitForm(values)
})
@@ -79,6 +176,7 @@ const onSubmit = form.handleSubmit((values) => {
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) return
form.setValues(newValues)
},
{ immediate: true }

View File

@@ -1,23 +1,25 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<div class="flex justify-end mb-5">
<Button @click="navigateToAddTeam" size="sm"> New team </Button>
</div>
<div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
<PageHeader title="Teams" description="Manage teams" />
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddTeam"> New team </Button>
</div>
<div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
</div>
</div>
</div>
<div>
<router-view></router-view>
<template v-else>
<router-view></router-view>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { handleHTTPError } from '@/utils/http'
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
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 DataTable from '@/components/admin/DataTable.vue'
import api from '@/api'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/', label: 'Teams' }
]
const emit = useEmitter()
const router = useRouter()
const data = ref([])
const isLoading = ref(false)
@@ -45,7 +51,7 @@ const getData = async () => {
data.value = response.data.data
} catch (error) {
toast({
title: 'Could not fetch teams.',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -58,7 +64,24 @@ const navigateToAddTeam = () => {
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 () => {
getData()
listenForRefresh()
})
onUnmounted(() => {
removeListeners()
})
</script>

View File

@@ -15,7 +15,7 @@ export const columns = [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Modified at')
return h('div', { class: 'text-center' }, 'Updated at')
},
cell: function ({ row }) {
return h(

View File

@@ -8,5 +8,7 @@ export const teamFormSchema = z.object({
.min(2, {
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(),
})

View File

@@ -18,7 +18,6 @@ const { toast } = useToast()
const router = useRouter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/users', label: 'Users' },
{ path: '#', label: 'Add user' }
]

View File

@@ -22,7 +22,7 @@ const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/users', label: 'Users' },
{ path: '#', label: 'Edit user' }
]

View File

@@ -73,7 +73,7 @@
</FormItem>
</FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>

View File

@@ -1,15 +1,19 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
<PageHeader title="Users" description="Manage users" />
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/users'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddUser"> New user </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
</div>
<template v-else>
<router-view></router-view>
</template>
</div>
<div class="flex justify-end mb-5">
<Button @click="navigateToAddUser" size="sm"> New user </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
<router-view></router-view>
</template>
<script setup>
@@ -22,6 +26,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
@@ -32,7 +37,7 @@ const isLoading = ref(false)
const data = ref([])
const emit = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Users' }
]

View File

@@ -33,7 +33,7 @@ export const columns = [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Modified at')
return h('div', { class: 'text-center' }, 'Updated at')
},
cell: function ({ row }) {
return h(

View File

@@ -4,24 +4,37 @@
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Template name" v-bind="componentField" />
<Input type="text" placeholder="Template name" v-bind="componentField" :disabled="!isOutgoingTemplate" />
</FormControl>
<FormMessage />
</FormItem>
</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">
<FormItem>
<FormLabel>Body</FormLabel>
<FormControl>
<CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
</FormControl>
<FormDescription>{{ `Make sure the template has \{\{ template "content" . \}\}` }}</FormDescription>
<FormDescription v-if="isOutgoingTemplate">{{ `Make sure the template has \{\{ template "content" . \}\}` }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="is_default" v-slot="{ value, handleChange }">
<FormField name="is_default" v-slot="{ value, handleChange }" v-if="isOutgoingTemplate">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
@@ -34,12 +47,12 @@
</FormItem>
</FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch } from 'vue'
import { watch, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
@@ -85,6 +98,10 @@ const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
})
const isOutgoingTemplate = computed(() => {
return props.initialValues?.type === 'email_outgoing'
})
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,

View File

@@ -1,30 +1,55 @@
<template>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Email Templates" description="Manage outgoing email templates" />
<div class="flex justify-end mb-4">
<Button @click="navigateToAddTemplate" size="sm"> New template </Button>
<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></div>
<div class="flex justify-end mb-4">
<Button @click="navigateToAddTemplate"> New template </Button>
</div>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="templates" />
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<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>
</template>
<template v-else>
<router-view/>
</template>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from '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 PageHeader from '@/components/admin/common/PageHeader.vue'
import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
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'
const templateType = useStorage('templateType', 'email_outgoing')
const templates = ref([])
const isLoading = ref(false)
const router = useRouter()
@@ -42,7 +67,7 @@ onUnmounted(() => {
const fetchAll = async () => {
try {
isLoading.value = true
const resp = await api.getTemplates()
const resp = await api.getTemplates(templateType.value)
templates.value = resp.data.data
} finally {
isLoading.value = false
@@ -56,4 +81,8 @@ const refreshList = (data) => {
const navigateToAddTemplate = () => {
router.push('/admin/templates/new')
}
watch(templateType, () => {
fetchAll()
})
</script>

View File

@@ -2,7 +2,7 @@ import { h } from 'vue'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
export const outgoingEmailTemplatesColumns = [
{
accessorKey: 'name',
header: function () {
@@ -30,7 +30,44 @@ export const columns = [
{
accessorKey: 'updated_at',
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 }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))

View File

@@ -37,7 +37,7 @@ const deleteTemplate = async (id) => {
})
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not delete template',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -1,11 +1,23 @@
import * as z from 'zod'
import * as z from 'zod';
export const formSchema = z.object({
name: z.string({
required_error: 'Template name is required.'
}),
body: z.string({
required_error: 'Template content is required.'
}),
is_default: z.boolean().optional()
})
export const formSchema = z
.object({
name: z.string({
required_error: 'Template name is required.',
}),
body: z.string({
required_error: 'Template content is required.',
}),
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,
});
}
});

View File

@@ -34,7 +34,7 @@
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
<Button type="submit"> {{ submitLabel }} </Button>
</form>
</template>

View File

@@ -121,7 +121,7 @@
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
<Button type="submit"> {{ submitLabel }} </Button>
</form>
</template>

View File

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

View 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>

View File

@@ -2,18 +2,19 @@
<div class="relative" v-if="conversationStore.messages.data">
<!-- 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="font-semibold">
<div class="font-medium">
{{ conversationStore.current.subject }}
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<Badge variant="primary">
{{ conversationStore.current.status }}
</Badge>
<div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
<GalleryVerticalEnd size="14" class="text-secondary" />
<span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
@@ -37,13 +38,16 @@
import { ref, onMounted } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useConversationStore } from '@/stores/conversation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
GalleryVerticalEnd,
} from 'lucide-vue-next'
import MessageList from '@/components/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue'
import api from '@/api'

View File

@@ -1,15 +1,44 @@
<template>
<div class="h-screen flex flex-col">
<!-- Filters -->
<div class="shrink-0">
<ConversationListFilters @updateFilters="handleUpdateFilters" />
<div class="flex justify-between px-2 py-2 w-full">
<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>
<!-- Empty list -->
<!-- Empty -->
<EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
<!-- List -->
<div class="flex-grow overflow-y-auto">
<EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
@@ -17,12 +46,15 @@
<!-- Items -->
<div v-else>
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
<div class="space-y-5 px-2">
<ConversationListItem class="mt-2" :conversation="conversation"
:currentConversation="conversationStore.current"
v-for="conversation in conversationStore.conversationsList" :key="conversation.uuid"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
</div>
</div>
<!-- List skeleton -->
<!-- skeleton -->
<div v-if="isLoading">
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
</div>
@@ -46,40 +78,47 @@
<script setup>
import { onMounted, computed, onUnmounted } from 'vue'
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
const conversationStore = useConversationStore()
let listRefreshInterval = null
let reFetchInterval = null
// Re-fetch conversations list every 30 seconds for any missed updates.
onMounted(() => {
conversationStore.fetchConversationsList()
// Refresh list every min.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversationsList(false)
}, 60000)
reFetchInterval = setInterval(() => {
conversationStore.reFetchConversationsList(false)
}, 30000)
})
onUnmounted(() => {
clearInterval(listRefreshInterval)
clearInterval(reFetchInterval)
conversationStore.clearListReRenderInterval()
})
const handleStatusChange = (status) => {
conversationStore.setListStatus(status.label)
}
const handleSortChange = (order) => {
conversationStore.setListSortField(order)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations()
}
const handleUpdateFilters = (filters) => {
console.log("setting ", filters)
conversationStore.setConversationListFilters(filters)
}
const hasConversations = computed(() => {
return conversationStore.sortedConversations.length !== 0
return conversationStore.conversationsList.length !== 0
})
const hasErrored = computed(() => {

View File

@@ -1,19 +1,12 @@
<template>
<div class="flex justify-between 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>
<div class="flex justify-end px-2 py-2 border-b w-full">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<div class="flex items-center mr-2 relative">
<span class="absolute inline-flex h-2 w-2 rounded-full bg-primary opacity-75 right-0 bottom-5 z-20"
v-if="conversationStore.conversations.filters.length > 0" />
<ListFilter size="27"
class="mx-auto cursor-pointer transition-all transform hover:scale-110 hover:bg-secondary hover:bg-opacity-80 p-1 rounded-md z-10" />
class="mx-auto cursor-pointer transition-all transform hover:scale-110 hover:bg-secondary hover:bg-opacity-80 p-1 rounded-md z-10" />
</div>
</PopoverTrigger>
<PopoverContent class="w-[450px]">
@@ -25,10 +18,25 @@
<script setup>
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 { ListFilter } from 'lucide-vue-next'
import { ListFilter, ChevronDown } from 'lucide-vue-next'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button'
import Filter from '@/components/common/Filter.vue'
import api from '@/api'
@@ -44,6 +52,14 @@ onMounted(() => {
localFilters.value = [...conversationStore.conversations.filters]
})
const handleStatusChange = (status) => {
console.log('status', status)
}
const handleSortChange = (order) => {
console.log('order', order)
}
const fetchInitialData = async () => {
const [statusesResp, prioritiesResp] = await Promise.all([
api.getStatuses(),

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
@click="router.push('/conversations/' + conversation.uuid)">
<div class="pl-3">
@@ -12,7 +12,7 @@
</Avatar>
</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>
<p class="text-xs text-gray-600 flex gap-x-1">
@@ -42,6 +42,10 @@
</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>
</template>
@@ -52,6 +56,7 @@ import { useRouter } from 'vue-router'
import { formatTime } from '@/utils/datetime'
import { Mail, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
const router = useRouter()
const props = defineProps({

View File

@@ -1,8 +1,17 @@
<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">
<p class="font-medium">Reference number</p>
<p>
#{{ conversation.reference_number }}
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
@@ -14,6 +23,7 @@
<div class="flex flex-col gap-1 mb-5">
<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">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
@@ -22,6 +32,7 @@
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Resolved at</p>
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
@@ -35,10 +46,12 @@
</p>
<p v-else>-</p>
</div>
</template>
<script setup>
import { format } from 'date-fns'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
defineProps({
conversation: Object
})

View File

@@ -1,29 +1,27 @@
<template>
<div class="flex gap-x-5">
<Card class="w-1/6 box" v-for="(value, key) in counts" :key="key">
<CardHeader>
<CardTitle class="text-2xl">
{{ value }}
</CardTitle>
<CardDescription>
{{ labels[key] }}
</CardDescription>
</CardHeader>
</Card>
<div class="flex">
<div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{title}}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span>
<strong class="uppercase tracking-wider">Live</strong>
</div>
</div>
<div class="flex">
<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>
</template>
<script setup>
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
defineProps({
counts: {
type: Object,
required: true
},
labels: {
type: Object,
required: true
}
counts: { type: Object, required: true },
labels: { type: Object, required: true },
title: { type: String, required: true }
})
</script>

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
<div>
<span class="font-medium text-3xl space-y-1">
<p>Hi, {{ userStore.getFullName }}</p>
<p class="text-sm-muted">🌤 {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
<span class="font-medium text-xl space-y-1">
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
<p class="text-muted-foreground text-lg">🌤 {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
</span>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More