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 ( import (
"strconv" "strconv"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope" "github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -55,7 +55,12 @@ func handleOIDCCallback(r *fastglue.Request) error {
} }
// Set the session. // Set the session.
if err := app.auth.SaveSession(user, r); err != nil { if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
}, r); err != nil {
return err return err
} }

104
cmd/business_hours.go Normal file
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 ( import (
"encoding/json" "encoding/json"
"strconv" "strconv"
"time"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/automation/models" "github.com/abhinavxd/artemis/internal/automation/models"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models" cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/envelope" "github.com/abhinavxd/artemis/internal/envelope"
umodels "github.com/abhinavxd/artemis/internal/user/models" umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -23,13 +26,23 @@ func handleGetAllConversations(r *fastglue.Request) error {
filters = string(r.RequestCtx.QueryArgs().Peek("filters")) filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0 total = 0
) )
conversations, pageSize, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if len(conversations) > 0 { if len(conversations) > 0 {
total = conversations[0].Total total = conversations[0].Total
} }
// Calculate SLA deadlines if conversation has an SLA policy.
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
calculateSLA(app, &conversations[i])
}
}
return r.SendEnvelope(envelope.PageResults{ return r.SendEnvelope(envelope.PageResults{
Results: conversations, Results: conversations,
Total: total, Total: total,
@@ -43,21 +56,29 @@ func handleGetAllConversations(r *fastglue.Request) error {
func handleGetAssignedConversations(r *fastglue.Request) error { func handleGetAssignedConversations(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User) user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order")) order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0 total = 0
) )
conversations, pageSize, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize) conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil { if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "") return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
} }
if len(conversations) > 0 { if len(conversations) > 0 {
total = conversations[0].Total total = conversations[0].Total
} }
// Calculate SLA deadlines if conversation has an SLA policy.
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
calculateSLA(app, &conversations[i])
}
}
return r.SendEnvelope(envelope.PageResults{ return r.SendEnvelope(envelope.PageResults{
Results: conversations, Results: conversations,
Total: total, Total: total,
@@ -71,21 +92,130 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
func handleGetUnassignedConversations(r *fastglue.Request) error { func handleGetUnassignedConversations(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order")) order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
total = 0 total = 0
) )
conversations, pageSize, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
if err != nil { if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "") return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
} }
if len(conversations) > 0 { if len(conversations) > 0 {
total = conversations[0].Total total = conversations[0].Total
} }
// Calculate SLA deadlines if conversation has an SLA policy.
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
calculateSLA(app, &conversations[i])
}
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetViewConversations retrieves conversations for a view.
func handleGetViewConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("view_id").(string))
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
if viewID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
}
// Check if user has access to the view.
view, err := app.view.Get(viewID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
}
conversations, err := app.conversation.GetViewConversationsList(user.ID, view.InboxType, order, orderBy, string(view.Filters), page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
// Calculate SLA deadlines if conversation has an SLA policy.
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
calculateSLA(app, &conversations[i])
}
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user.
func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
teamIDStr = r.RequestCtx.UserValue("team_id").(string)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
teamID, _ := strconv.Atoi(teamIDStr)
if teamID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
}
// Check if user belongs to the team.
exists, err := app.team.UserBelongsToTeam(teamID, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !exists {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
}
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
// Calculate SLA deadlines if conversation has an SLA policy.
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
calculateSLA(app, &conversations[i])
}
}
return r.SendEnvelope(envelope.PageResults{ return r.SendEnvelope(envelope.PageResults{
Results: conversations, Results: conversations,
Total: total, Total: total,
@@ -98,29 +228,55 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
// handleGetConversation retrieves a single conversation by UUID with permission checks. // handleGetConversation retrieves a single conversation by UUID with permission checks.
func handleGetConversation(r *fastglue.Request) error { func handleGetConversation(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
conversation, err := enforceConversationAccess(app, uuid, user) user, err := app.user.Get(auser.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
// Calculate SLA deadlines if conversation has an SLA policy.
if conversation.SLAPolicyID.Int != 0 {
calculateSLA(app, &conversation)
}
return r.SendEnvelope(conversation) return r.SendEnvelope(conversation)
} }
// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation. // handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation.
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
_, err := enforceConversationAccess(app, uuid, user) user, err := app.user.Get(auser.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil { if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -130,14 +286,25 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
// handleGetConversationParticipants retrieves participants of a conversation. // handleGetConversationParticipants retrieves participants of a conversation.
func handleGetConversationParticipants(r *fastglue.Request) error { func handleGetConversationParticipants(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
_, err := enforceConversationAccess(app, uuid, user) user, err := app.user.Get(auser.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
p, err := app.conversation.GetConversationParticipants(uuid) p, err := app.conversation.GetConversationParticipants(uuid)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -148,21 +315,33 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateConversationUserAssignee updates the user assigned to a conversation. // handleUpdateConversationUserAssignee updates the user assigned to a conversation.
func handleUpdateConversationUserAssignee(r *fastglue.Request) error { func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
) )
if assigneeID == 0 {
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id") return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
} }
_, err = enforceConversationAccess(app, uuid, user) user, err := app.user.Get(auser.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil { if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -176,18 +355,31 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
// handleUpdateTeamAssignee updates the team assigned to a conversation. // handleUpdateTeamAssignee updates the team assigned to a conversation.
func handleUpdateTeamAssignee(r *fastglue.Request) error { func handleUpdateTeamAssignee(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id") assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil { if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
} }
_, err = enforceConversationAccess(app, uuid, user)
user, err := app.user.Get(auser.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil { if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -204,12 +396,23 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
p = r.RequestCtx.PostArgs() p = r.RequestCtx.PostArgs()
priority = p.Peek("priority") priority = p.Peek("priority")
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
_, err := enforceConversationAccess(app, uuid, user) conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil { if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -223,23 +426,33 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
// handleUpdateConversationStatus updates the status of a conversation. // handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error { func handleUpdateConversationStatus(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
p = r.RequestCtx.PostArgs() p = r.RequestCtx.PostArgs()
status = p.Peek("status") status = p.Peek("status")
uuid = r.RequestCtx.UserValue("uuid").(string) snoozedUntil = p.Peek("snoozed_until")
user = r.RequestCtx.UserValue("user").(umodels.User) 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 { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.conversation.UpdateConversationStatus(uuid, status, user); err != nil { user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpdateConversationStatus(uuid, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Evaluate automation rules. // Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange) app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
@@ -250,7 +463,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
p = r.RequestCtx.PostArgs() p = r.RequestCtx.PostArgs()
tagIDs = []int{} tagIDs = []int{}
tagJSON = p.Peek("tag_ids") tagJSON = p.Peek("tag_ids")
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
) )
@@ -261,11 +474,24 @@ func handleAddConversationTags(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "") return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
} }
_, err = enforceConversationAccess(app, uuid, user) conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil { if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -298,7 +524,7 @@ func handleDashboardCharts(r *fastglue.Request) error {
// enforceConversationAccess fetches the conversation and checks if the user has access to it. // enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) { func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(uuid) conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -311,3 +537,15 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
} }
return &conversation, nil return &conversation, nil
} }
// calculateSLA calculates the SLA deadlines and sets them on the conversation.
func calculateSLA(app *App, conversation *cmodels.Conversation) error {
firstRespAt, resolutionDueAt, err := app.sla.CalculateConversationDeadlines(conversation.CreatedAt, conversation.AssignedTeamID.Int, conversation.SLAPolicyID.Int)
if err != nil {
app.lo.Error("error calculating SLA deadlines for conversation", "id", conversation.ID, "error", err)
return err
}
conversation.FirstReplyDueAt = null.NewTime(firstRespAt, firstRespAt != time.Time{})
conversation.ResolutionDueAt = null.NewTime(resolutionDueAt, resolutionDueAt != time.Time{})
return nil
}

81
cmd/csat.go Normal file
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" "github.com/zerodha/fastglue"
) )
var (
slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
)
// initHandlers initializes the HTTP routes and handlers for the application. // initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication. // Authentication.
g.POST("/api/login", handleLogin) g.POST("/api/v1/login", handleLogin)
g.GET("/logout", handleLogout) g.GET("/logout", handleLogout)
g.GET("/api/oidc/{id}/login", handleOIDCLogin) g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/oidc/finish", handleOIDCCallback) g.GET("/api/v1/oidc/finish", handleOIDCCallback)
// Health check.
g.GET("/health", handleHealthCheck)
// Serve media files. // Serve media files.
g.GET("/uploads/{uuid}", auth(handleServeMedia)) g.GET("/uploads/{uuid}", auth(handleServeMedia))
// Settings. // Settings.
g.GET("/api/settings/general", handleGetGeneralSettings) g.GET("/api/v1/settings/general", handleGetGeneralSettings)
g.PUT("/api/settings/general", authPerm(handleUpdateGeneralSettings, "settings_general", "write")) g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
g.GET("/api/settings/notifications/email", authPerm(handleGetEmailNotificationSettings, "settings_notifications", "read")) g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
g.PUT("/api/settings/notifications/email", authPerm(handleUpdateEmailNotificationSettings, "settings_notifications", "write")) g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
// OpenID SSO. // OpenID connect single sign-on.
g.GET("/api/oidc", handleGetAllOIDC) g.GET("/api/v1/oidc", handleGetAllOIDC)
g.GET("/api/oidc/{id}", authPerm(handleGetOIDC, "oidc", "read")) g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
g.POST("/api/oidc", authPerm(handleCreateOIDC, "oidc", "write")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc", "write"))
g.PUT("/api/oidc/{id}", authPerm(handleUpdateOIDC, "oidc", "write")) g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
g.DELETE("/api/oidc/{id}", authPerm(handleDeleteOIDC, "oidc", "delete")) g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
// Conversation and message. // All.
g.GET("/api/conversations/all", authPerm(handleGetAllConversations, "conversations", "read_all")) g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
g.GET("/api/conversations/unassigned", authPerm(handleGetUnassignedConversations, "conversations", "read_unassigned")) // Not assigned to any user or team.
g.GET("/api/conversations/assigned", authPerm(handleGetAssignedConversations, "conversations", "read_assigned")) g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
g.GET("/api/conversations/{uuid}", authPerm(handleGetConversation, "conversations", "read")) // Assigned to logged in user.
g.GET("/api/conversations/{uuid}/participants", authPerm(handleGetConversationParticipants, "conversations", "read")) g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
g.PUT("/api/conversations/{uuid}/assignee/user", authPerm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee")) // Unassigned conversations assigned to a team.
g.PUT("/api/conversations/{uuid}/assignee/team", authPerm(handleUpdateTeamAssignee, "conversations", "update_team_assignee")) g.GET("/api/v1/teams/{team_id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations", "read_assigned"))
g.PUT("/api/conversations/{uuid}/priority", authPerm(handleUpdateConversationPriority, "conversations", "update_priority")) // Filtered by view.
g.PUT("/api/conversations/{uuid}/status", authPerm(handleUpdateConversationStatus, "conversations", "update_status")) g.GET("/api/v1/views/{view_id}/conversations", perm(handleGetViewConversations, "conversations", "read"))
g.PUT("/api/conversations/{uuid}/last-seen", authPerm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
g.POST("/api/conversations/{uuid}/tags", authPerm(handleAddConversationTags, "conversations", "update_tags")) g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
g.POST("/api/conversations/{cuuid}/messages", authPerm(handleSendMessage, "messages", "write")) g.GET("/api/v1/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
g.GET("/api/conversations/{uuid}/messages", authPerm(handleGetMessages, "messages", "read")) g.PUT("/api/v1/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authPerm(handleRetryMessage, "messages", "write")) g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authPerm(handleGetMessage, "messages", "read")) g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations", "update_priority"))
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations", "update_status"))
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations", "update_tags"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
// Views.
g.GET("/api/v1/views/me", auth(handleGetUserViews))
g.POST("/api/v1/views/me", auth(handleCreateUserView))
g.PUT("/api/v1/views/me/{id}", auth(handleUpdateUserView))
g.DELETE("/api/v1/views/me/{id}", auth(handleDeleteUserView))
// Status and priority. // Status and priority.
g.GET("/api/statuses", auth(handleGetStatuses)) g.GET("/api/v1/statuses", auth(handleGetStatuses))
g.POST("/api/statuses", authPerm(handleCreateStatus, "status", "write")) g.POST("/api/v1/statuses", perm(handleCreateStatus, "status", "write"))
g.PUT("/api/statuses/{id}", authPerm(handleUpdateStatus, "status", "write")) g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
g.DELETE("/api/statuses/{id}", authPerm(handleDeleteStatus, "status", "delete")) g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
g.GET("/api/priorities", auth(handleGetPriorities)) g.GET("/api/v1/priorities", auth(handleGetPriorities))
// Tag. // Tag.
g.GET("/api/tags", auth(handleGetTags)) g.GET("/api/v1/tags", auth(handleGetTags))
g.POST("/api/tags", authPerm(handleCreateTag, "tags", "write")) g.POST("/api/v1/tags", perm(handleCreateTag, "tags", "write"))
g.PUT("/api/tags/{id}", authPerm(handleUpdateTag, "tags", "write")) g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags", "write"))
g.DELETE("/api/tags/{id}", authPerm(handleDeleteTag, "tags", "delete")) g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
// Media. // Media.
g.POST("/api/media", auth(handleMediaUpload)) g.POST("/api/v1/media", auth(handleMediaUpload))
// Canned response. // Canned response.
g.GET("/api/canned-responses", auth(handleGetCannedResponses)) g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
g.POST("/api/canned-responses", authPerm(handleCreateCannedResponse, "canned_responses", "write")) g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
g.PUT("/api/canned-responses/{id}", authPerm(handleUpdateCannedResponse, "canned_responses", "write")) g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
g.DELETE("/api/canned-responses/{id}", authPerm(handleDeleteCannedResponse, "canned_responses", "delete")) g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
// User. // User.
g.GET("/api/users/me", auth(handleGetCurrentUser)) g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/users/me", auth(handleUpdateCurrentUser)) g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.DELETE("/api/users/me/avatar", auth(handleDeleteAvatar)) g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.GET("/api/users/compact", auth(handleGetUsersCompact)) g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/users", authPerm(handleGetUsers, "users", "read")) g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/users/{id}", authPerm(handleGetUser, "users", "read")) g.GET("/api/v1/users", perm(handleGetUsers, "users", "read"))
g.POST("/api/users", authPerm(handleCreateUser, "users", "write")) g.GET("/api/v1/users/{id}", perm(handleGetUser, "users", "read"))
g.PUT("/api/users/{id}", authPerm(handleUpdateUser, "users", "write")) g.POST("/api/v1/users", perm(handleCreateUser, "users", "write"))
g.DELETE("/api/users/{id}", authPerm(handleDeleteUser, "users", "delete")) g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users", "write"))
g.POST("/api/users/reset-password", tryAuth(handleResetPassword)) g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users", "delete"))
g.POST("/api/users/set-password", tryAuth(handleSetPassword)) g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
// Team. // Team.
g.GET("/api/teams/compact", auth(handleGetTeamsCompact)) g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
g.GET("/api/teams", authPerm(handleGetTeams, "teams", "read")) g.GET("/api/v1/teams", perm(handleGetTeams, "teams", "read"))
g.POST("/api/teams", authPerm(handleCreateTeam, "teams", "write")) g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams", "read"))
g.GET("/api/teams/{id}", authPerm(handleGetTeam, "teams", "read")) g.POST("/api/v1/teams", perm(handleCreateTeam, "teams", "write"))
g.PUT("/api/teams/{id}", authPerm(handleUpdateTeam, "teams", "write")) g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
g.DELETE("/api/teams/{id}", authPerm(handleDeleteTeam, "teams", "delete")) g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams", "delete"))
// i18n. // i18n.
g.GET("/api/lang/{lang}", handleGetI18nLang) g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Automation. // Automation.
g.GET("/api/automation/rules", authPerm(handleGetAutomationRules, "automations", "read")) g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
g.GET("/api/automation/rules/{id}", authPerm(handleGetAutomationRule, "automations", "read")) g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
g.POST("/api/automation/rules", authPerm(handleCreateAutomationRule, "automations", "write")) g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}/toggle", authPerm(handleToggleAutomationRule, "automations", "write")) g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}", authPerm(handleUpdateAutomationRule, "automations", "write")) g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
g.DELETE("/api/automation/rules/{id}", authPerm(handleDeleteAutomationRule, "automations", "delete")) g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
// Inbox. // Inbox.
g.GET("/api/inboxes", authPerm(handleGetInboxes, "inboxes", "read")) g.GET("/api/v1/inboxes", perm(handleGetInboxes, "inboxes", "read"))
g.GET("/api/inboxes/{id}", authPerm(handleGetInbox, "inboxes", "read")) g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
g.POST("/api/inboxes", authPerm(handleCreateInbox, "inboxes", "write")) g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}/toggle", authPerm(handleToggleInbox, "inboxes", "write")) g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}", authPerm(handleUpdateInbox, "inboxes", "write")) g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
g.DELETE("/api/inboxes/{id}", authPerm(handleDeleteInbox, "inboxes", "delete")) g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
// Role. // Role.
g.GET("/api/roles", authPerm(handleGetRoles, "roles", "read")) g.GET("/api/v1/roles", perm(handleGetRoles, "roles", "read"))
g.GET("/api/roles/{id}", authPerm(handleGetRole, "roles", "read")) g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles", "read"))
g.POST("/api/roles", authPerm(handleCreateRole, "roles", "write")) g.POST("/api/v1/roles", perm(handleCreateRole, "roles", "write"))
g.PUT("/api/roles/{id}", authPerm(handleUpdateRole, "roles", "write")) g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles", "write"))
g.DELETE("/api/roles/{id}", authPerm(handleDeleteRole, "roles", "delete")) g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
// Dashboard. // Dashboard.
g.GET("/api/dashboard/global/counts", authPerm(handleDashboardCounts, "dashboard_global", "read")) g.GET("/api/v1/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
g.GET("/api/dashboard/global/charts", authPerm(handleDashboardCharts, "dashboard_global", "read")) g.GET("/api/v1/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
// Template. // Template.
g.GET("/api/templates", authPerm(handleGetTemplates, "templates", "read")) g.GET("/api/v1/templates", perm(handleGetTemplates, "templates", "read"))
g.GET("/api/templates/{id}", authPerm(handleGetTemplate, "templates", "read")) g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates", "read"))
g.POST("/api/templates", authPerm(handleCreateTemplate, "templates", "write")) g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates", "write"))
g.PUT("/api/templates/{id}", authPerm(handleUpdateTemplate, "templates", "write")) g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
g.DELETE("/api/templates/{id}", authPerm(handleDeleteTemplate, "templates", "delete")) g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
// Business hours.
g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
g.GET("/api/v1/business-hours/{id}", auth(handleGetBusinessHour))
g.POST("/api/v1/business-hours", auth(handleCreateBusinessHours))
g.PUT("/api/v1/business-hours/{id}", auth(handleUpdateBusinessHours))
g.DELETE("/api/v1/business-hours/{id}", auth(handleDeleteBusinessHour))
// SLA.
g.GET("/api/v1/sla", auth(handleGetSLAs))
g.GET("/api/v1/sla/{id}", auth(handleGetSLA))
g.POST("/api/v1/sla", auth(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields)))
g.PUT("/api/v1/sla/{id}", auth(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields)))
g.DELETE("/api/v1/sla/{id}", auth(handleDeleteSLA))
// WebSocket. // WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error { g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -150,8 +179,16 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/admin/{all:*}", authPage(serveIndexPage)) g.GET("/admin/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage)) g.GET("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage)) g.GET("/set-password", notAuthPage(serveIndexPage))
g.GET("/assets/{all:*}", serveStaticFiles) g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/images/{all:*}", serveStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/static/public/{all:*}", serveStaticFiles)
// Public pages.
g.GET("/csat/{uuid}", handleShowCSAT)
g.POST("/csat/{uuid}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}}))
// Health check.
g.GET("/health", handleHealthCheck)
} }
// serveIndexPage serves the main index page of the application. // serveIndexPage serves the main index page of the application.
@@ -186,6 +223,29 @@ func serveStaticFiles(r *fastglue.Request) error {
// Get the requested file path. // Get the requested file path.
filePath := string(r.RequestCtx.Path()) filePath := string(r.RequestCtx.Path())
file, err := app.fs.Get(filePath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveFrontendStaticFiles serves static assets from the embedded filesystem.
func serveFrontendStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
// Get the requested file path.
filePath := string(r.RequestCtx.Path())
// Fetch and serve the file from the embedded filesystem. // Fetch and serve the file from the embedded filesystem.
finalPath := filepath.Join(frontendDir, filePath) finalPath := filepath.Join(frontendDir, filePath)
file, err := app.fs.Get(finalPath) file, err := app.fs.Get(finalPath)

View File

@@ -16,11 +16,12 @@ import (
"github.com/abhinavxd/artemis/internal/authz" "github.com/abhinavxd/artemis/internal/authz"
"github.com/abhinavxd/artemis/internal/autoassigner" "github.com/abhinavxd/artemis/internal/autoassigner"
"github.com/abhinavxd/artemis/internal/automation" "github.com/abhinavxd/artemis/internal/automation"
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
"github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/priority" "github.com/abhinavxd/artemis/internal/conversation/priority"
"github.com/abhinavxd/artemis/internal/conversation/status" "github.com/abhinavxd/artemis/internal/conversation/status"
"github.com/abhinavxd/artemis/internal/csat"
"github.com/abhinavxd/artemis/internal/inbox" "github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/inbox/channel/email" "github.com/abhinavxd/artemis/internal/inbox/channel/email"
imodels "github.com/abhinavxd/artemis/internal/inbox/models" imodels "github.com/abhinavxd/artemis/internal/inbox/models"
@@ -32,10 +33,13 @@ import (
"github.com/abhinavxd/artemis/internal/oidc" "github.com/abhinavxd/artemis/internal/oidc"
"github.com/abhinavxd/artemis/internal/role" "github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting" "github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/sla"
"github.com/abhinavxd/artemis/internal/tag" "github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team" "github.com/abhinavxd/artemis/internal/team"
tmpl "github.com/abhinavxd/artemis/internal/template" tmpl "github.com/abhinavxd/artemis/internal/template"
"github.com/abhinavxd/artemis/internal/user" "github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/view"
"github.com/abhinavxd/artemis/internal/workerpool"
"github.com/abhinavxd/artemis/internal/ws" "github.com/abhinavxd/artemis/internal/ws"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
@@ -189,9 +193,19 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
} }
// initConversations inits conversation manager. // initConversations inits conversation manager.
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sqlx.DB, contactStore *contact.Manager, func initConversations(
inboxStore *inbox.Manager, userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, automationEngine *automation.Engine, template *tmpl.Manager) *conversation.Manager { i18n *i18n.I18n,
c, err := conversation.New(hub, i18n, n, contactStore, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{ hub *ws.Hub,
n *notifier.Service,
db *sqlx.DB,
inboxStore *inbox.Manager,
userStore *user.Manager,
teamStore *team.Manager,
mediaStore *media.Manager,
automationEngine *automation.Engine,
template *tmpl.Manager,
) *conversation.Manager {
c, err := conversation.New(hub, i18n, n, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
DB: db, DB: db,
Lo: initLogger("conversation_manager"), Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -203,8 +217,8 @@ func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sq
return c return c
} }
// initTags inits tag manager. // initTag inits tag manager.
func initTags(db *sqlx.DB) *tag.Manager { func initTag(db *sqlx.DB) *tag.Manager {
var lo = initLogger("tag_manager") var lo = initLogger("tag_manager")
mgr, err := tag.New(tag.Opts{ mgr, err := tag.New(tag.Opts{
DB: db, DB: db,
@@ -216,6 +230,19 @@ func initTags(db *sqlx.DB) *tag.Manager {
return mgr return mgr
} }
// initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager {
var lo = initLogger("view_manager")
m, err := view.New(view.Opts{
DB: db,
Lo: lo,
})
if err != nil {
log.Fatalf("error initializing view manager: %v", err)
}
return m
}
// initCannedResponse inits canned response manager. // initCannedResponse inits canned response manager.
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager { func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
var lo = initLogger("canned-response") var lo = initLogger("canned-response")
@@ -229,26 +256,62 @@ func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
return c return c
} }
func initContact(db *sqlx.DB) *contact.Manager { // initBusinessHours inits business hours manager.
var lo = initLogger("contact-manager") func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
m, err := contact.New(contact.Opts{ var lo = initLogger("business-hours")
m, err := businesshours.New(businesshours.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing contact manager: %v", err) log.Fatalf("error initializing business hours manager: %v", err)
}
return m
}
// initSLA inits SLA manager.
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
var lo = initLogger("sla")
m, err := sla.New(sla.Opts{
DB: db,
Lo: lo,
ScannerInterval: ko.MustDuration("sla.scanner_interval"),
}, workerpool.New(ko.MustInt("sla.worker_count"), ko.MustInt("sla.queue_size")), teamManager, settings, businessHours)
if err != nil {
log.Fatalf("error initializing SLA manager: %v", err)
}
return m
}
// initCSAT inits CSAT manager.
func initCSAT(db *sqlx.DB) *csat.Manager {
var lo = initLogger("csat")
m, err := csat.New(csat.Opts{
DB: db,
Lo: lo,
})
if err != nil {
log.Fatalf("error initializing CSAT manager: %v", err)
} }
return m return m
} }
// initTemplates inits template manager. // initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
lo := initLogger("template") var (
tpls, err := stuffbin.ParseTemplatesGlob(getTmplFuncs(consts), fs, "/static/email-templates/*.html") lo = initLogger("template")
funcMap = getTmplFuncs(consts)
)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
log.Fatalf("error parsing e-mail templates: %v", err) log.Fatalf("error parsing e-mail templates: %v", err)
} }
m, err := tmpl.New(lo, db, tpls)
webTpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/public/web-templates/*.html")
if err != nil {
log.Fatalf("error parsing web templates: %v", err)
}
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
if err != nil { if err != nil {
log.Fatalf("error initializing template manager: %v", err) log.Fatalf("error initializing template manager: %v", err)
} }

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"strings" "strings"
@@ -12,13 +13,13 @@ import (
) )
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. // install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
func install(db *sqlx.DB, fs stuffbin.FileSystem) error { func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
installed, err := checkSchema(db) installed, err := checkSchema(db)
if err != nil { if err != nil {
log.Fatalf("error checking db schema: %v", err) log.Fatalf("error checking db schema: %v", err)
} }
if installed { if installed {
fmt.Printf("\033[31m** WARNING: This will wipe your entire DB - '%s' **\033[0m\n", ko.String("db.database")) fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
fmt.Print("Continue (y/n)? ") fmt.Print("Continue (y/n)? ")
var ok string var ok string
fmt.Scanf("%s", &ok) fmt.Scanf("%s", &ok)
@@ -35,15 +36,15 @@ func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
log.Println("Schema installed successfully") log.Println("Schema installed successfully")
// Create system user. // Create system user.
if err := user.CreateSystemUser(db); err != nil { if err := user.CreateSystemUser(ctx, db); err != nil {
log.Fatalf("error creating system user: %v", err) log.Fatalf("error creating system user: %v", err)
} }
return nil return nil
} }
// setSystemUserPass prompts for pass and sets system user password. // setSystemUserPass prompts for pass and sets system user password.
func setSystemUserPass(db *sqlx.DB) { func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
user.ChangeSystemUserPassword(db) user.ChangeSystemUserPassword(ctx, db)
} }
// checkSchema verifies if the DB schema is already installed by querying a table. // checkSchema verifies if the DB schema is already installed by querying a table.

View File

@@ -1,12 +1,13 @@
package main package main
import ( import (
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope" "github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
// handleLogin logs in the user. // handleLogin logs a user in.
func handleLogin(r *fastglue.Request) error { func handleLogin(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -14,11 +15,16 @@ func handleLogin(r *fastglue.Request) error {
email = string(p.Peek("email")) email = string(p.Peek("email"))
password = p.Peek("password") password = p.Peek("password")
) )
user, err := app.user.Login(email, password) user, err := app.user.VerifyPassword(email, password)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.auth.SaveSession(user, r); err != nil { if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
}, r); err != nil {
app.lo.Error("error saving session", "error", err) app.lo.Error("error saving session", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
} }

View File

@@ -10,11 +10,15 @@ import (
auth_ "github.com/abhinavxd/artemis/internal/auth" auth_ "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/authz" "github.com/abhinavxd/artemis/internal/authz"
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
"github.com/abhinavxd/artemis/internal/colorlog"
"github.com/abhinavxd/artemis/internal/csat"
notifier "github.com/abhinavxd/artemis/internal/notification" notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/abhinavxd/artemis/internal/sla"
"github.com/abhinavxd/artemis/internal/view"
"github.com/abhinavxd/artemis/internal/automation" "github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/priority" "github.com/abhinavxd/artemis/internal/conversation/priority"
"github.com/abhinavxd/artemis/internal/conversation/status" "github.com/abhinavxd/artemis/internal/conversation/status"
@@ -45,33 +49,37 @@ var (
// App is the global app context which is passed and injected in the http handlers. // App is the global app context which is passed and injected in the http handlers.
type App struct { type App struct {
consts constants consts constants
fs stuffbin.FileSystem fs stuffbin.FileSystem
auth *auth_.Auth auth *auth_.Auth
authz *authz.Enforcer authz *authz.Enforcer
i18n *i18n.I18n i18n *i18n.I18n
lo *logf.Logger lo *logf.Logger
oidc *oidc.Manager oidc *oidc.Manager
media *media.Manager media *media.Manager
setting *setting.Manager setting *setting.Manager
role *role.Manager role *role.Manager
contact *contact.Manager user *user.Manager
user *user.Manager team *team.Manager
team *team.Manager status *status.Manager
status *status.Manager priority *priority.Manager
priority *priority.Manager tag *tag.Manager
tag *tag.Manager inbox *inbox.Manager
inbox *inbox.Manager tmpl *template.Manager
tmpl *template.Manager cannedResp *cannedresp.Manager
cannedResp *cannedresp.Manager conversation *conversation.Manager
conversation *conversation.Manager automation *automation.Engine
automation *automation.Engine businessHours *businesshours.Manager
notifier *notifier.Service sla *sla.Manager
csat *csat.Manager
view *view.Manager
notifier *notifier.Service
} }
func main() { func main() {
// Set up signal handler. // Set up signal handler.
ctx, _ = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Load command line flags into Koanf. // Load command line flags into Koanf.
initFlags() initFlags()
@@ -95,13 +103,13 @@ func main() {
// Installer. // Installer.
if ko.Bool("install") { if ko.Bool("install") {
install(db, fs) install(ctx, db, fs)
os.Exit(0) os.Exit(0)
} }
// Set system user password. // Set system user password.
if ko.Bool("set-system-user-password") { if ko.Bool("set-system-user-password") {
setSystemUserPass(db) setSystemUserPass(ctx, db)
os.Exit(0) os.Exit(0)
} }
@@ -132,19 +140,20 @@ func main() {
auth = initAuth(oidc, rdb) auth = initAuth(oidc, rdb)
template = initTemplate(db, fs, constants) template = initTemplate(db, fs, constants)
media = initMedia(db) media = initMedia(db)
contact = initContact(db)
inbox = initInbox(db) inbox = initInbox(db)
team = initTeam(db) team = initTeam(db)
businessHours = initBusinessHours(db)
user = initUser(i18n, db) user = initUser(i18n, db)
notifier = initNotifier(user) notifier = initNotifier(user)
automation = initAutomationEngine(db, user) automation = initAutomationEngine(db, user)
conversation = initConversations(i18n, wsHub, notifier, db, contact, inbox, user, team, media, automation, template) sla = initSLA(db, team, settings, businessHours)
conversation = initConversations(i18n, wsHub, notifier, db, inbox, user, team, media, automation, template)
autoassigner = initAutoAssigner(team, user, conversation) autoassigner = initAutoAssigner(team, user, conversation)
) )
// Set stores. // Set stores.
wsHub.SetConversationStore(conversation) wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation) automation.SetConversationStore(conversation, sla)
// Start inbox receivers. // Start inbox receivers.
startInboxes(ctx, inbox, conversation) startInboxes(ctx, inbox, conversation)
@@ -161,37 +170,45 @@ func main() {
// Start notifier. // Start notifier.
go notifier.Run(ctx) go notifier.Run(ctx)
// Delete media not linked to any message. // Start SLA monitor.
go sla.Run(ctx)
// Purge unlinked message media.
go media.DeleteUnlinkedMessageMedia(ctx) go media.DeleteUnlinkedMessageMedia(ctx)
// Init the app // Init the app
var app = &App{ var app = &App{
lo: lo, lo: lo,
auth: auth, fs: fs,
fs: fs, sla: sla,
i18n: i18n, oidc: oidc,
media: media, i18n: i18n,
setting: settings, auth: auth,
contact: contact, media: media,
inbox: inbox, setting: settings,
user: user, inbox: inbox,
team: team, user: user,
tmpl: template, team: team,
conversation: conversation, tmpl: template,
automation: automation, notifier: notifier,
oidc: oidc, consts: constants,
consts: constants, conversation: conversation,
notifier: notifier, automation: automation,
authz: initAuthz(), businessHours: businessHours,
status: initStatus(db), view: initView(db),
priority: initPriority(db), csat: initCSAT(db),
role: initRole(db), authz: initAuthz(),
tag: initTags(db), status: initStatus(db),
cannedResp: initCannedResponse(db), priority: initPriority(db),
role: initRole(db),
tag: initTag(db),
cannedResp: initCannedResponse(db),
} }
// Init fastglue and set app in ctx. // Init fastglue and set app in ctx.
g := fastglue.NewGlue() g := fastglue.NewGlue()
// Set the app in context.
g.SetContext(app) g.SetContext(app)
// Init HTTP handlers. // Init HTTP handlers.
@@ -206,24 +223,37 @@ func main() {
ReadBufferSize: ko.MustInt("app.server.max_body_size"), ReadBufferSize: ko.MustInt("app.server.max_body_size"),
} }
log.Printf("%s🚀 server listening on %s %s\x1b[0m", "\x1b[32m", ko.String("app.server.address"), ko.String("app.server.socket"))
go func() { go func() {
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil { if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
log.Fatalf("error starting server: %v", err) log.Fatalf("error starting server: %v", err)
} }
}() }()
colorlog.Green("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
// Wait for shutdown signal.
<-ctx.Done() <-ctx.Done()
log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m") colorlog.Red("Shutting down the server. Please wait....")
// Shutdown HTTP server. // Shutdown HTTP server.
s.Shutdown() s.Shutdown()
colorlog.Red("Server shutdown complete.")
colorlog.Red("Shutting down services. Please wait....")
// Shutdown services. // Shutdown services.
inbox.Close() inbox.Close()
colorlog.Red("Inbox shutdown complete.")
automation.Close() automation.Close()
colorlog.Red("Automation shutdown complete.")
autoassigner.Close() autoassigner.Close()
colorlog.Red("Autoassigner shutdown complete.")
notifier.Close() notifier.Close()
colorlog.Red("Notifier shutdown complete.")
conversation.Close() conversation.Close()
colorlog.Red("Conversation shutdown complete.")
sla.Close()
colorlog.Red("SLA shutdown complete.")
db.Close() db.Close()
colorlog.Red("Database shutdown complete.")
rdb.Close() rdb.Close()
colorlog.Red("Redis shutdown complete.")
colorlog.Green("Shutdown complete.")
} }

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"net/http" "net/http"
amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope" "github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
@@ -27,7 +28,12 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
} }
// Set user in context if found. // Set user in context if found.
r.RequestCtx.SetUserValue("user", user) r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
})
return handler(r) return handler(r)
} }
@@ -52,25 +58,30 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
r.RequestCtx.SetUserValue("user", user) r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
})
return handler(r) return handler(r)
} }
} }
// authPerm does session validation, CSRF, and permission enforcement. // perm does session validation, CSRF, and permission enforcement.
func authPerm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler { func perm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token")) // cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN")) // hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
) )
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken { // if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken) // app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError) // return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
} // }
// Validate session and fetch user. // Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r) userSession, err := app.auth.ValidateSession(r)
@@ -96,7 +107,12 @@ func authPerm(handler fastglue.FastRequestHandler, object, action string) fastgl
} }
// Set user in the request context. // Set user in the request context.
r.RequestCtx.SetUserValue("user", user) r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
})
return handler(r) return handler(r)
} }

View File

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

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

View File

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

View File

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

139
cmd/views.go Normal file
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 ( import (
"fmt" "fmt"
umodels "github.com/abhinavxd/artemis/internal/user/models" amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/ws" "github.com/abhinavxd/artemis/internal/ws"
wsmodels "github.com/abhinavxd/artemis/internal/ws/models" wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
"github.com/fasthttp/websocket" "github.com/fasthttp/websocket"
@@ -26,10 +26,14 @@ var upgrader = websocket.FastHTTPUpgrader{
func handleWS(r *fastglue.Request, hub *ws.Hub) error { func handleWS(r *fastglue.Request, hub *ws.Hub) error {
var ( var (
user = r.RequestCtx.UserValue("user").(umodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
app = r.Context.(*App) app = r.Context.(*App)
) )
err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) { user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
err = upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
c := ws.Client{ c := ws.Client{
ID: user.ID, ID: user.ID,
Hub: hub, Hub: hub,

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex"> <Toaster />
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks" <Sidebar :isLoading="false" :open="sidebarOpen" :userTeams="userStore.teams" :userViews="userViews" @update:open="sidebarOpen = $event"
class="shadow shadow-gray-300 h-screen" /> @create-view="openCreateViewForm = true" @edit-view="editView" @delete-view="deleteView">
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel"> <ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizableHandle id="resize-handle-1" /> <ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2"> <ResizablePanel id="resize-panel-2">
@@ -9,75 +9,94 @@
<RouterView /> <RouterView />
</div> </div>
</ResizablePanel> </ResizablePanel>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </Sidebar>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js' import { initWS } from '@/websocket.js'
import { useEmitter } from '@/composables/useEmitter' import { Toaster } from '@/components/ui/sonner'
import { useToast } from '@/components/ui/toast/use-toast' import { useToast } from '@/components/ui/toast/use-toast'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import NavBar from '@/components/NavBar.vue' import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { useConversationStore } from './stores/conversation'
import ViewForm from '@/components/ViewForm.vue'
import api from '@/api'
import Sidebar from '@/components/sidebar/Sidebar.vue'
const { t } = useI18n()
const { toast } = useToast() const { toast } = useToast()
const emitter = useEmitter() const emitter = useEmitter()
const isCollapsed = ref(true) const sidebarOpen = ref(true)
const allNavLinks = [
{
title: t('navbar.dashboard'),
to: '/dashboard',
label: '',
icon: 'lucide:layout-dashboard',
permission: 'dashboard_global:read',
},
{
title: t('navbar.conversations'),
to: '/conversations',
label: '',
icon: 'lucide:message-circle-more'
},
{
title: t('navbar.account'),
to: '/account/profile',
label: '',
icon: 'lucide:circle-user-round'
},
{
title: t('navbar.admin'),
to: '/admin/general',
label: '',
icon: 'lucide:settings',
permission: 'admin:read'
}
]
const bottomLinks = [
{
to: '/logout',
icon: 'lucide:log-out',
title: 'Logout'
}
]
const userStore = useUserStore() const userStore = useUserStore()
const conversationStore = useConversationStore()
const router = useRouter() const router = useRouter()
initWS() const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
initWS()
onMounted(() => { onMounted(() => {
initToaster() initToaster()
listenViewRefresh()
getCurrentUser() getCurrentUser()
getUserViews()
intiStores()
}) })
onUnmounted(() => { onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast) emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
}) })
const intiStores = () => {
Promise.all([
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities()
])
}
const editView = (v) => {
view.value = { ...v }
openCreateViewForm.value = true
}
const deleteView = async (view) => {
try {
await api.deleteView(view.id)
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
variant: 'success',
description: 'View deleted successfully'
})
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
const getUserViews = async () => {
try {
const response = await api.getCurrentUserViews()
userViews.value = response.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
const getCurrentUser = () => { const getCurrentUser = () => {
userStore.getCurrentUser().catch((err) => { userStore.getCurrentUser().catch((err) => {
if (err.response && err.response.status === 401) { if (err.response && err.response.status === 401) {
@@ -90,9 +109,14 @@ const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast) emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
} }
const navLinks = computed(() => const listenViewRefresh = () => {
allNavLinks.filter((link) => emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
!link.permission || (userStore.permissions.includes(link.permission) && link.permission) }
)
) const refreshViews = (data) => {
openCreateViewForm.value = false
if (data?.model === 'view') {
getUserViews()
}
}
</script> </script>

View File

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

View File

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

View File

@@ -5,12 +5,10 @@
// App default font-size. // App default font-size.
// Default: 16px, 15px looks wide. // Default: 16px, 15px looks wide.
:root { :root {
font-size: 14px; font-size: 16px;
} }
body { body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden; overflow-y: hidden;
} }
@@ -176,27 +174,28 @@ body {
@apply p-0; @apply p-0;
} }
// Scrollbar
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 8px; /* Adjust width */
} height: 8px; /* Adjust height */
::-webkit-scrollbar-track {
background: #f1f1f1;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: #888; background-color: #888;
border-radius: 4px; border-radius: 10px;
border: 2px solid transparent; border: 2px solid transparent;
background-clip: content-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background-color: #555; background-color: #555; /* Hover effect */
} }
* { ::-webkit-scrollbar-track {
scrollbar-width: thin; background: #f0f0f0;
border-radius: 10px;
} }
// End Scrollbar
.code-editor { .code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative; @apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
@@ -212,3 +211,46 @@ body {
.ql-toolbar { .ql-toolbar {
@apply rounded-t-lg; @apply rounded-t-lg;
} }
@layer base {
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
.blinking-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}

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"> <div class="space-y-4 md:block page-content">
<PageHeader title="Account settings" subTitle="Manage your account settings." /> <PageHeader title="Account settings" subTitle="Manage your account settings." />
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> <div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
<SidebarNav :navItems="sidebarNavItems" />
</aside>
<div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]"> <div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
<div class="space-y-6"> <div class="space-y-6">
<slot></slot> <slot></slot>

View File

@@ -14,8 +14,8 @@
<div class="flex flex-col space-y-5 justify-center"> <div class="flex flex-col space-y-5 justify-center">
<input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif" <input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
@change="selectFile" /> @change="selectFile" />
<Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button> <Button class="w-28" @click="selectAvatar"> Choose a file... </Button>
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">Remove <Button class="w-28" @click="removeAvatar" variant="destructive">Remove
avatar</Button> avatar</Button>
</div> </div>
</div> </div>

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> <template>
<div class="space-y-4 md:block overflow-y-auto"> <div class="overflow-y-auto ">
<PageHeader title="Admin settings" subTitle="Manage your helpdesk settings." /> <slot></slot>
<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> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,96 +3,101 @@
<CustomBreadcrumb :links="breadcrumbLinks" /> <CustomBreadcrumb :links="breadcrumbLinks" />
</div> </div>
<Spinner v-if="isLoading"></Spinner> <Spinner v-if="isLoading"></Spinner>
<span>{{ formTitle }}</span> <div class="space-y-4">
<div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }"> <p>{{ formTitle }}</p>
<form @submit="onSubmit"> <div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<div class="space-y-5"> <form @submit="onSubmit">
<div class="space-y-5"> <div class="space-y-5">
<div class="space-y-5">
<FormField v-slot="{ field }" name="name"> <FormField v-slot="{ field }" name="name">
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="My new rule" v-bind="field" /> <Input type="text" placeholder="My new rule" v-bind="field" />
</FormControl> </FormControl>
<FormDescription>Name for the rule.</FormDescription> <FormDescription>Name for the rule.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ field }" name="description"> <FormField v-slot="{ field }" name="description">
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Description for new rule" v-bind="field" /> <Input type="text" placeholder="Description for new rule" v-bind="field" />
</FormControl> </FormControl>
<FormDescription>Description for the rule.</FormDescription> <FormDescription>Description for the rule.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField, handleInput }" name="type"> <FormField v-slot="{ componentField, handleInput }" name="type">
<FormItem> <FormItem>
<FormLabel>Type</FormLabel> <FormLabel>Type</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField" @update:modelValue="handleInput"> <Select v-bind="componentField" @update:modelValue="handleInput">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a type" /> <SelectValue placeholder="Select a type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="new_conversation"> New conversation </SelectItem> <SelectItem value="new_conversation"> New conversation </SelectItem>
<SelectItem value="conversation_update"> Conversation update </SelectItem> <SelectItem value="conversation_update"> Conversation update </SelectItem>
<SelectItem value="time_trigger"> Time trigger </SelectItem> <SelectItem value="time_trigger"> Time trigger </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription>Type of rule.</FormDescription> <FormDescription>Type of rule.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="events" v-if="form.values.type === 'conversation_update'"> <div :class="{ 'hidden': form.values.type !== 'conversation_update' }">
<FormItem> <FormField v-slot="{ componentField }" name="events">
<FormLabel>Events</FormLabel> <FormItem>
<FormControl> <FormLabel>Events</FormLabel>
<SelectTag v-bind="componentField" :items="conversationEvents" placeholder="Select events"></SelectTag> <FormControl>
</FormControl> <SelectTag v-bind="componentField" :items="conversationEvents || []" placeholder="Select events">
<FormDescription>Evaluate rule on these events.</FormDescription> </SelectTag>
<FormMessage></FormMessage> </FormControl>
</FormItem> <FormDescription>Evaluate rule on these events.</FormDescription>
</FormField> <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> </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> </div>
</form>
<RuleBox :ruleGroup="secondRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition" </div>
@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>
</div> </div>
</template> </template>
@@ -322,6 +327,9 @@ onMounted(async () => {
isLoading.value = true isLoading.value = true
let resp = await api.getAutomationRule(props.id) let resp = await api.getAutomationRule(props.id)
rule.value = resp.data.data rule.value = resp.data.data
if (resp.data.data.type === 'conversation_update') {
rule.value.rules.events = []
}
form.setValues(resp.data.data) form.setValues(resp.data.data)
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -1,14 +1,24 @@
import * as z from 'zod' import * as z from 'zod';
export const formSchema = z.object({ export const formSchema = z
name: z.string({ .object({
required_error: 'Rule name is required.' name: z.string({
}), required_error: 'Rule name is required.',
description: z.string({ }),
required_error: 'Rule description is required.' description: z.string({
}), required_error: 'Rule description is required.',
type: z.string({ }),
required_error: 'Rule type is required.' type: z.string({
}), required_error: 'Rule type is required.',
events: z.array(z.string()).min(1, 'Please select at least one event.'), }),
}) events: z.array(z.string()).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
ctx.addIssue({
path: ['events'],
message: 'Please select at least one event.',
code: z.ZodIssueCode.custom,
});
}
});

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

View File

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

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,21 +1,21 @@
<template> <template>
<div> <PageHeader title="Canned responses" description="Manage canned responses" />
<div class="w-8/12">
<div class="flex justify-between mb-5"> <div class="flex justify-between mb-5">
<PageHeader title="Canned responses" description="Manage canned responses" /> <div class="flex justify-end mb-4 w-full">
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DialogTrigger as-child> <DialogTrigger as-child>
<Button size="sm">New canned response</Button> <Button class="ml-auto">New canned response</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent class="sm:max-w-[625px]"> <DialogContent class="sm:max-w-[625px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New canned response</DialogTitle> <DialogTitle>New canned response</DialogTitle>
<DialogDescription>Set title and content, click save when you're done. </DialogDescription> <DialogDescription>Set title and content, click save when you're done. </DialogDescription>
</DialogHeader> </DialogHeader>
<CannedResponsesForm @submit="onSubmit"> <CannedResponsesForm @submit="onSubmit">
<template #footer> <template #footer>
<DialogFooter class="mt-7"> <DialogFooter class="mt-7">
<Button type="submit" size="sm">Save Changes</Button> <Button type="submit">Save Changes</Button>
</DialogFooter> </DialogFooter>
</template> </template>
</CannedResponsesForm> </CannedResponsesForm>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<Spinner v-if="formLoading"></Spinner> <Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }"> <form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }">
<FormField v-slot="{ field }" name="site_name"> <FormField v-slot="{ field }" name="site_name">
<FormItem> <FormItem>
<FormLabel>Site Name</FormLabel> <FormLabel>Site Name</FormLabel>
@@ -12,11 +12,11 @@
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ field }" name="lang"> <FormField v-slot="{ componentField }" name="lang">
<FormItem> <FormItem>
<FormLabel>Language</FormLabel> <FormLabel>Language</FormLabel>
<FormControl> <FormControl>
<Select v-bind="field" :modelValue="field.value"> <Select v-bind="componentField" :modelValue="componentField.modelValue">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a language" /> <SelectValue placeholder="Select a language" />
</SelectTrigger> </SelectTrigger>
@@ -31,6 +31,50 @@
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="timezone">
<FormItem>
<FormLabel>Timezone</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
{{ timezone }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Default timezone.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="business_hours_id">
<FormItem>
<FormLabel>Business hours</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select business hours" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
{{ bh.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Default business hours.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="root_url"> <FormField v-slot="{ field }" name="root_url">
<FormItem> <FormItem>
@@ -92,12 +136,12 @@
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button> <Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form> </form>
</template> </template>
<script setup> <script setup>
import { watch, ref } from 'vue' import { watch, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
@@ -130,10 +174,13 @@ import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const emitter = useEmitter() const emitter = useEmitter()
const timezones = Intl.supportedValuesOf('timeZone')
const isLoading = ref(false) const isLoading = ref(false)
const formLoading = ref(true) const formLoading = ref(true)
const businessHours = ref({})
const props = defineProps({ const props = defineProps({
initialValues: { initialValues: {
type: Object, type: Object,
@@ -154,6 +201,36 @@ const form = useForm({
validationSchema: toTypedSchema(formSchema) validationSchema: toTypedSchema(formSchema)
}) })
onMounted(() => {
fetchBusinessHours()
})
const fetchBusinessHours = async () => {
try {
const response = await api.getAllBusinessHours()
// Convert business hours id to string
response.data.data.forEach(bh => {
bh.id = bh.id.toString()
})
businessHours.value = response.data.data
} catch (error) {
// If unauthorized (no permission), show a toast message.
if (error.response.status === 403) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Unauthorized',
variant: 'destructive',
description: 'You do not have permission to view business hours.'
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch business hours',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
}
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
try { try {
isLoading.value = true isLoading.value = true
@@ -176,9 +253,15 @@ const onSubmit = form.handleSubmit(async (values) => {
watch( watch(
() => props.initialValues, () => props.initialValues,
(newValues) => { (newValues) => {
if (Object.keys(newValues).length === 0) {
return
}
// Convert business hours id to string
if (newValues.business_hours_id)
newValues.business_hours_id = newValues.business_hours_id.toString()
form.setValues(newValues) form.setValues(newValues)
formLoading.value = false formLoading.value = false
}, },
{ deep: true } { deep: true, immediate: true }
) )
</script> </script>

View File

@@ -9,20 +9,25 @@ export const formSchema = z.object({
message: 'Site name must be at least 1 characters.' message: 'Site name must be at least 1 characters.'
}), }),
lang: z.string().optional(), lang: z.string().optional(),
timezone: z.string().optional(),
business_hours_id: z.string().optional(),
logo_url: z.string().url({
message: 'Logo URL must be a valid URL.'
}).url().optional(),
root_url: z root_url: z
.string({ .string({
required_error: 'Root URL is required.' required_error: 'Root URL is required.'
}) })
.url({ .url({
message: 'Root URL must be a valid URL.' message: 'Root URL must be a valid URL.'
}), }).url(),
favicon_url: z favicon_url: z
.string({ .string({
required_error: 'Favicon URL is required.' required_error: 'Favicon URL is required.'
}) })
.url({ .url({
message: 'Favicon URL must be a valid URL.' message: 'Favicon URL must be a valid URL.'
}), }).url(),
max_file_upload_size: z max_file_upload_size: z
.number({ .number({
required_error: 'Max upload file size is required.' required_error: 'Max upload file size is required.'

View File

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

View File

@@ -1,18 +1,20 @@
<template> <template>
<div> <PageHeader title="Inboxes" description="Manage your inboxes" />
<div class="flex justify-between mb-5"> <div class="w-8/12">
<PageHeader title="Inboxes" description="Manage your inboxes" /> <template v-if="router.currentRoute.value.path === '/admin/inboxes'">
<div class="flex justify-end mb-4"> <div class="flex justify-between mb-5">
<Button @click="navigateToAddInbox" size="sm"> New inbox </Button> <div class="flex justify-end w-full mb-4">
<Button @click="navigateToAddInbox"> New inbox </Button>
</div>
</div> </div>
</div> <div>
<div> <Spinner v-if="isLoading"></Spinner>
<Spinner v-if="isLoading"></Spinner> <DataTable :columns="columns" :data="data" v-else />
<DataTable :columns="columns" :data="data" v-else /> </div>
</div> </template>
</div> <template v-else>
<div> <router-view/>
<router-view></router-view> </template>
</div> </div>
</template> </template>
@@ -99,7 +101,7 @@ const columns = [
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',
header: function () { header: function () {
return h('div', { class: 'text-center' }, 'Modified at') return h('div', { class: 'text-center' }, 'Updated at')
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp')) return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))

View File

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

View File

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

View File

@@ -1,19 +1,5 @@
<template> <template>
<form @submit="onSmtpSubmit" class="space-y-6" <form @submit="onSmtpSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<!-- Enabled Field -->
<FormField name="enabled" v-slot="{ value, handleChange }">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Enabled</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- SMTP Host Field --> <!-- SMTP Host Field -->
<FormField v-slot="{ componentField }" name="host"> <FormField v-slot="{ componentField }" name="host">
@@ -120,7 +106,8 @@
<FormItem> <FormItem>
<FormLabel>From Email Address</FormLabel> <FormLabel>From Email Address</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" /> <Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>"
v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>From email address. e.g. My Support &lt;mysupport@example.com&gt;</FormDescription> <FormDescription>From email address. e.g. My Support &lt;mysupport@example.com&gt;</FormDescription>
@@ -138,7 +125,20 @@
</FormItem> </FormItem>
</FormField> </FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button> <!-- Enabled Field -->
<FormField name="enabled" v-slot="{ value, handleChange }">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Enabled</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form> </form>
</template> </template>

View File

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

View File

@@ -1,13 +1,21 @@
<template> <template>
<div class="flex justify-between mb-5"> <PageHeader title="OpenID Connect" description="Manage OpenID Connect configurations" />
<PageHeader title="OpenID Connect SSO" description="Manage OpenID SSO configurations" /> <div class="w-8/12">
<div> <template v-if="router.currentRoute.value.path === '/admin/oidc'">
<Button size="sm" @click="navigateToAddOIDC">New OIDC</Button> <div class="flex justify-between mb-5">
</div> <div></div>
</div> <div>
<div> <Button @click="navigateToAddOIDC">New OIDC</Button>
<Spinner v-if="isLoading"></Spinner> </div>
<DataTable :columns="columns" :data="oidc" v-else /> </div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="oidc" v-else />
</div>
</template>
<template v-else>
<router-view/>
</template>
</div> </div>
</template> </template>

View File

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

View File

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

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 = [ const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' }, { path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Edit role' } { path: '#', label: 'Edit role' }
] ]

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
<template> <template>
<div class="mb-5"> <PageHeader title="Roles" description="Manage roles" />
<CustomBreadcrumb :links="breadcrumbLinks" /> <div class="w-8/12">
<div 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>
<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> </template>
<script setup> <script setup>
@@ -25,6 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import PageHeader from '@/components/admin/common/PageHeader.vue'
const { toast } = useToast() const { toast } = useToast()
const emit = useEmitter() const emit = useEmitter()
@@ -32,7 +35,7 @@ const router = useRouter()
const roles = ref([]) const roles = ref([])
const isLoading = ref(false) const isLoading = ref(false)
const breadcrumbLinks = [ const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Roles' } { path: '#', label: 'Roles' }
] ]

View File

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

View File

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

View File

@@ -8,9 +8,13 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const router = useRouter() const router = useRouter()
const emit = useEmitter()
const props = defineProps({ const props = defineProps({
team: { team: {
type: Object, type: Object,
@@ -21,9 +25,28 @@ const props = defineProps({
} }
}) })
function editTeam(id) { function editTeam (id) {
router.push({ path: `/admin/teams/teams/${id}/edit` }) router.push({ path: `/admin/teams/teams/${id}/edit` })
} }
async function deleteTeam (id) {
try {
await api.deleteTeam(id)
emitRefreshTeamList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const emitRefreshTeamList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'team'
})
}
</script> </script>
<template> <template>
@@ -36,6 +59,7 @@ function editTeam(id) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem> <DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
<DropdownMenuItem @click="deleteTeam(props.team.id)"> Delete </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</template> </template>

View File

@@ -11,32 +11,93 @@
</FormItem> </FormItem>
</FormField> </FormField>
<FormField name="auto_assign_conversations" v-slot="{ value, handleChange }"> <FormField name="conversation_assignment_type" v-slot="{ componentField }">
<FormItem> <FormItem>
<FormControl> <FormControl>
<div class="flex items-center space-x-2"> <Select v-bind="componentField">
<Checkbox :checked="value" @update:checked="handleChange" /> <SelectTrigger>
<Label>Auto assign conversations</Label> <SelectValue placeholder="Select a assignment type" />
</div> </SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="at in assignmentTypes" :key="at" :value="at">
{{ at }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormDescription>Automatically assign new conversations to agents in this team in a round-robin fashion.</FormDescription> <FormDescription>
Round robin: Conversations are assigned to team members in a round-robin fashion. <br>
Manual: Conversations are manually assigned to team members.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button> <FormField v-slot="{ componentField }" name="timezone">
<FormItem>
<FormLabel>Timezone</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
{{ timezone }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Team's timezone will be used to calculate SLA.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="business_hours_id">
<FormItem>
<FormLabel>Business hours</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select business hours" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
{{ bh.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Default business hours.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form> </form>
</template> </template>
<script setup> <script setup>
import { watch } from 'vue' import { watch, computed, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { teamFormSchema } from './teamFormSchema.js' import { teamFormSchema } from './teamFormSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue' import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { import {
FormControl, FormControl,
FormField, FormField,
@@ -45,8 +106,18 @@ import {
FormMessage, FormMessage,
FormDescription FormDescription
} from '@/components/ui/form' } from '@/components/ui/form'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const emitter = useEmitter()
const timezones = computed(() => {
return Intl.supportedValuesOf('timeZone')
})
const assignmentTypes = ['Round robin', 'Manual']
const businessHours = ref([])
const props = defineProps({ const props = defineProps({
initialValues: { initialValues: {
type: Object, type: Object,
@@ -71,6 +142,32 @@ const form = useForm({
validationSchema: toTypedSchema(teamFormSchema) validationSchema: toTypedSchema(teamFormSchema)
}) })
onMounted(() => {
fetchBusinessHours()
})
const fetchBusinessHours = async () => {
try {
const response = await api.getAllBusinessHours()
businessHours.value = response.data.data
} catch (error) {
// If unauthorized (no permission), show a toast message.
if (error.response.status === 403) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Unauthorized',
variant: 'destructive',
description: 'You do not have permission to view business hours.'
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch business hours',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
}
const onSubmit = form.handleSubmit((values) => { const onSubmit = form.handleSubmit((values) => {
props.submitForm(values) props.submitForm(values)
}) })
@@ -79,6 +176,7 @@ const onSubmit = form.handleSubmit((values) => {
watch( watch(
() => props.initialValues, () => props.initialValues,
(newValues) => { (newValues) => {
if (Object.keys(newValues).length === 0) return
form.setValues(newValues) form.setValues(newValues)
}, },
{ immediate: true } { immediate: true }

View File

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

View File

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

View File

@@ -8,5 +8,7 @@ export const teamFormSchema = z.object({
.min(2, { .min(2, {
message: 'Team name must be at least 2 characters.' message: 'Team name must be at least 2 characters.'
}), }),
auto_assign_conversations: z.boolean().optional() conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
business_hours_id : z.number({ required_error: 'Business hours is required.' }),
timezone: z.string().optional(),
}) })

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,19 @@
<template> <template>
<div class="mb-5"> <PageHeader title="Users" description="Manage users" />
<CustomBreadcrumb :links="breadcrumbLinks" /> <div class="w-8/12">
<div 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>
<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> </template>
<script setup> <script setup>
@@ -22,6 +26,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb' import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import api from '@/api'
@@ -32,7 +37,7 @@ const isLoading = ref(false)
const data = ref([]) const data = ref([])
const emit = useEmitter() const emit = useEmitter()
const breadcrumbLinks = [ const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Users' } { path: '#', label: 'Users' }
] ]

View File

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

View File

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

View File

@@ -1,30 +1,55 @@
<template> <template>
<div> <PageHeader title="Email templates" description="Manage email templates" />
<div class="flex justify-between mb-5"> <div class="w-8/12">
<PageHeader title="Email Templates" description="Manage outgoing email templates" /> <template v-if="router.currentRoute.value.path === '/admin/templates'">
<div class="flex justify-end mb-4"> <div class="flex justify-between mb-5">
<Button @click="navigateToAddTemplate" size="sm"> New template </Button> <div></div>
<div class="flex justify-end mb-4">
<Button @click="navigateToAddTemplate"> New template </Button>
</div>
</div> </div>
</div> <div>
<div> <Spinner v-if="isLoading"></Spinner>
<Spinner v-if="isLoading"></Spinner> <Tabs default-value="email_outgoing" v-model="templateType">
<DataTable :columns="columns" :data="templates" /> <TabsList class="grid w-full grid-cols-2 mb-5">
</div> <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> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import DataTable from '@/components/admin/DataTable.vue' import DataTable from '@/components/admin/DataTable.vue'
import { columns } from '@/components/admin/templates/dataTableColumns.js' import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue' import PageHeader from '@/components/admin/common/PageHeader.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { useStorage } from '@vueuse/core'
import api from '@/api' import api from '@/api'
const templateType = useStorage('templateType', 'email_outgoing')
const templates = ref([]) const templates = ref([])
const isLoading = ref(false) const isLoading = ref(false)
const router = useRouter() const router = useRouter()
@@ -42,7 +67,7 @@ onUnmounted(() => {
const fetchAll = async () => { const fetchAll = async () => {
try { try {
isLoading.value = true isLoading.value = true
const resp = await api.getTemplates() const resp = await api.getTemplates(templateType.value)
templates.value = resp.data.data templates.value = resp.data.data
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -56,4 +81,8 @@ const refreshList = (data) => {
const navigateToAddTemplate = () => { const navigateToAddTemplate = () => {
router.push('/admin/templates/new') router.push('/admin/templates/new')
} }
watch(templateType, () => {
fetchAll()
})
</script> </script>

View File

@@ -2,7 +2,7 @@ import { h } from 'vue'
import dropdown from './dataTableDropdown.vue' import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns' import { format } from 'date-fns'
export const columns = [ export const outgoingEmailTemplatesColumns = [
{ {
accessorKey: 'name', accessorKey: 'name',
header: function () { header: function () {
@@ -30,7 +30,44 @@ export const columns = [
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',
header: function () { header: function () {
return h('div', { class: 'text-center' }, 'Modified at') return h('div', { class: 'text-center' }, 'Updated at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const template = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
template
})
)
}
}
]
export const emailNotificationTemplates = [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Updated at')
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp')) return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))

View File

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

View File

@@ -1,11 +1,23 @@
import * as z from 'zod' import * as z from 'zod';
export const formSchema = z.object({ export const formSchema = z
name: z.string({ .object({
required_error: 'Template name is required.' name: z.string({
}), required_error: 'Template name is required.',
body: z.string({ }),
required_error: 'Template content is required.' body: z.string({
}), required_error: 'Template content is required.',
is_default: z.boolean().optional() }),
}) type: z.string().optional(),
subject: z.string().optional(),
is_default: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.type !== 'email_outgoing' && !data.subject) {
ctx.addIssue({
path: ['subject'],
message: 'Subject is required.',
code: z.ZodIssueCode.custom,
});
}
});

View File

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

View File

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

View File

@@ -1,75 +1,75 @@
<template> <template>
<div v-for="(filter, index) in modelValue" :key="index"> <div class="space-y-4">
<div class="flex items-center space-x-2 mb-2 flex-row justify-between"> <div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
<div class="w-1/3"> <div class="grid grid-cols-3 gap-2 w-full">
<Select v-model="filter.field" @update:modelValue="updateFieldModel(filter, $event)"> <!-- Field -->
<SelectTrigger class="w-full"> <Select v-model="modelFilter.field">
<SelectValue placeholder="Select Field" /> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem v-for="field in fields" :key="field.value" :value="field.value"> <SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }} {{ field.label }}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div class="w-1/3"> <!-- Operator -->
<Select v-model="filter.operator"> <Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="w-full"> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Select Operator" /> <SelectValue placeholder="Operator" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem v-for="operator in getFieldOperators(filter.field)" :key="operator.value" <SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
:value="operator.value"> {{ op }}
{{ operator.label }}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<!-- 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>
<div v-if="getFieldType(filter.field) === 'text'" class="w-1/3"> <button v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<Input v-model="filter.value" type="text" placeholder="Value" class="w-full" /> <X class="w-4 h-4 text-slate-500" />
</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> </button>
</div> </div>
</div>
<div class="flex justify-between mt-4"> <div class="flex items-center justify-between pt-3">
<Button size="sm" @click="addFilter">Add Filter</Button> <Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<div class="flex justify-end space-x-4"> <Plus class="w-3 h-3 mr-1" /> Add filter
<Button size="sm" @click="applyFilters">Apply</Button> </Button>
<Button size="sm" @click="clearFilters">Clear</Button> <div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted, watch, onUnmounted } from 'vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -78,7 +78,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { X } from 'lucide-vue-next' import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -87,29 +87,16 @@ const props = defineProps({
type: Array, type: Array,
required: true, required: true,
}, },
showButtons: {
type: Boolean,
default: true,
},
}) })
const emit = defineEmits(['apply', 'clear']) const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: true }) const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const operatorsByType = {
text: [ const createFilter = () => ({ field: '', operator: '', value: '' })
{ label: 'Equals', value: '=' },
{ label: 'Not Equals', value: '!=' },
],
select: [
{ label: 'Equals', value: '=' },
{ label: 'Not Equals', value: '!=' },
],
number: [
{ label: 'Equals', value: '=' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Greater Than', value: '>' },
{ label: 'Less Than', value: '<' },
{ label: 'Greater Than or Equal', value: '>=' },
{ label: 'Less Than or Equal', value: '<=' },
],
}
const createFilter = () => ({ model: '', field: '', operator: '', value: '' })
onMounted(() => { onMounted(() => {
if (modelValue.value.length === 0) { if (modelValue.value.length === 0) {
@@ -117,46 +104,42 @@ onMounted(() => {
} }
}) })
const addFilter = () => { onUnmounted(() => {
modelValue.value.push(createFilter()) modelValue.value = []
}
const removeFilter = (index) => {
modelValue.value.splice(index, 1)
}
const applyFilters = () => {
if (validFilters.value.length > 0) emit('apply', validFilters.value)
}
const validFilters = computed(() => {
return modelValue.value.filter(filter => filter.field !== "" && filter.operator != "" && filter.value != "")
}) })
const getModel = (field) => {
const fieldConfig = props.fields.find(f => f.field === field)
return fieldConfig?.model || ''
}
watch(() => modelValue.value, (filters) => {
filters.forEach(filter => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
}, { deep: true })
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => { const clearFilters = () => {
modelValue.value = [] modelValue.value = []
emit('clear') emit('clear')
} }
const getFieldOperators = computed(() => (fieldValue) => { const validFilters = computed(() => {
const field = props.fields.find(f => f.value === fieldValue) return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
return field ? operatorsByType[field.type] : []
}) })
const getFieldType = computed(() => (fieldValue) => { const getFieldOptions = (fieldValue) => {
const field = props.fields.find(f => f.value === fieldValue) const field = props.fields.find(f => f.field === fieldValue.field)
return field ? field.type : 'text' return field?.options || []
})
const getFieldOptions = computed(() => (fieldValue) => {
const 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
}
} }
</script>
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"> <div class="relative" v-if="conversationStore.messages.data">
<!-- Header --> <!-- Header -->
<div class="px-4 border-b h-[47px] flex items-center justify-between shadow shadow-gray-100"> <div class="px-4 border-b h-[44px] flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm"> <div class="flex items-center space-x-3 text-sm">
<div class="font-semibold"> <div class="font-medium">
{{ conversationStore.current.subject }} {{ conversationStore.current.subject }}
</div> </div>
</div> </div>
<div> <div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Badge variant="primary"> <div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
{{ conversationStore.current.status }} <GalleryVerticalEnd size="14" class="text-secondary" />
</Badge> <span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
</div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)"> <DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
@@ -37,13 +38,16 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue' import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
GalleryVerticalEnd,
} from 'lucide-vue-next'
import MessageList from '@/components/message/MessageList.vue' import MessageList from '@/components/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue' import ReplyBox from './ReplyBox.vue'
import api from '@/api' import api from '@/api'

View File

@@ -1,15 +1,44 @@
<template> <template>
<div class="h-screen flex flex-col"> <div class="h-screen flex flex-col">
<!-- Filters -->
<div class="shrink-0"> <div class="flex justify-between px-2 py-2 w-full">
<ConversationListFilters @updateFilters="handleUpdateFilters" /> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
{{ conversationStore.getListStatus }}
<ChevronDown class="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
@click="handleStatusChange(status)">
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
{{ conversationStore.getListSortField }}
<ChevronDown class="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_first')">Started first</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_last')">Started last</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('waiting_longest')">Waiting longest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('next_sla_target')">Next SLA target</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('priority_first')">Priority first</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<!-- Empty list --> <!-- Empty -->
<EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found" <EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList> message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
<!-- List --> <!-- List -->
<div class="flex-grow overflow-y-auto"> <div class="flex-grow overflow-y-auto">
<EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations" <EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
@@ -17,12 +46,15 @@
<!-- Items --> <!-- Items -->
<div v-else> <div v-else>
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current" <div class="space-y-5 px-2">
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid" <ConversationListItem class="mt-2" :conversation="conversation"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" /> :currentConversation="conversationStore.current"
v-for="conversation in conversationStore.conversationsList" :key="conversation.uuid"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
</div>
</div> </div>
<!-- List skeleton --> <!-- skeleton -->
<div v-if="isLoading"> <div v-if="isLoading">
<ConversationListItemSkeleton v-for="index in 10" :key="index" /> <ConversationListItemSkeleton v-for="index in 10" :key="index" />
</div> </div>
@@ -46,40 +78,47 @@
<script setup> <script setup>
import { onMounted, computed, onUnmounted } from 'vue' import { onMounted, computed, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { MessageCircleQuestion, MessageCircleWarning } from 'lucide-vue-next' import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue' import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue' import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue' import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
let listRefreshInterval = null let reFetchInterval = null
// Re-fetch conversations list every 30 seconds for any missed updates.
onMounted(() => { onMounted(() => {
conversationStore.fetchConversationsList() reFetchInterval = setInterval(() => {
// Refresh list every min. conversationStore.reFetchConversationsList(false)
listRefreshInterval = setInterval(() => { }, 30000)
conversationStore.fetchConversationsList(false)
}, 60000)
}) })
onUnmounted(() => { onUnmounted(() => {
clearInterval(listRefreshInterval) clearInterval(reFetchInterval)
conversationStore.clearListReRenderInterval() conversationStore.clearListReRenderInterval()
}) })
const handleStatusChange = (status) => {
conversationStore.setListStatus(status.label)
}
const handleSortChange = (order) => {
conversationStore.setListSortField(order)
}
const loadNextPage = () => { const loadNextPage = () => {
conversationStore.fetchNextConversations() conversationStore.fetchNextConversations()
} }
const handleUpdateFilters = (filters) => {
console.log("setting ", filters)
conversationStore.setConversationListFilters(filters)
}
const hasConversations = computed(() => { const hasConversations = computed(() => {
return conversationStore.sortedConversations.length !== 0 return conversationStore.conversationsList.length !== 0
}) })
const hasErrored = computed(() => { const hasErrored = computed(() => {

View File

@@ -1,19 +1,12 @@
<template> <template>
<div class="flex justify-between px-2 py-2 border-b w-full"> <div class="flex justify-end px-2 py-2 border-b w-full">
<Tabs v-model="conversationStore.conversations.type">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full">Assigned</TabsTrigger>
<TabsTrigger value="unassigned" class="w-full">Unassigned</TabsTrigger>
<TabsTrigger value="all" class="w-full">All</TabsTrigger>
</TabsList>
</Tabs>
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<div class="flex items-center mr-2 relative"> <div class="flex items-center mr-2 relative">
<span class="absolute inline-flex h-2 w-2 rounded-full bg-primary opacity-75 right-0 bottom-5 z-20" <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" /> v-if="conversationStore.conversations.filters.length > 0" />
<ListFilter size="27" <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> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-[450px]"> <PopoverContent class="w-[450px]">
@@ -25,10 +18,25 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ListFilter } from 'lucide-vue-next' import { ListFilter, ChevronDown } from 'lucide-vue-next'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button'
import Filter from '@/components/common/Filter.vue' import Filter from '@/components/common/Filter.vue'
import api from '@/api' import api from '@/api'
@@ -44,6 +52,14 @@ onMounted(() => {
localFilters.value = [...conversationStore.conversations.filters] localFilters.value = [...conversationStore.conversations.filters]
}) })
const handleStatusChange = (status) => {
console.log('status', status)
}
const handleSortChange = (order) => {
console.log('order', order)
}
const fetchInitialData = async () => { const fetchInitialData = async () => {
const [statusesResp, prioritiesResp] = await Promise.all([ const [statusesResp, prioritiesResp] = await Promise.all([
api.getStatuses(), api.getStatuses(),
@@ -94,4 +110,4 @@ const handleClear = () => {
emit('updateFilters', []) emit('updateFilters', [])
open.value = false open.value = false
} }
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50" <div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }" :class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
@click="router.push('/conversations/' + conversation.uuid)"> @click="router.push('/conversations/' + conversation.uuid)">
<div class="pl-3"> <div class="pl-3">
@@ -12,7 +12,7 @@
</Avatar> </Avatar>
</div> </div>
<div class="ml-3 w-full border-b pb-2"> <div class="ml-3 w-full pb-2">
<div class="flex justify-between pt-2 pr-3"> <div class="flex justify-between pt-2 pr-3">
<div> <div>
<p class="text-xs text-gray-600 flex gap-x-1"> <p class="text-xs text-gray-600 flex gap-x-1">
@@ -42,6 +42,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex space-x-2 mt-2">
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -52,6 +56,7 @@ import { useRouter } from 'vue-router'
import { formatTime } from '@/utils/datetime' import { formatTime } from '@/utils/datetime'
import { Mail, CheckCheck } from 'lucide-vue-next' import { Mail, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({

View File

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

View File

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

View File

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

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