mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 05:23:48 +00:00
WIP: MVP with shadcn sidebar
- csat - SLA - email notification templates
This commit is contained in:
@@ -3,8 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
@@ -55,7 +55,12 @@ func handleOIDCCallback(r *fastglue.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the session.
|
// Set the session.
|
||||||
if err := app.auth.SaveSession(user, r); err != nil {
|
if err := app.auth.SaveSession(amodels.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email.String,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
}, r); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
cmd/business_hours.go
Normal file
104
cmd/business_hours.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
models "github.com/abhinavxd/artemis/internal/business_hours/models"
|
||||||
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetBusinessHours returns all business hours.
|
||||||
|
func handleGetBusinessHours(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
businessHours, err := app.businessHours.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(businessHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetBusinessHour returns the business hour with the given id.
|
||||||
|
func handleGetBusinessHour(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
businessHour, err := app.businessHours.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(businessHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateBusinessHours creates a new business hour.
|
||||||
|
func handleCreateBusinessHours(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
businessHours = models.BusinessHours{}
|
||||||
|
)
|
||||||
|
if err := r.Decode(&businessHours, "json"); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if businessHours.Name == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteBusinessHour deletes the business hour with the given id.
|
||||||
|
func handleDeleteBusinessHour(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.businessHours.Delete(id)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateBusinessHours updates the business hour with the given id.
|
||||||
|
func handleUpdateBusinessHours(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
businessHours = models.BusinessHours{}
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Decode(&businessHours, "json"); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if businessHours.Name == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/automation/models"
|
"github.com/abhinavxd/artemis/internal/automation/models"
|
||||||
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/volatiletech/null/v9"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,13 +26,23 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
|||||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||||
total = 0
|
total = 0
|
||||||
)
|
)
|
||||||
conversations, pageSize, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
|
|
||||||
|
conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(conversations) > 0 {
|
if len(conversations) > 0 {
|
||||||
total = conversations[0].Total
|
total = conversations[0].Total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||||
|
for i := range conversations {
|
||||||
|
if conversations[i].SLAPolicyID.Int != 0 {
|
||||||
|
calculateSLA(app, &conversations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return r.SendEnvelope(envelope.PageResults{
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
Results: conversations,
|
Results: conversations,
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -43,21 +56,29 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
|||||||
func handleGetAssignedConversations(r *fastglue.Request) error {
|
func handleGetAssignedConversations(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||||
|
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
|
||||||
total = 0
|
total = 0
|
||||||
)
|
)
|
||||||
conversations, pageSize, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
}
|
}
|
||||||
if len(conversations) > 0 {
|
if len(conversations) > 0 {
|
||||||
total = conversations[0].Total
|
total = conversations[0].Total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||||
|
for i := range conversations {
|
||||||
|
if conversations[i].SLAPolicyID.Int != 0 {
|
||||||
|
calculateSLA(app, &conversations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return r.SendEnvelope(envelope.PageResults{
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
Results: conversations,
|
Results: conversations,
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -71,21 +92,130 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
|
|||||||
func handleGetUnassignedConversations(r *fastglue.Request) error {
|
func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
|
||||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||||
|
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
|
||||||
total = 0
|
total = 0
|
||||||
)
|
)
|
||||||
conversations, pageSize, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
|
||||||
|
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
}
|
}
|
||||||
if len(conversations) > 0 {
|
if len(conversations) > 0 {
|
||||||
total = conversations[0].Total
|
total = conversations[0].Total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||||
|
for i := range conversations {
|
||||||
|
if conversations[i].SLAPolicyID.Int != 0 {
|
||||||
|
calculateSLA(app, &conversations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
|
Results: conversations,
|
||||||
|
Total: total,
|
||||||
|
PerPage: pageSize,
|
||||||
|
TotalPages: (total + pageSize - 1) / pageSize,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetViewConversations retrieves conversations for a view.
|
||||||
|
func handleGetViewConversations(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("view_id").(string))
|
||||||
|
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||||
|
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||||
|
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||||
|
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||||
|
total = 0
|
||||||
|
)
|
||||||
|
if viewID < 1 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the view.
|
||||||
|
view, err := app.view.Get(viewID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
if view.UserID != user.ID {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations, err := app.conversation.GetViewConversationsList(user.ID, view.InboxType, order, orderBy, string(view.Filters), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
if len(conversations) > 0 {
|
||||||
|
total = conversations[0].Total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||||
|
for i := range conversations {
|
||||||
|
if conversations[i].SLAPolicyID.Int != 0 {
|
||||||
|
calculateSLA(app, &conversations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
|
Results: conversations,
|
||||||
|
Total: total,
|
||||||
|
PerPage: pageSize,
|
||||||
|
TotalPages: (total + pageSize - 1) / pageSize,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user.
|
||||||
|
func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
teamIDStr = r.RequestCtx.UserValue("team_id").(string)
|
||||||
|
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||||
|
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||||
|
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||||
|
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||||
|
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||||
|
total = 0
|
||||||
|
)
|
||||||
|
teamID, _ := strconv.Atoi(teamIDStr)
|
||||||
|
if teamID < 1 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user belongs to the team.
|
||||||
|
exists, err := app.team.UserBelongsToTeam(teamID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
if len(conversations) > 0 {
|
||||||
|
total = conversations[0].Total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||||
|
for i := range conversations {
|
||||||
|
if conversations[i].SLAPolicyID.Int != 0 {
|
||||||
|
calculateSLA(app, &conversations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return r.SendEnvelope(envelope.PageResults{
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
Results: conversations,
|
Results: conversations,
|
||||||
Total: total,
|
Total: total,
|
||||||
@@ -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
81
cmd/csat.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleShowCSAT renders the CSAT page for a given csat.
|
||||||
|
func handleShowCSAT(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||||
|
)
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||||
|
"error_message": "Page not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
csat, err := app.csat.Get(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||||
|
"error_message": "CSAT not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if csat.ResponseTimestamp.Valid {
|
||||||
|
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||||
|
"message": "You've already submitted your feedback",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
|
||||||
|
if err != nil {
|
||||||
|
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||||
|
"error_message": "Conversation not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||||
|
"csat": map[string]interface{}{
|
||||||
|
"uuid": csat.UUID,
|
||||||
|
},
|
||||||
|
"conversation": map[string]interface{}{
|
||||||
|
"subject": conversation.Subject.String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateCSATResponse updates the CSAT response for a given csat.
|
||||||
|
func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||||
|
rating = r.RequestCtx.FormValue("rating")
|
||||||
|
feedback = string(r.RequestCtx.FormValue("feedback"))
|
||||||
|
)
|
||||||
|
|
||||||
|
ratingI, err := strconv.Atoi(string(rating))
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `rating`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ratingI < 1 || ratingI > 5 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`rating` should be between 1 and 5", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `uuid`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope("CSAT response updated")
|
||||||
|
}
|
||||||
240
cmd/handlers.go
240
cmd/handlers.go
@@ -12,129 +12,158 @@ import (
|
|||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
|
||||||
|
)
|
||||||
|
|
||||||
// initHandlers initializes the HTTP routes and handlers for the application.
|
// initHandlers initializes the HTTP routes and handlers for the application.
|
||||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||||
// Authentication.
|
// Authentication.
|
||||||
g.POST("/api/login", handleLogin)
|
g.POST("/api/v1/login", handleLogin)
|
||||||
g.GET("/logout", handleLogout)
|
g.GET("/logout", handleLogout)
|
||||||
g.GET("/api/oidc/{id}/login", handleOIDCLogin)
|
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
|
||||||
g.GET("/api/oidc/finish", handleOIDCCallback)
|
g.GET("/api/v1/oidc/finish", handleOIDCCallback)
|
||||||
|
|
||||||
// Health check.
|
|
||||||
g.GET("/health", handleHealthCheck)
|
|
||||||
|
|
||||||
// Serve media files.
|
// Serve media files.
|
||||||
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
||||||
|
|
||||||
// Settings.
|
// Settings.
|
||||||
g.GET("/api/settings/general", handleGetGeneralSettings)
|
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
|
||||||
g.PUT("/api/settings/general", authPerm(handleUpdateGeneralSettings, "settings_general", "write"))
|
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
|
||||||
g.GET("/api/settings/notifications/email", authPerm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
||||||
g.PUT("/api/settings/notifications/email", authPerm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
|
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
|
||||||
|
|
||||||
// OpenID SSO.
|
// OpenID connect single sign-on.
|
||||||
g.GET("/api/oidc", handleGetAllOIDC)
|
g.GET("/api/v1/oidc", handleGetAllOIDC)
|
||||||
g.GET("/api/oidc/{id}", authPerm(handleGetOIDC, "oidc", "read"))
|
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
|
||||||
g.POST("/api/oidc", authPerm(handleCreateOIDC, "oidc", "write"))
|
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc", "write"))
|
||||||
g.PUT("/api/oidc/{id}", authPerm(handleUpdateOIDC, "oidc", "write"))
|
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
|
||||||
g.DELETE("/api/oidc/{id}", authPerm(handleDeleteOIDC, "oidc", "delete"))
|
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
|
||||||
|
|
||||||
// Conversation and message.
|
// All.
|
||||||
g.GET("/api/conversations/all", authPerm(handleGetAllConversations, "conversations", "read_all"))
|
g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
|
||||||
g.GET("/api/conversations/unassigned", authPerm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
// Not assigned to any user or team.
|
||||||
g.GET("/api/conversations/assigned", authPerm(handleGetAssignedConversations, "conversations", "read_assigned"))
|
g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
||||||
g.GET("/api/conversations/{uuid}", authPerm(handleGetConversation, "conversations", "read"))
|
// Assigned to logged in user.
|
||||||
g.GET("/api/conversations/{uuid}/participants", authPerm(handleGetConversationParticipants, "conversations", "read"))
|
g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
|
||||||
g.PUT("/api/conversations/{uuid}/assignee/user", authPerm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
// Unassigned conversations assigned to a team.
|
||||||
g.PUT("/api/conversations/{uuid}/assignee/team", authPerm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
|
g.GET("/api/v1/teams/{team_id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations", "read_assigned"))
|
||||||
g.PUT("/api/conversations/{uuid}/priority", authPerm(handleUpdateConversationPriority, "conversations", "update_priority"))
|
// Filtered by view.
|
||||||
g.PUT("/api/conversations/{uuid}/status", authPerm(handleUpdateConversationStatus, "conversations", "update_status"))
|
g.GET("/api/v1/views/{view_id}/conversations", perm(handleGetViewConversations, "conversations", "read"))
|
||||||
g.PUT("/api/conversations/{uuid}/last-seen", authPerm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
|
|
||||||
g.POST("/api/conversations/{uuid}/tags", authPerm(handleAddConversationTags, "conversations", "update_tags"))
|
g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
|
||||||
g.POST("/api/conversations/{cuuid}/messages", authPerm(handleSendMessage, "messages", "write"))
|
g.GET("/api/v1/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
|
||||||
g.GET("/api/conversations/{uuid}/messages", authPerm(handleGetMessages, "messages", "read"))
|
g.PUT("/api/v1/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
||||||
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authPerm(handleRetryMessage, "messages", "write"))
|
g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
|
||||||
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authPerm(handleGetMessage, "messages", "read"))
|
g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations", "update_priority"))
|
||||||
|
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations", "update_status"))
|
||||||
|
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
|
||||||
|
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations", "update_tags"))
|
||||||
|
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
|
||||||
|
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
|
||||||
|
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
|
||||||
|
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
|
||||||
|
|
||||||
|
// Views.
|
||||||
|
g.GET("/api/v1/views/me", auth(handleGetUserViews))
|
||||||
|
g.POST("/api/v1/views/me", auth(handleCreateUserView))
|
||||||
|
g.PUT("/api/v1/views/me/{id}", auth(handleUpdateUserView))
|
||||||
|
g.DELETE("/api/v1/views/me/{id}", auth(handleDeleteUserView))
|
||||||
|
|
||||||
// Status and priority.
|
// Status and priority.
|
||||||
g.GET("/api/statuses", auth(handleGetStatuses))
|
g.GET("/api/v1/statuses", auth(handleGetStatuses))
|
||||||
g.POST("/api/statuses", authPerm(handleCreateStatus, "status", "write"))
|
g.POST("/api/v1/statuses", perm(handleCreateStatus, "status", "write"))
|
||||||
g.PUT("/api/statuses/{id}", authPerm(handleUpdateStatus, "status", "write"))
|
g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
|
||||||
g.DELETE("/api/statuses/{id}", authPerm(handleDeleteStatus, "status", "delete"))
|
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
|
||||||
g.GET("/api/priorities", auth(handleGetPriorities))
|
g.GET("/api/v1/priorities", auth(handleGetPriorities))
|
||||||
|
|
||||||
// Tag.
|
// Tag.
|
||||||
g.GET("/api/tags", auth(handleGetTags))
|
g.GET("/api/v1/tags", auth(handleGetTags))
|
||||||
g.POST("/api/tags", authPerm(handleCreateTag, "tags", "write"))
|
g.POST("/api/v1/tags", perm(handleCreateTag, "tags", "write"))
|
||||||
g.PUT("/api/tags/{id}", authPerm(handleUpdateTag, "tags", "write"))
|
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags", "write"))
|
||||||
g.DELETE("/api/tags/{id}", authPerm(handleDeleteTag, "tags", "delete"))
|
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
|
||||||
|
|
||||||
// Media.
|
// Media.
|
||||||
g.POST("/api/media", auth(handleMediaUpload))
|
g.POST("/api/v1/media", auth(handleMediaUpload))
|
||||||
|
|
||||||
// Canned response.
|
// Canned response.
|
||||||
g.GET("/api/canned-responses", auth(handleGetCannedResponses))
|
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
|
||||||
g.POST("/api/canned-responses", authPerm(handleCreateCannedResponse, "canned_responses", "write"))
|
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
|
||||||
g.PUT("/api/canned-responses/{id}", authPerm(handleUpdateCannedResponse, "canned_responses", "write"))
|
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
|
||||||
g.DELETE("/api/canned-responses/{id}", authPerm(handleDeleteCannedResponse, "canned_responses", "delete"))
|
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
|
||||||
|
|
||||||
// User.
|
// User.
|
||||||
g.GET("/api/users/me", auth(handleGetCurrentUser))
|
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||||
g.PUT("/api/users/me", auth(handleUpdateCurrentUser))
|
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||||
g.DELETE("/api/users/me/avatar", auth(handleDeleteAvatar))
|
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
||||||
g.GET("/api/users/compact", auth(handleGetUsersCompact))
|
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||||
g.GET("/api/users", authPerm(handleGetUsers, "users", "read"))
|
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||||
g.GET("/api/users/{id}", authPerm(handleGetUser, "users", "read"))
|
g.GET("/api/v1/users", perm(handleGetUsers, "users", "read"))
|
||||||
g.POST("/api/users", authPerm(handleCreateUser, "users", "write"))
|
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users", "read"))
|
||||||
g.PUT("/api/users/{id}", authPerm(handleUpdateUser, "users", "write"))
|
g.POST("/api/v1/users", perm(handleCreateUser, "users", "write"))
|
||||||
g.DELETE("/api/users/{id}", authPerm(handleDeleteUser, "users", "delete"))
|
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users", "write"))
|
||||||
g.POST("/api/users/reset-password", tryAuth(handleResetPassword))
|
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users", "delete"))
|
||||||
g.POST("/api/users/set-password", tryAuth(handleSetPassword))
|
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
|
||||||
|
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
|
||||||
|
|
||||||
// Team.
|
// Team.
|
||||||
g.GET("/api/teams/compact", auth(handleGetTeamsCompact))
|
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
|
||||||
g.GET("/api/teams", authPerm(handleGetTeams, "teams", "read"))
|
g.GET("/api/v1/teams", perm(handleGetTeams, "teams", "read"))
|
||||||
g.POST("/api/teams", authPerm(handleCreateTeam, "teams", "write"))
|
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams", "read"))
|
||||||
g.GET("/api/teams/{id}", authPerm(handleGetTeam, "teams", "read"))
|
g.POST("/api/v1/teams", perm(handleCreateTeam, "teams", "write"))
|
||||||
g.PUT("/api/teams/{id}", authPerm(handleUpdateTeam, "teams", "write"))
|
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
|
||||||
g.DELETE("/api/teams/{id}", authPerm(handleDeleteTeam, "teams", "delete"))
|
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams", "delete"))
|
||||||
|
|
||||||
// i18n.
|
// i18n.
|
||||||
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
|
||||||
|
|
||||||
// Automation.
|
// Automation.
|
||||||
g.GET("/api/automation/rules", authPerm(handleGetAutomationRules, "automations", "read"))
|
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
|
||||||
g.GET("/api/automation/rules/{id}", authPerm(handleGetAutomationRule, "automations", "read"))
|
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
|
||||||
g.POST("/api/automation/rules", authPerm(handleCreateAutomationRule, "automations", "write"))
|
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
|
||||||
g.PUT("/api/automation/rules/{id}/toggle", authPerm(handleToggleAutomationRule, "automations", "write"))
|
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
|
||||||
g.PUT("/api/automation/rules/{id}", authPerm(handleUpdateAutomationRule, "automations", "write"))
|
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
|
||||||
g.DELETE("/api/automation/rules/{id}", authPerm(handleDeleteAutomationRule, "automations", "delete"))
|
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
|
||||||
|
|
||||||
// Inbox.
|
// Inbox.
|
||||||
g.GET("/api/inboxes", authPerm(handleGetInboxes, "inboxes", "read"))
|
g.GET("/api/v1/inboxes", perm(handleGetInboxes, "inboxes", "read"))
|
||||||
g.GET("/api/inboxes/{id}", authPerm(handleGetInbox, "inboxes", "read"))
|
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
|
||||||
g.POST("/api/inboxes", authPerm(handleCreateInbox, "inboxes", "write"))
|
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes", "write"))
|
||||||
g.PUT("/api/inboxes/{id}/toggle", authPerm(handleToggleInbox, "inboxes", "write"))
|
g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
|
||||||
g.PUT("/api/inboxes/{id}", authPerm(handleUpdateInbox, "inboxes", "write"))
|
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
|
||||||
g.DELETE("/api/inboxes/{id}", authPerm(handleDeleteInbox, "inboxes", "delete"))
|
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
|
||||||
|
|
||||||
// Role.
|
// Role.
|
||||||
g.GET("/api/roles", authPerm(handleGetRoles, "roles", "read"))
|
g.GET("/api/v1/roles", perm(handleGetRoles, "roles", "read"))
|
||||||
g.GET("/api/roles/{id}", authPerm(handleGetRole, "roles", "read"))
|
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles", "read"))
|
||||||
g.POST("/api/roles", authPerm(handleCreateRole, "roles", "write"))
|
g.POST("/api/v1/roles", perm(handleCreateRole, "roles", "write"))
|
||||||
g.PUT("/api/roles/{id}", authPerm(handleUpdateRole, "roles", "write"))
|
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles", "write"))
|
||||||
g.DELETE("/api/roles/{id}", authPerm(handleDeleteRole, "roles", "delete"))
|
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
|
||||||
|
|
||||||
// Dashboard.
|
// Dashboard.
|
||||||
g.GET("/api/dashboard/global/counts", authPerm(handleDashboardCounts, "dashboard_global", "read"))
|
g.GET("/api/v1/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
|
||||||
g.GET("/api/dashboard/global/charts", authPerm(handleDashboardCharts, "dashboard_global", "read"))
|
g.GET("/api/v1/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
|
||||||
|
|
||||||
// Template.
|
// Template.
|
||||||
g.GET("/api/templates", authPerm(handleGetTemplates, "templates", "read"))
|
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates", "read"))
|
||||||
g.GET("/api/templates/{id}", authPerm(handleGetTemplate, "templates", "read"))
|
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates", "read"))
|
||||||
g.POST("/api/templates", authPerm(handleCreateTemplate, "templates", "write"))
|
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates", "write"))
|
||||||
g.PUT("/api/templates/{id}", authPerm(handleUpdateTemplate, "templates", "write"))
|
g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
|
||||||
g.DELETE("/api/templates/{id}", authPerm(handleDeleteTemplate, "templates", "delete"))
|
g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
|
||||||
|
|
||||||
|
// Business hours.
|
||||||
|
g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
|
||||||
|
g.GET("/api/v1/business-hours/{id}", auth(handleGetBusinessHour))
|
||||||
|
g.POST("/api/v1/business-hours", auth(handleCreateBusinessHours))
|
||||||
|
g.PUT("/api/v1/business-hours/{id}", auth(handleUpdateBusinessHours))
|
||||||
|
g.DELETE("/api/v1/business-hours/{id}", auth(handleDeleteBusinessHour))
|
||||||
|
|
||||||
|
// SLA.
|
||||||
|
g.GET("/api/v1/sla", auth(handleGetSLAs))
|
||||||
|
g.GET("/api/v1/sla/{id}", auth(handleGetSLA))
|
||||||
|
g.POST("/api/v1/sla", auth(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields)))
|
||||||
|
g.PUT("/api/v1/sla/{id}", auth(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields)))
|
||||||
|
g.DELETE("/api/v1/sla/{id}", auth(handleDeleteSLA))
|
||||||
|
|
||||||
// WebSocket.
|
// WebSocket.
|
||||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||||
@@ -150,8 +179,16 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
g.GET("/admin/{all:*}", authPage(serveIndexPage))
|
g.GET("/admin/{all:*}", authPage(serveIndexPage))
|
||||||
g.GET("/reset-password", notAuthPage(serveIndexPage))
|
g.GET("/reset-password", notAuthPage(serveIndexPage))
|
||||||
g.GET("/set-password", notAuthPage(serveIndexPage))
|
g.GET("/set-password", notAuthPage(serveIndexPage))
|
||||||
g.GET("/assets/{all:*}", serveStaticFiles)
|
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
|
||||||
g.GET("/images/{all:*}", serveStaticFiles)
|
g.GET("/images/{all:*}", serveFrontendStaticFiles)
|
||||||
|
g.GET("/static/public/{all:*}", serveStaticFiles)
|
||||||
|
|
||||||
|
// Public pages.
|
||||||
|
g.GET("/csat/{uuid}", handleShowCSAT)
|
||||||
|
g.POST("/csat/{uuid}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}}))
|
||||||
|
|
||||||
|
// Health check.
|
||||||
|
g.GET("/health", handleHealthCheck)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveIndexPage serves the main index page of the application.
|
// serveIndexPage serves the main index page of the application.
|
||||||
@@ -186,6 +223,29 @@ func serveStaticFiles(r *fastglue.Request) error {
|
|||||||
// Get the requested file path.
|
// Get the requested file path.
|
||||||
filePath := string(r.RequestCtx.Path())
|
filePath := string(r.RequestCtx.Path())
|
||||||
|
|
||||||
|
file, err := app.fs.Get(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the appropriate Content-Type based on the file extension.
|
||||||
|
ext := filepath.Ext(filePath)
|
||||||
|
contentType := mime.TypeByExtension(ext)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = http.DetectContentType(file.ReadBytes())
|
||||||
|
}
|
||||||
|
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
|
||||||
|
r.RequestCtx.SetBody(file.ReadBytes())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveFrontendStaticFiles serves static assets from the embedded filesystem.
|
||||||
|
func serveFrontendStaticFiles(r *fastglue.Request) error {
|
||||||
|
app := r.Context.(*App)
|
||||||
|
|
||||||
|
// Get the requested file path.
|
||||||
|
filePath := string(r.RequestCtx.Path())
|
||||||
|
|
||||||
// Fetch and serve the file from the embedded filesystem.
|
// Fetch and serve the file from the embedded filesystem.
|
||||||
finalPath := filepath.Join(frontendDir, filePath)
|
finalPath := filepath.Join(frontendDir, filePath)
|
||||||
file, err := app.fs.Get(finalPath)
|
file, err := app.fs.Get(finalPath)
|
||||||
|
|||||||
89
cmd/init.go
89
cmd/init.go
@@ -16,11 +16,12 @@ import (
|
|||||||
"github.com/abhinavxd/artemis/internal/authz"
|
"github.com/abhinavxd/artemis/internal/authz"
|
||||||
"github.com/abhinavxd/artemis/internal/autoassigner"
|
"github.com/abhinavxd/artemis/internal/autoassigner"
|
||||||
"github.com/abhinavxd/artemis/internal/automation"
|
"github.com/abhinavxd/artemis/internal/automation"
|
||||||
|
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
|
||||||
"github.com/abhinavxd/artemis/internal/cannedresp"
|
"github.com/abhinavxd/artemis/internal/cannedresp"
|
||||||
"github.com/abhinavxd/artemis/internal/contact"
|
|
||||||
"github.com/abhinavxd/artemis/internal/conversation"
|
"github.com/abhinavxd/artemis/internal/conversation"
|
||||||
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
||||||
"github.com/abhinavxd/artemis/internal/conversation/status"
|
"github.com/abhinavxd/artemis/internal/conversation/status"
|
||||||
|
"github.com/abhinavxd/artemis/internal/csat"
|
||||||
"github.com/abhinavxd/artemis/internal/inbox"
|
"github.com/abhinavxd/artemis/internal/inbox"
|
||||||
"github.com/abhinavxd/artemis/internal/inbox/channel/email"
|
"github.com/abhinavxd/artemis/internal/inbox/channel/email"
|
||||||
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
|
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
|
||||||
@@ -32,10 +33,13 @@ import (
|
|||||||
"github.com/abhinavxd/artemis/internal/oidc"
|
"github.com/abhinavxd/artemis/internal/oidc"
|
||||||
"github.com/abhinavxd/artemis/internal/role"
|
"github.com/abhinavxd/artemis/internal/role"
|
||||||
"github.com/abhinavxd/artemis/internal/setting"
|
"github.com/abhinavxd/artemis/internal/setting"
|
||||||
|
"github.com/abhinavxd/artemis/internal/sla"
|
||||||
"github.com/abhinavxd/artemis/internal/tag"
|
"github.com/abhinavxd/artemis/internal/tag"
|
||||||
"github.com/abhinavxd/artemis/internal/team"
|
"github.com/abhinavxd/artemis/internal/team"
|
||||||
tmpl "github.com/abhinavxd/artemis/internal/template"
|
tmpl "github.com/abhinavxd/artemis/internal/template"
|
||||||
"github.com/abhinavxd/artemis/internal/user"
|
"github.com/abhinavxd/artemis/internal/user"
|
||||||
|
"github.com/abhinavxd/artemis/internal/view"
|
||||||
|
"github.com/abhinavxd/artemis/internal/workerpool"
|
||||||
"github.com/abhinavxd/artemis/internal/ws"
|
"github.com/abhinavxd/artemis/internal/ws"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/knadh/go-i18n"
|
"github.com/knadh/go-i18n"
|
||||||
@@ -189,9 +193,19 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initConversations inits conversation manager.
|
// initConversations inits conversation manager.
|
||||||
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sqlx.DB, contactStore *contact.Manager,
|
func initConversations(
|
||||||
inboxStore *inbox.Manager, userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, automationEngine *automation.Engine, template *tmpl.Manager) *conversation.Manager {
|
i18n *i18n.I18n,
|
||||||
c, err := conversation.New(hub, i18n, n, contactStore, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
hub *ws.Hub,
|
||||||
|
n *notifier.Service,
|
||||||
|
db *sqlx.DB,
|
||||||
|
inboxStore *inbox.Manager,
|
||||||
|
userStore *user.Manager,
|
||||||
|
teamStore *team.Manager,
|
||||||
|
mediaStore *media.Manager,
|
||||||
|
automationEngine *automation.Engine,
|
||||||
|
template *tmpl.Manager,
|
||||||
|
) *conversation.Manager {
|
||||||
|
c, err := conversation.New(hub, i18n, n, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||||
DB: db,
|
DB: db,
|
||||||
Lo: initLogger("conversation_manager"),
|
Lo: initLogger("conversation_manager"),
|
||||||
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
||||||
@@ -203,8 +217,8 @@ func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sq
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTags inits tag manager.
|
// initTag inits tag manager.
|
||||||
func initTags(db *sqlx.DB) *tag.Manager {
|
func initTag(db *sqlx.DB) *tag.Manager {
|
||||||
var lo = initLogger("tag_manager")
|
var lo = initLogger("tag_manager")
|
||||||
mgr, err := tag.New(tag.Opts{
|
mgr, err := tag.New(tag.Opts{
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -216,6 +230,19 @@ func initTags(db *sqlx.DB) *tag.Manager {
|
|||||||
return mgr
|
return mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initViews inits view manager.
|
||||||
|
func initView(db *sqlx.DB) *view.Manager {
|
||||||
|
var lo = initLogger("view_manager")
|
||||||
|
m, err := view.New(view.Opts{
|
||||||
|
DB: db,
|
||||||
|
Lo: lo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing view manager: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// initCannedResponse inits canned response manager.
|
// initCannedResponse inits canned response manager.
|
||||||
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
||||||
var lo = initLogger("canned-response")
|
var lo = initLogger("canned-response")
|
||||||
@@ -229,26 +256,62 @@ func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func initContact(db *sqlx.DB) *contact.Manager {
|
// initBusinessHours inits business hours manager.
|
||||||
var lo = initLogger("contact-manager")
|
func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
|
||||||
m, err := contact.New(contact.Opts{
|
var lo = initLogger("business-hours")
|
||||||
|
m, err := businesshours.New(businesshours.Opts{
|
||||||
DB: db,
|
DB: db,
|
||||||
Lo: lo,
|
Lo: lo,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error initializing contact manager: %v", err)
|
log.Fatalf("error initializing business hours manager: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSLA inits SLA manager.
|
||||||
|
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
|
||||||
|
var lo = initLogger("sla")
|
||||||
|
m, err := sla.New(sla.Opts{
|
||||||
|
DB: db,
|
||||||
|
Lo: lo,
|
||||||
|
ScannerInterval: ko.MustDuration("sla.scanner_interval"),
|
||||||
|
}, workerpool.New(ko.MustInt("sla.worker_count"), ko.MustInt("sla.queue_size")), teamManager, settings, businessHours)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing SLA manager: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// initCSAT inits CSAT manager.
|
||||||
|
func initCSAT(db *sqlx.DB) *csat.Manager {
|
||||||
|
var lo = initLogger("csat")
|
||||||
|
m, err := csat.New(csat.Opts{
|
||||||
|
DB: db,
|
||||||
|
Lo: lo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing CSAT manager: %v", err)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTemplates inits template manager.
|
// initTemplates inits template manager.
|
||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
|
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
|
||||||
lo := initLogger("template")
|
var (
|
||||||
tpls, err := stuffbin.ParseTemplatesGlob(getTmplFuncs(consts), fs, "/static/email-templates/*.html")
|
lo = initLogger("template")
|
||||||
|
funcMap = getTmplFuncs(consts)
|
||||||
|
)
|
||||||
|
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error parsing e-mail templates: %v", err)
|
log.Fatalf("error parsing e-mail templates: %v", err)
|
||||||
}
|
}
|
||||||
m, err := tmpl.New(lo, db, tpls)
|
|
||||||
|
webTpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/public/web-templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error parsing web templates: %v", err)
|
||||||
|
}
|
||||||
|
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error initializing template manager: %v", err)
|
log.Fatalf("error initializing template manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,13 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
|
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
|
||||||
func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
|
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||||
installed, err := checkSchema(db)
|
installed, err := checkSchema(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error checking db schema: %v", err)
|
log.Fatalf("error checking db schema: %v", err)
|
||||||
}
|
}
|
||||||
if installed {
|
if installed {
|
||||||
fmt.Printf("\033[31m** WARNING: This will wipe your entire DB - '%s' **\033[0m\n", ko.String("db.database"))
|
fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
|
||||||
fmt.Print("Continue (y/n)? ")
|
fmt.Print("Continue (y/n)? ")
|
||||||
var ok string
|
var ok string
|
||||||
fmt.Scanf("%s", &ok)
|
fmt.Scanf("%s", &ok)
|
||||||
@@ -35,15 +36,15 @@ func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
|
|||||||
log.Println("Schema installed successfully")
|
log.Println("Schema installed successfully")
|
||||||
|
|
||||||
// Create system user.
|
// Create system user.
|
||||||
if err := user.CreateSystemUser(db); err != nil {
|
if err := user.CreateSystemUser(ctx, db); err != nil {
|
||||||
log.Fatalf("error creating system user: %v", err)
|
log.Fatalf("error creating system user: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSystemUserPass prompts for pass and sets system user password.
|
// setSystemUserPass prompts for pass and sets system user password.
|
||||||
func setSystemUserPass(db *sqlx.DB) {
|
func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
|
||||||
user.ChangeSystemUserPassword(db)
|
user.ChangeSystemUserPassword(ctx, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSchema verifies if the DB schema is already installed by querying a table.
|
// checkSchema verifies if the DB schema is already installed by querying a table.
|
||||||
|
|||||||
12
cmd/login.go
12
cmd/login.go
@@ -1,12 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleLogin logs in the user.
|
// handleLogin logs a user in.
|
||||||
func handleLogin(r *fastglue.Request) error {
|
func handleLogin(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -14,11 +15,16 @@ func handleLogin(r *fastglue.Request) error {
|
|||||||
email = string(p.Peek("email"))
|
email = string(p.Peek("email"))
|
||||||
password = p.Peek("password")
|
password = p.Peek("password")
|
||||||
)
|
)
|
||||||
user, err := app.user.Login(email, password)
|
user, err := app.user.VerifyPassword(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
if err := app.auth.SaveSession(user, r); err != nil {
|
if err := app.auth.SaveSession(amodels.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email.String,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
}, r); err != nil {
|
||||||
app.lo.Error("error saving session", "error", err)
|
app.lo.Error("error saving session", "error", err)
|
||||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
|
||||||
}
|
}
|
||||||
|
|||||||
140
cmd/main.go
140
cmd/main.go
@@ -10,11 +10,15 @@ import (
|
|||||||
|
|
||||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||||
"github.com/abhinavxd/artemis/internal/authz"
|
"github.com/abhinavxd/artemis/internal/authz"
|
||||||
|
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
|
||||||
|
"github.com/abhinavxd/artemis/internal/colorlog"
|
||||||
|
"github.com/abhinavxd/artemis/internal/csat"
|
||||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||||
|
"github.com/abhinavxd/artemis/internal/sla"
|
||||||
|
"github.com/abhinavxd/artemis/internal/view"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/automation"
|
"github.com/abhinavxd/artemis/internal/automation"
|
||||||
"github.com/abhinavxd/artemis/internal/cannedresp"
|
"github.com/abhinavxd/artemis/internal/cannedresp"
|
||||||
"github.com/abhinavxd/artemis/internal/contact"
|
|
||||||
"github.com/abhinavxd/artemis/internal/conversation"
|
"github.com/abhinavxd/artemis/internal/conversation"
|
||||||
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
||||||
"github.com/abhinavxd/artemis/internal/conversation/status"
|
"github.com/abhinavxd/artemis/internal/conversation/status"
|
||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|||||||
13
cmd/media.go
13
cmd/media.go
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
@@ -27,7 +28,12 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set user in context if found.
|
// Set user in context if found.
|
||||||
r.RequestCtx.SetUserValue("user", user)
|
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email.String,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
})
|
||||||
|
|
||||||
return handler(r)
|
return handler(r)
|
||||||
}
|
}
|
||||||
@@ -52,25 +58,30 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
r.RequestCtx.SetUserValue("user", user)
|
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email.String,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
})
|
||||||
|
|
||||||
return handler(r)
|
return handler(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// authPerm does session validation, CSRF, and permission enforcement.
|
// perm does session validation, CSRF, and permission enforcement.
|
||||||
func authPerm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
|
func perm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
|
||||||
return func(r *fastglue.Request) error {
|
return func(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
// cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||||
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
// hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||||
)
|
)
|
||||||
|
|
||||||
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
// if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||||
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
// app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
||||||
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
// return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Validate session and fetch user.
|
// Validate session and fetch user.
|
||||||
userSession, err := app.auth.ValidateSession(r)
|
userSession, err := app.auth.ValidateSession(r)
|
||||||
@@ -96,7 +107,12 @@ func authPerm(handler fastglue.FastRequestHandler, object, action string) fastgl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set user in the request context.
|
// Set user in the request context.
|
||||||
r.RequestCtx.SetUserValue("user", user)
|
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email.String,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
})
|
||||||
|
|
||||||
return handler(r)
|
return handler(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetGeneralSettings fetches general settings.
|
||||||
func handleGetGeneralSettings(r *fastglue.Request) error {
|
func handleGetGeneralSettings(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -22,6 +23,7 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(out)
|
return r.SendEnvelope(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateGeneralSettings updates general settings.
|
||||||
func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -35,9 +37,10 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
|||||||
if err := app.setting.Update(req); err != nil {
|
if err := app.setting.Update(req); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Settings updated successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetEmailNotificationSettings fetches email notification settings.
|
||||||
func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -59,6 +62,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(notif)
|
return r.SendEnvelope(notif)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateEmailNotificationSettings updates email notification settings.
|
||||||
func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -86,5 +90,5 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
|||||||
if err := app.setting.Update(req); err != nil {
|
if err := app.setting.Update(req); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Settings updated successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
106
cmd/sla.go
Normal file
106
cmd/sla.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleGetSLAs(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
slas, err := app.sla.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(slas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetSLA(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
sla, err := app.sla.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(sla)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateSLA(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||||
|
desc = string(r.RequestCtx.PostArgs().Peek("description"))
|
||||||
|
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
|
||||||
|
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate time duration strings
|
||||||
|
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
if _, err := time.ParseDuration(resTime); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteSLA(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.sla.Delete(id); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateSLA(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||||
|
desc = string(r.RequestCtx.PostArgs().Peek("description"))
|
||||||
|
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
|
||||||
|
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate time duration strings
|
||||||
|
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
if _, err := time.ParseDuration(resTime); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
49
cmd/teams.go
49
cmd/teams.go
@@ -1,15 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
"github.com/abhinavxd/artemis/internal/team/models"
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetTeams returns a list of all teams.
|
||||||
func handleGetTeams(r *fastglue.Request) error {
|
func handleGetTeams(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -21,6 +20,7 @@ func handleGetTeams(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(teams)
|
return r.SendEnvelope(teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetTeamsCompact returns a list of all teams in a compact format.
|
||||||
func handleGetTeamsCompact(r *fastglue.Request) error {
|
func handleGetTeamsCompact(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -32,6 +32,7 @@ func handleGetTeamsCompact(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(teams)
|
return r.SendEnvelope(teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetTeam returns a single team.
|
||||||
func handleGetTeam(r *fastglue.Request) error {
|
func handleGetTeam(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -41,35 +42,38 @@ func handleGetTeam(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid team `id`.", nil, envelope.InputError)
|
"Invalid team `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
team, err := app.team.GetTeam(id)
|
team, err := app.team.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(team)
|
return r.SendEnvelope(team)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateTeam creates a new team.
|
||||||
func handleCreateTeam(r *fastglue.Request) error {
|
func handleCreateTeam(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
req = models.Team{}
|
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||||
|
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
|
||||||
|
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||||
)
|
)
|
||||||
|
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||||
if _, err := fastglue.ScanArgs(r.RequestCtx.PostArgs(), &req, `json`); err != nil {
|
if err != nil || businessHrsID == 0 {
|
||||||
app.lo.Error("error scanning args", "error", err)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
|
||||||
return envelope.NewError(envelope.InputError,
|
|
||||||
fmt.Sprintf("Invalid request (%s)", err.Error()), nil)
|
|
||||||
}
|
}
|
||||||
err := app.team.CreateTeam(req)
|
if err := app.team.Create(name, timezone, conversationAssignmentType, businessHrsID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Team created successfully.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateTeam updates an existing team.
|
||||||
func handleUpdateTeam(r *fastglue.Request) error {
|
func handleUpdateTeam(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
req = models.Team{}
|
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||||
|
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
|
||||||
|
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||||
)
|
)
|
||||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -77,11 +81,12 @@ func handleUpdateTeam(r *fastglue.Request) error {
|
|||||||
"Invalid team `id`.", nil, envelope.InputError)
|
"Invalid team `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.Decode(&req, "json"); err != nil {
|
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||||
return envelope.NewError(envelope.InputError, "Bad request", nil)
|
if err != nil || businessHrsID == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
err = app.team.UpdateTeam(id, req)
|
|
||||||
if err != nil {
|
if err = app.team.Update(id, name, timezone, conversationAssignmentType, businessHrsID); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope(true)
|
||||||
@@ -97,9 +102,9 @@ func handleDeleteTeam(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid team `id`.", nil, envelope.InputError)
|
"Invalid team `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
err = app.team.DeleteTeam(id)
|
err = app.team.Delete(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Team deleted successfully.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ import (
|
|||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetTemplates returns all templates.
|
||||||
func handleGetTemplates(r *fastglue.Request) error {
|
func handleGetTemplates(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
|
typ = string(r.RequestCtx.QueryArgs().Peek("type"))
|
||||||
)
|
)
|
||||||
t, err := app.tmpl.GetAll()
|
if typ == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `type`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
t, err := app.tmpl.GetAll(typ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(t)
|
return r.SendEnvelope(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetTemplate returns a template by id.
|
||||||
func handleGetTemplate(r *fastglue.Request) error {
|
func handleGetTemplate(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -36,6 +42,7 @@ func handleGetTemplate(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(t)
|
return r.SendEnvelope(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateTemplate creates a new template.
|
||||||
func handleCreateTemplate(r *fastglue.Request) error {
|
func handleCreateTemplate(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -44,14 +51,13 @@ func handleCreateTemplate(r *fastglue.Request) error {
|
|||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
|
if err := app.tmpl.Create(req); err != nil {
|
||||||
err := app.tmpl.Create(req)
|
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateTemplate updates a template.
|
||||||
func handleUpdateTemplate(r *fastglue.Request) error {
|
func handleUpdateTemplate(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -62,17 +68,16 @@ func handleUpdateTemplate(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid template `id`.", nil, envelope.InputError)
|
"Invalid template `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = app.tmpl.Update(id, req); err != nil {
|
if err = app.tmpl.Update(id, req); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteTemplate deletes a template.
|
||||||
func handleDeleteTemplate(r *fastglue.Request) error {
|
func handleDeleteTemplate(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -83,11 +88,9 @@ func handleDeleteTemplate(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid template `id`.", nil, envelope.InputError)
|
"Invalid template `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = app.tmpl.Delete(id); err != nil {
|
if err = app.tmpl.Delete(id); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|||||||
69
cmd/users.go
69
cmd/users.go
@@ -8,13 +8,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
"github.com/abhinavxd/artemis/internal/image"
|
"github.com/abhinavxd/artemis/internal/image"
|
||||||
mmodels "github.com/abhinavxd/artemis/internal/media/models"
|
mmodels "github.com/abhinavxd/artemis/internal/media/models"
|
||||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||||
tmpl "github.com/abhinavxd/artemis/internal/template"
|
tmpl "github.com/abhinavxd/artemis/internal/template"
|
||||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
"github.com/abhinavxd/artemis/internal/user/models"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ const (
|
|||||||
maxAvatarSizeMB = 5
|
maxAvatarSizeMB = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetUsers returns all users.
|
||||||
func handleGetUsers(r *fastglue.Request) error {
|
func handleGetUsers(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -61,11 +63,33 @@ func handleGetUser(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(user)
|
return r.SendEnvelope(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetCurrentUserTeams returns the teams of a user.
|
||||||
|
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
)
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := app.team.GetUserTeams(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(teams)
|
||||||
|
}
|
||||||
|
|
||||||
func handleUpdateCurrentUser(r *fastglue.Request) error {
|
func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
)
|
)
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get current user.
|
// Get current user.
|
||||||
currentUser, err := app.user.Get(user.ID)
|
currentUser, err := app.user.Get(user.ID)
|
||||||
@@ -144,17 +168,17 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
|
|||||||
func handleCreateUser(r *fastglue.Request) error {
|
func handleCreateUser(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = umodels.User{}
|
user = models.User{}
|
||||||
)
|
)
|
||||||
if err := r.Decode(&user, "json"); err != nil {
|
if err := r.Decode(&user, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Email == "" {
|
if user.Email.String == "" {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := app.user.Create(&user)
|
err := app.user.CreateAgent(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +196,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render template and send email.
|
// Render template and send email.
|
||||||
content, err := app.tmpl.Render(tmpl.TmplWelcome, map[string]interface{}{
|
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
|
||||||
"ResetToken": resetToken,
|
"ResetToken": resetToken,
|
||||||
"Email": user.Email,
|
"Email": user.Email,
|
||||||
})
|
})
|
||||||
@@ -198,7 +222,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
|||||||
func handleUpdateUser(r *fastglue.Request) error {
|
func handleUpdateUser(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = umodels.User{}
|
user = models.User{}
|
||||||
)
|
)
|
||||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -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
139
cmd/views.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
vmodels "github.com/abhinavxd/artemis/internal/view/models"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetUserViews returns all views for a user.
|
||||||
|
func handleGetUserViews(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
)
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
v, err := app.view.GetUsersViews(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateUserView creates a view for a user.
|
||||||
|
func handleCreateUserView(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
view = vmodels.View{}
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
)
|
||||||
|
if err := r.Decode(&view, "json"); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
|
}
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
if view.Name == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(view.Filters) == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.view.Create(view.Name, view.Filters, view.InboxType, user.ID); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope("View created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetUserView deletes a view for a user.
|
||||||
|
func handleDeleteUserView(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
|
"Invalid view `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id <= 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := app.view.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.UserID != user.ID {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.view.Delete(id); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope("View deleted successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateUserView updates a view for a user.
|
||||||
|
func handleUpdateUserView(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
view = vmodels.View{}
|
||||||
|
)
|
||||||
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
|
"Invalid view `id`.", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Decode(&view, "json"); err != nil {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.Name == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(view.Filters) == "" {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := app.view.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.UserID != user.ID {
|
||||||
|
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.view.Update(id, view.Name, view.Filters, view.InboxType); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SendEnvelope(true)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||||
"github.com/abhinavxd/artemis/internal/ws"
|
"github.com/abhinavxd/artemis/internal/ws"
|
||||||
wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
|
wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
|
||||||
"github.com/fasthttp/websocket"
|
"github.com/fasthttp/websocket"
|
||||||
@@ -26,10 +26,14 @@ var upgrader = websocket.FastHTTPUpgrader{
|
|||||||
|
|
||||||
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
||||||
var (
|
var (
|
||||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
)
|
)
|
||||||
err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
|
user, err := app.user.Get(auser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
err = upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
|
||||||
c := ws.Client{
|
c := ws.Client{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"@vue/reactivity": "^3.4.15",
|
"@vue/reactivity": "^3.4.15",
|
||||||
"@vue/runtime-core": "^3.4.15",
|
"@vue/runtime-core": "^3.4.15",
|
||||||
"@vueup/vue-quill": "^1.2.0",
|
"@vueup/vue-quill": "^1.2.0",
|
||||||
"@vueuse/core": "^11.2.0",
|
"@vueuse/core": "^12.2.0",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"vue-letter": "^0.2.0",
|
"vue-letter": "^0.2.0",
|
||||||
"vue-picture-cropper": "^0.7.0",
|
"vue-picture-cropper": "^0.7.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
|
"vue-sonner": "^1.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<Toaster />
|
||||||
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
|
<Sidebar :isLoading="false" :open="sidebarOpen" :userTeams="userStore.teams" :userViews="userViews" @update:open="sidebarOpen = $event"
|
||||||
class="shadow shadow-gray-300 h-screen" />
|
@create-view="openCreateViewForm = true" @edit-view="editView" @delete-view="deleteView">
|
||||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||||
<ResizableHandle id="resize-handle-1" />
|
<ResizableHandle id="resize-handle-1" />
|
||||||
<ResizablePanel id="resize-panel-2">
|
<ResizablePanel id="resize-panel-2">
|
||||||
@@ -9,75 +9,94 @@
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</Sidebar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { initWS } from '@/websocket.js'
|
import { initWS } from '@/websocket.js'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import NavBar from '@/components/NavBar.vue'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
import { useConversationStore } from './stores/conversation'
|
||||||
|
import ViewForm from '@/components/ViewForm.vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const isCollapsed = ref(true)
|
const sidebarOpen = ref(true)
|
||||||
const allNavLinks = [
|
|
||||||
{
|
|
||||||
title: t('navbar.dashboard'),
|
|
||||||
to: '/dashboard',
|
|
||||||
label: '',
|
|
||||||
icon: 'lucide:layout-dashboard',
|
|
||||||
permission: 'dashboard_global:read',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('navbar.conversations'),
|
|
||||||
to: '/conversations',
|
|
||||||
label: '',
|
|
||||||
icon: 'lucide:message-circle-more'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('navbar.account'),
|
|
||||||
to: '/account/profile',
|
|
||||||
label: '',
|
|
||||||
icon: 'lucide:circle-user-round'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('navbar.admin'),
|
|
||||||
to: '/admin/general',
|
|
||||||
label: '',
|
|
||||||
icon: 'lucide:settings',
|
|
||||||
permission: 'admin:read'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const bottomLinks = [
|
|
||||||
{
|
|
||||||
to: '/logout',
|
|
||||||
icon: 'lucide:log-out',
|
|
||||||
title: 'Logout'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const conversationStore = useConversationStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
initWS()
|
const userViews = ref([])
|
||||||
|
const view = ref({})
|
||||||
|
const openCreateViewForm = ref(false)
|
||||||
|
|
||||||
|
initWS()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initToaster()
|
initToaster()
|
||||||
|
listenViewRefresh()
|
||||||
getCurrentUser()
|
getCurrentUser()
|
||||||
|
getUserViews()
|
||||||
|
intiStores()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
|
emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||||
|
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const intiStores = () => {
|
||||||
|
Promise.all([
|
||||||
|
conversationStore.fetchStatuses(),
|
||||||
|
conversationStore.fetchPriorities()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const editView = (v) => {
|
||||||
|
view.value = { ...v }
|
||||||
|
openCreateViewForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteView = async (view) => {
|
||||||
|
try {
|
||||||
|
await api.deleteView(view.id)
|
||||||
|
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
variant: 'success',
|
||||||
|
description: 'View deleted successfully'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(err).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserViews = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getCurrentUserViews()
|
||||||
|
userViews.value = response.data.data
|
||||||
|
} catch (err) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(err).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getCurrentUser = () => {
|
const getCurrentUser = () => {
|
||||||
userStore.getCurrentUser().catch((err) => {
|
userStore.getCurrentUser().catch((err) => {
|
||||||
if (err.response && err.response.status === 401) {
|
if (err.response && err.response.status === 401) {
|
||||||
@@ -90,9 +109,14 @@ const initToaster = () => {
|
|||||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
|
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = computed(() =>
|
const listenViewRefresh = () => {
|
||||||
allNavLinks.filter((link) =>
|
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||||
!link.permission || (userStore.permissions.includes(link.permission) && link.permission)
|
}
|
||||||
)
|
|
||||||
)
|
const refreshViews = (data) => {
|
||||||
|
openCreateViewForm.value = false
|
||||||
|
if (data?.model === 'view') {
|
||||||
|
getUserViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TooltipProvider :delay-duration="250">
|
<TooltipProvider :delay-duration="200">
|
||||||
<div class="font-inter">
|
<div class="font-inter">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,177 +33,214 @@ http.interceptors.request.use((request) => {
|
|||||||
return request
|
return request
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetPassword = (data) => http.post('/api/users/reset-password', data)
|
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
|
||||||
const setPassword = (data) => http.post('/api/users/set-password', data)
|
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
|
||||||
const deleteUser = (id) => http.delete(`/api/users/${id}`)
|
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
|
||||||
const getEmailNotificationSettings = () => http.get('/api/settings/notifications/email')
|
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
|
||||||
const updateEmailNotificationSettings = (data) => http.put('/api/settings/notifications/email', data)
|
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
|
||||||
const getPriorities = () => http.get('/api/priorities')
|
const getPriorities = () => http.get('/api/v1/priorities')
|
||||||
const getStatuses = () => http.get('/api/statuses')
|
const getStatuses = () => http.get('/api/v1/statuses')
|
||||||
const createStatus = (data) => http.post('/api/statuses', data)
|
const createStatus = (data) => http.post('/api/v1/statuses', data)
|
||||||
const updateStatus = (id, data) => http.put(`/api/statuses/${id}`, data)
|
const updateStatus = (id, data) => http.put(`/api/v1/statuses/${id}`, data)
|
||||||
const deleteStatus = (id) => http.delete(`/api/statuses/${id}`)
|
const deleteStatus = (id) => http.delete(`/api/v1/statuses/${id}`)
|
||||||
const createTag = (data) => http.post('/api/tags', data)
|
const createTag = (data) => http.post('/api/v1/tags', data)
|
||||||
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
|
const updateTag = (id, data) => http.put(`/api/v1/tags/${id}`, data)
|
||||||
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
|
const deleteTag = (id) => http.delete(`/api/v1/tags/${id}`)
|
||||||
const getTemplate = (id) => http.get(`/api/templates/${id}`)
|
const getTemplate = (id) => http.get(`/api/v1/templates/${id}`)
|
||||||
const getTemplates = () => http.get('/api/templates')
|
const getTemplates = (type) => http.get('/api/v1/templates', { params: { type: type } })
|
||||||
const createTemplate = (data) =>
|
const createTemplate = (data) =>
|
||||||
http.post('/api/templates', data, {
|
http.post('/api/v1/templates', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const deleteTemplate = (id) => http.delete(`/api/templates/${id}`)
|
const deleteTemplate = (id) => http.delete(`/api/v1/templates/${id}`)
|
||||||
const updateTemplate = (id, data) =>
|
const updateTemplate = (id, data) =>
|
||||||
http.put(`/api/templates/${id}`, data, {
|
http.put(`/api/v1/templates/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getAllBusinessHours = () => http.get('/api/v1/business-hours')
|
||||||
|
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
|
||||||
|
const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const updateBusinessHours = (id, data) =>
|
||||||
|
http.put(`/api/v1/business-hours/${id}`, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
|
||||||
|
|
||||||
|
const getAllSLAs = () => http.get('/api/v1/sla')
|
||||||
|
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
|
||||||
|
const createSLA = (data) => http.post('/api/v1/sla', data)
|
||||||
|
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
|
||||||
|
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
|
||||||
|
|
||||||
const createOIDC = (data) =>
|
const createOIDC = (data) =>
|
||||||
http.post('/api/oidc', data, {
|
http.post('/api/v1/oidc', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getAllOIDC = () => http.get('/api/oidc')
|
const getAllOIDC = () => http.get('/api/v1/oidc')
|
||||||
const getOIDC = (id) => http.get(`/api/oidc/${id}`)
|
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
||||||
const updateOIDC = (id, data) =>
|
const updateOIDC = (id, data) =>
|
||||||
http.put(`/api/oidc/${id}`, data, {
|
http.put(`/api/v1/oidc/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const deleteOIDC = (id) => http.delete(`/api/oidc/${id}`)
|
const deleteOIDC = (id) => http.delete(`/api/v1/oidc/${id}`)
|
||||||
const updateSettings = (key, data) =>
|
const updateSettings = (key, data) =>
|
||||||
http.put(`/api/settings/${key}`, data, {
|
http.put(`/api/v1/settings/${key}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getSettings = (key) => http.get(`/api/settings/${key}`)
|
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
|
||||||
const login = (data) => http.post(`/api/login`, data)
|
const login = (data) => http.post(`/api/v1/login`, data)
|
||||||
const getAutomationRules = (type) =>
|
const getAutomationRules = (type) =>
|
||||||
http.get(`/api/automation/rules`, {
|
http.get(`/api/v1/automation/rules`, {
|
||||||
params: { type: type }
|
params: { type: type }
|
||||||
})
|
})
|
||||||
const toggleAutomationRule = (id) => http.put(`/api/automation/rules/${id}/toggle`)
|
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
|
||||||
const getAutomationRule = (id) => http.get(`/api/automation/rules/${id}`)
|
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
|
||||||
const updateAutomationRule = (id, data) =>
|
const updateAutomationRule = (id, data) =>
|
||||||
http.put(`/api/automation/rules/${id}`, data, {
|
http.put(`/api/v1/automation/rules/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const createAutomationRule = (data) =>
|
const createAutomationRule = (data) =>
|
||||||
http.post(`/api/automation/rules`, data, {
|
http.post(`/api/v1/automation/rules`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getRoles = () => http.get('/api/roles')
|
const getRoles = () => http.get('/api/v1/roles')
|
||||||
const getRole = (id) => http.get(`/api/roles/${id}`)
|
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
|
||||||
const createRole = (data) =>
|
const createRole = (data) =>
|
||||||
http.post('/api/roles', data, {
|
http.post('/api/v1/roles', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const updateRole = (id, data) =>
|
const updateRole = (id, data) =>
|
||||||
http.put(`/api/roles/${id}`, data, {
|
http.put(`/api/v1/roles/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const deleteRole = (id) => http.delete(`/api/roles/${id}`)
|
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
|
||||||
const deleteAutomationRule = (id) => http.delete(`/api/automation/rules/${id}`)
|
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
|
||||||
const getUser = (id) => http.get(`/api/users/${id}`)
|
const getUser = (id) => http.get(`/api/v1/users/${id}`)
|
||||||
const getTeam = (id) => http.get(`/api/teams/${id}`)
|
|
||||||
const getTeams = () => http.get('/api/teams')
|
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
|
||||||
const getTeamsCompact = () => http.get('/api/teams/compact')
|
const getTeams = () => http.get('/api/v1/teams')
|
||||||
const getUsers = () => http.get('/api/users')
|
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
|
||||||
const getUsersCompact = () => http.get('/api/users/compact')
|
const createTeam = (data) => http.post('/api/v1/teams', data)
|
||||||
|
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
|
||||||
|
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
|
||||||
|
|
||||||
|
const getUsers = () => http.get('/api/v1/users')
|
||||||
|
const getUsersCompact = () => http.get('/api/v1/users/compact')
|
||||||
const updateCurrentUser = (data) =>
|
const updateCurrentUser = (data) =>
|
||||||
http.put('/api/users/me', data, {
|
http.put('/api/v1/users/me', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const deleteUserAvatar = () => http.delete('/api/users/me/avatar')
|
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||||
const getCurrentUser = () => http.get('/api/users/me')
|
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||||
const getTags = () => http.get('/api/tags')
|
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
||||||
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
|
const getTags = () => http.get('/api/v1/tags')
|
||||||
|
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
||||||
const updateAssignee = (uuid, assignee_type, data) =>
|
const updateAssignee = (uuid, assignee_type, data) =>
|
||||||
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
|
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||||
const updateConversationStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
|
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
|
||||||
const updateConversationPriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
|
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
|
||||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
|
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
|
||||||
const getConversationMessage = (cuuid, uuid) => http.get(`/api/conversations/${cuuid}/messages/${uuid}`)
|
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
|
||||||
const retryMessage = (cuuid, uuid) => http.put(`/api/conversations/${cuuid}/messages/${uuid}/retry`)
|
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
|
||||||
const getConversationMessages = (uuid, page) =>
|
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
|
||||||
http.get(`/api/conversations/${uuid}/messages`, {
|
|
||||||
params: { page: page }
|
|
||||||
})
|
|
||||||
const sendMessage = (uuid, data) =>
|
const sendMessage = (uuid, data) =>
|
||||||
http.post(`/api/conversations/${uuid}/messages`, data, {
|
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
|
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
|
||||||
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
|
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
|
||||||
const getCannedResponses = () => http.get('/api/canned-responses')
|
const getCannedResponses = () => http.get('/api/v1/canned-responses')
|
||||||
const createCannedResponse = (data) => http.post('/api/canned-responses', data)
|
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
|
||||||
const updateCannedResponse = (id, data) => http.put(`/api/canned-responses/${id}`, data)
|
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
|
||||||
const deleteCannedResponse = (id) => http.delete(`/api/canned-responses/${id}`)
|
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
|
||||||
const getAssignedConversations = (params) =>
|
const getTeamUnassignedConversations = (teamID, params) =>
|
||||||
http.get('/api/conversations/assigned', { params })
|
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
|
||||||
const getUnassignedConversations = (params) =>
|
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
|
||||||
http.get('/api/conversations/unassigned', { params })
|
const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
|
||||||
const getAllConversations = (params) =>
|
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
|
||||||
http.get('/api/conversations/all', { params })
|
const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
|
||||||
const uploadMedia = (data) =>
|
const uploadMedia = (data) =>
|
||||||
http.post('/api/media', data, {
|
http.post('/api/v1/media', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts')
|
const getGlobalDashboardCounts = () => http.get('/api/v1/dashboard/global/counts')
|
||||||
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts')
|
const getGlobalDashboardCharts = () => http.get('/api/v1/dashboard/global/charts')
|
||||||
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`)
|
const getUserDashboardCounts = () => http.get(`/api/v1/dashboard/me/counts`)
|
||||||
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`)
|
const getUserDashboardCharts = () => http.get(`/api/v1/dashboard/me/charts`)
|
||||||
const getLanguage = (lang) => http.get(`/api/lang/${lang}`)
|
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
|
||||||
const createUser = (data) =>
|
const createUser = (data) =>
|
||||||
http.post('/api/users', data, {
|
http.post('/api/v1/users', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const updateUser = (id, data) =>
|
const updateUser = (id, data) =>
|
||||||
http.put(`/api/users/${id}`, data, {
|
http.put(`/api/v1/users/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const updateTeam = (id, data) => http.put(`/api/teams/${id}`, data)
|
|
||||||
const createTeam = (data) => http.post('/api/teams', data)
|
|
||||||
const createInbox = (data) =>
|
const createInbox = (data) =>
|
||||||
http.post('/api/inboxes', data, {
|
http.post('/api/v1/inboxes', data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const getInboxes = () => http.get('/api/inboxes')
|
const getInboxes = () => http.get('/api/v1/inboxes')
|
||||||
const getInbox = (id) => http.get(`/api/inboxes/${id}`)
|
const getInbox = (id) => http.get(`/api/v1/inboxes/${id}`)
|
||||||
const toggleInbox = (id) => http.put(`/api/inboxes/${id}/toggle`)
|
const toggleInbox = (id) => http.put(`/api/v1/inboxes/${id}/toggle`)
|
||||||
const updateInbox = (id, data) =>
|
const updateInbox = (id, data) =>
|
||||||
http.put(`/api/inboxes/${id}`, data, {
|
http.put(`/api/v1/inboxes/${id}`, data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const deleteInbox = (id) => http.delete(`/api/inboxes/${id}`)
|
const deleteInbox = (id) => http.delete(`/api/v1/inboxes/${id}`)
|
||||||
|
const getCurrentUserViews = () => http.get('/api/v1/views/me')
|
||||||
|
const createView = (data) =>
|
||||||
|
http.post('/api/v1/views/me', data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const updateView = (id, data) =>
|
||||||
|
http.put(`/api/v1/views/me/${id}`, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
login,
|
login,
|
||||||
@@ -219,6 +256,7 @@ export default {
|
|||||||
deleteRole,
|
deleteRole,
|
||||||
updateRole,
|
updateRole,
|
||||||
getTeams,
|
getTeams,
|
||||||
|
deleteTeam,
|
||||||
getUsers,
|
getUsers,
|
||||||
getInbox,
|
getInbox,
|
||||||
getInboxes,
|
getInboxes,
|
||||||
@@ -226,9 +264,21 @@ export default {
|
|||||||
getConversation,
|
getConversation,
|
||||||
getAutomationRule,
|
getAutomationRule,
|
||||||
getAutomationRules,
|
getAutomationRules,
|
||||||
|
getAllBusinessHours,
|
||||||
|
getBusinessHours,
|
||||||
|
createBusinessHours,
|
||||||
|
updateBusinessHours,
|
||||||
|
deleteBusinessHours,
|
||||||
|
getAllSLAs,
|
||||||
|
getSLA,
|
||||||
|
createSLA,
|
||||||
|
updateSLA,
|
||||||
|
deleteSLA,
|
||||||
getAssignedConversations,
|
getAssignedConversations,
|
||||||
getUnassignedConversations,
|
getUnassignedConversations,
|
||||||
getAllConversations,
|
getAllConversations,
|
||||||
|
getTeamUnassignedConversations,
|
||||||
|
getViewConversations,
|
||||||
getGlobalDashboardCharts,
|
getGlobalDashboardCharts,
|
||||||
getGlobalDashboardCounts,
|
getGlobalDashboardCounts,
|
||||||
getUserDashboardCounts,
|
getUserDashboardCounts,
|
||||||
@@ -237,6 +287,7 @@ export default {
|
|||||||
getConversationMessage,
|
getConversationMessage,
|
||||||
getConversationMessages,
|
getConversationMessages,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
|
getCurrentUserTeams,
|
||||||
getCannedResponses,
|
getCannedResponses,
|
||||||
createCannedResponse,
|
createCannedResponse,
|
||||||
updateCannedResponse,
|
updateCannedResponse,
|
||||||
@@ -287,4 +338,8 @@ export default {
|
|||||||
getUsersCompact,
|
getUsersCompact,
|
||||||
getEmailNotificationSettings,
|
getEmailNotificationSettings,
|
||||||
updateEmailNotificationSettings,
|
updateEmailNotificationSettings,
|
||||||
|
getCurrentUserViews,
|
||||||
|
createView,
|
||||||
|
updateView,
|
||||||
|
deleteView
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
// App default font-size.
|
// App default font-size.
|
||||||
// Default: 16px, 15px looks wide.
|
// Default: 16px, 15px looks wide.
|
||||||
:root {
|
:root {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
@@ -176,27 +174,28 @@ body {
|
|||||||
@apply p-0;
|
@apply p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrollbar
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 8px; /* Adjust width */
|
||||||
}
|
height: 8px; /* Adjust height */
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #888;
|
background-color: #888;
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #555;
|
background-color: #555; /* Hover effect */
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
::-webkit-scrollbar-track {
|
||||||
scrollbar-width: thin;
|
background: #f0f0f0;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
// End Scrollbar
|
||||||
|
|
||||||
.code-editor {
|
.code-editor {
|
||||||
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
|
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
|
||||||
@@ -212,3 +211,46 @@ body {
|
|||||||
.ql-toolbar {
|
.ql-toolbar {
|
||||||
@apply rounded-t-lg;
|
@apply rounded-t-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blinking-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: red;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: blink 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
157
frontend/src/components/ViewForm.vue
Normal file
157
frontend/src/components/ViewForm.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog :open="openDialog" @update:open="openDialog = false">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
|
||||||
|
<DialogDescription>Views let you create custom filters and save them for reuse.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input id="name" class="col-span-3" placeholder="Enter view name"
|
||||||
|
v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Enter a unique name for your view</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="{ componentField }" name="inbox_type">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Inbox</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select class="w-full" v-bind="componentField">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select inbox" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem v-for="(value, key) in CONVERSATION_VIEWS_INBOXES" :key="key"
|
||||||
|
:value="key">
|
||||||
|
{{ value }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Select inbox to filter conversations</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="{ componentField }" name="filters">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Filters</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Add filters to customize view</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" :disabled="isSubmitting" :isLoading="isSubmitting">
|
||||||
|
{{ isSubmitting ? 'Saving...' : 'Save changes' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineModel, ref, onMounted, watch } from 'vue'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||||
|
import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import Filter from '@/components/common/Filter.vue'
|
||||||
|
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const emitter = useEmitter()
|
||||||
|
const openDialog = defineModel('openDialog', { required: false, default: false })
|
||||||
|
const view = defineModel('view', { required: false, default: {} })
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
conversationsListFilters,
|
||||||
|
initConversationListFilters
|
||||||
|
} = useConversationFilters()
|
||||||
|
|
||||||
|
const filterFields = ref([])
|
||||||
|
const initFields = async () => {
|
||||||
|
await initConversationListFilters()
|
||||||
|
filterFields.value = Object.entries(conversationsListFilters.value).map(([field, value]) => ({
|
||||||
|
model: 'conversations',
|
||||||
|
label: value.label,
|
||||||
|
field,
|
||||||
|
type: value.type,
|
||||||
|
operators: value.operators,
|
||||||
|
options: value.options?.map(option => ({
|
||||||
|
...option,
|
||||||
|
value: String(option.value)
|
||||||
|
})) ?? []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(initFields)
|
||||||
|
|
||||||
|
const formSchema = toTypedSchema(z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
name: z.string()
|
||||||
|
.min(2, { message: "Name must be at least 2 characters." })
|
||||||
|
.max(250, { message: "Name cannot exceed 250 characters." }),
|
||||||
|
inbox_type: z.enum(Object.keys(CONVERSATION_VIEWS_INBOXES)),
|
||||||
|
filters: z.array(
|
||||||
|
z.object({
|
||||||
|
model: z.string({ required_error: "Model required" }),
|
||||||
|
field: z.string({ required_error: "Field required" }),
|
||||||
|
operator: z.string({ required_error: "Operator required" }),
|
||||||
|
value: z.union([z.string(), z.number(), z.boolean()])
|
||||||
|
})
|
||||||
|
).default([])
|
||||||
|
}))
|
||||||
|
|
||||||
|
const form = useForm({ validationSchema: formSchema })
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (values.id) {
|
||||||
|
await api.updateView(values.id, values)
|
||||||
|
} else {
|
||||||
|
await api.createView(values)
|
||||||
|
}
|
||||||
|
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||||
|
openDialog.value = false
|
||||||
|
form.resetForm()
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => view.value, (newVal) => {
|
||||||
|
if (newVal && Object.keys(newVal).length) {
|
||||||
|
form.setValues(newVal)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -15,9 +15,6 @@ const sidebarNavItems = [
|
|||||||
<div class="space-y-4 md:block page-content">
|
<div class="space-y-4 md:block page-content">
|
||||||
<PageHeader title="Account settings" subTitle="Manage your account settings." />
|
<PageHeader title="Account settings" subTitle="Manage your account settings." />
|
||||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||||
<aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
|
|
||||||
<SidebarNav :navItems="sidebarNavItems" />
|
|
||||||
</aside>
|
|
||||||
<div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
|
<div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<div class="flex flex-col space-y-5 justify-center">
|
<div class="flex flex-col space-y-5 justify-center">
|
||||||
<input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
|
<input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
|
||||||
@change="selectFile" />
|
@change="selectFile" />
|
||||||
<Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button>
|
<Button class="w-28" @click="selectAvatar"> Choose a file... </Button>
|
||||||
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">Remove
|
<Button class="w-28" @click="removeAvatar" variant="destructive">Remove
|
||||||
avatar</Button>
|
avatar</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,79 +1,5 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import PageHeader from '@/components/common/PageHeader.vue'
|
|
||||||
import SidebarNav from '@/components/common/SidebarNav.vue'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
const allNavItems = [
|
|
||||||
{
|
|
||||||
title: 'General',
|
|
||||||
href: '/admin/general',
|
|
||||||
description: 'Configure general app settings',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Conversations',
|
|
||||||
href: '/admin/conversations',
|
|
||||||
description: 'Manage tags, canned responses and statuses.',
|
|
||||||
permission: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Inboxes',
|
|
||||||
href: '/admin/inboxes',
|
|
||||||
description: 'Manage your inboxes',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Teams',
|
|
||||||
href: '/admin/teams',
|
|
||||||
description: 'Manage teams, manage agents and roles',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Automations',
|
|
||||||
href: '/admin/automations',
|
|
||||||
description: 'Manage automations and time triggers',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Notification',
|
|
||||||
href: '/admin/notification',
|
|
||||||
description: 'Manage email notification settings',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Email templates',
|
|
||||||
href: '/admin/templates',
|
|
||||||
description: 'Manage outgoing email templates',
|
|
||||||
permission: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'OpenID Connect SSO',
|
|
||||||
href: '/admin/oidc',
|
|
||||||
description: 'Manage OpenID SSO configurations',
|
|
||||||
permission: null,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const sidebarNavItems = computed(() =>
|
|
||||||
allNavItems.filter((item) => !item.permission || item.permission && userStore.permissions.includes(item.permission))
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 md:block overflow-y-auto">
|
<div class="overflow-y-auto ">
|
||||||
<PageHeader title="Admin settings" subTitle="Manage your helpdesk settings." />
|
<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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button variant="outline" @click.prevent="addAction" size="sm">Add action</Button>
|
<Button variant="outline" @click.prevent="addAction">Add action</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,6 +83,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { actions } = toRefs(props)
|
const { actions } = toRefs(props)
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
const slas = ref([])
|
||||||
const teams = ref([])
|
const teams = ref([])
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
@@ -91,7 +92,8 @@ const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
|
const [slasResp, teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
|
||||||
|
api.getAllSLAs(),
|
||||||
api.getTeamsCompact(),
|
api.getTeamsCompact(),
|
||||||
api.getUsersCompact(),
|
api.getUsersCompact(),
|
||||||
api.getStatuses(),
|
api.getStatuses(),
|
||||||
@@ -117,9 +119,14 @@ onMounted(async () => {
|
|||||||
value: priority.name,
|
value: priority.name,
|
||||||
name: priority.name
|
name: priority.name
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
slas.value = slasResp.data.data.map(sla => ({
|
||||||
|
value: sla.id,
|
||||||
|
name: sla.name
|
||||||
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Something went wrong',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
@@ -173,6 +180,10 @@ const conversationActions = {
|
|||||||
reply: {
|
reply: {
|
||||||
label: 'Send reply',
|
label: 'Send reply',
|
||||||
inputType: 'richtext',
|
inputType: 'richtext',
|
||||||
|
},
|
||||||
|
set_sla: {
|
||||||
|
label: 'Set SLA',
|
||||||
|
inputType: 'select',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +192,7 @@ const actionDropdownValues = {
|
|||||||
assign_user: users,
|
assign_user: users,
|
||||||
set_status: statuses,
|
set_status: statuses,
|
||||||
set_priority: priorities,
|
set_priority: priorities,
|
||||||
|
set_sla: slas,
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDropdownValues = (field) => {
|
const getDropdownValues = (field) => {
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<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 } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import RuleTab from './RuleTab.vue'
|
import RuleTab from './RuleTab.vue'
|
||||||
|
|
||||||
const selectedTab = defineModel('selectedTab', {
|
const selectedTab = defineModel('automationsTab', {
|
||||||
default: 'new_conversation'
|
default: 'new_conversation',
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -3,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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<PageHeader title="Business hours" description="Manage business hours" />
|
||||||
|
<div class="w-8/12">
|
||||||
|
<template v-if="router.currentRoute.value.path === '/admin/business-hours'">
|
||||||
|
<div class="flex justify-between mb-5">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<Button @click="navigateToAddBusinessHour">New business hour</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Spinner v-if="isLoading"></Spinner>
|
||||||
|
<DataTable :columns="columns" :data="businessHours" v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-view/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import DataTable from '@/components/admin/DataTable.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import PageHeader from '../common/PageHeader.vue'
|
||||||
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
import { columns } from '@/components/admin/business_hours/dataTableColumns.js'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const businessHours = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const emit = useEmitter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAll()
|
||||||
|
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshList = (data) => {
|
||||||
|
if (data?.model === 'business_hours') fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const resp = await api.getAllBusinessHours()
|
||||||
|
businessHours.value = resp.data.data
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToAddBusinessHour = () => {
|
||||||
|
router.push('/admin/business-hours/new')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit="onSubmit" class="space-y-8">
|
||||||
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="General working hours" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="General working hours for my company" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="is_always_open">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Set business hours
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup v-bind="componentField">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<RadioGroupItem id="r1" value="true" />
|
||||||
|
<Label for="r1">Always open (24x7)</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<RadioGroupItem id="r2" value="false" />
|
||||||
|
<Label for="r2">Custom business hours</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div v-if="form.values.is_always_open === 'false'">
|
||||||
|
<div>
|
||||||
|
<div v-for="day in WEEKDAYS" :key="day" class="flex items-center justify-between space-y-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<Checkbox :id="day" :checked="!!selectedDays[day]"
|
||||||
|
@update:checked="handleDayToggle(day, $event)" />
|
||||||
|
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2 items-center">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<Input type="time" :defaultValue="hours[day]?.open || '09:00'"
|
||||||
|
@update:modelValue="(val) => updateHours(day, 'open', val)"
|
||||||
|
:disabled="!selectedDays[day]" />
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500">to</span>
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<Input type="time" :defaultValue="hours[day]?.close || '17:00'"
|
||||||
|
@update:modelValue="(val) => updateHours(day, 'close', val)"
|
||||||
|
:disabled="!selectedDays[day]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog >
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div></div>
|
||||||
|
<DialogTrigger as-child>
|
||||||
|
<Button>New holiday</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SimpleTable :headers="['Name', 'Date']" :keys="['name', 'date']" :data="holidays" @deleteItem="deleteHoliday" />
|
||||||
|
<DialogContent class="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New holiday</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="holiday_name" class="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="holiday_name" v-model="holidayName" class="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="date" class="text-right">
|
||||||
|
Date
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="outline" :class="cn(
|
||||||
|
'w-[280px] justify-start text-left font-normal',
|
||||||
|
!holidayDate && 'text-muted-foreground',
|
||||||
|
)">
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
|
{{ holidayDate && !isNaN(new Date(holidayDate).getTime()) ? format(new
|
||||||
|
Date(holidayDate), 'MMMM dd, yyyy') : "Pick a date" }}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-0">
|
||||||
|
<Calendar v-model="holidayDate" />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button :disabled="!holidayName || !holidayDate"
|
||||||
|
@click="saveHoliday">
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, reactive } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { formSchema } from './formSchema.js'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||||
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { WEEKDAYS } from '@/constants/date'
|
||||||
|
import { Calendar as CalendarIcon } from 'lucide-vue-next'
|
||||||
|
import SimpleTable from '@/components/common/SimpleTable.vue'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialValues: {
|
||||||
|
type: Object,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
submitForm: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: () => 'Save'
|
||||||
|
},
|
||||||
|
isNewForm: {
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let holidays = reactive([])
|
||||||
|
const holidayName = ref('')
|
||||||
|
const holidayDate = ref(null)
|
||||||
|
const selectedDays = ref({})
|
||||||
|
const hours = ref({})
|
||||||
|
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(formSchema),
|
||||||
|
initialValues: props.initialValues
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const saveHoliday = () => {
|
||||||
|
holidays.push({
|
||||||
|
name: holidayName.value,
|
||||||
|
date: new Date(holidayDate.value).toISOString().split('T')[0]
|
||||||
|
})
|
||||||
|
holidayName.value = ''
|
||||||
|
holidayDate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteHoliday = (item) => {
|
||||||
|
holidays.splice(holidays.findIndex(h => h.name === item.name), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayToggle = (day, checked) => {
|
||||||
|
selectedDays.value = {
|
||||||
|
...selectedDays.value,
|
||||||
|
[day]: checked
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked && !hours.value[day]) {
|
||||||
|
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||||
|
} else if (!checked) {
|
||||||
|
const newHours = { ...hours.value }
|
||||||
|
delete newHours[day]
|
||||||
|
hours.value = newHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHours = (day, type, value) => {
|
||||||
|
if (!hours.value[day]) {
|
||||||
|
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||||
|
}
|
||||||
|
hours.value[day][type] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
values.is_always_open = values.is_always_open === 'true'
|
||||||
|
const businessHours = values.is_always_open === true
|
||||||
|
? {}
|
||||||
|
:
|
||||||
|
Object.keys(selectedDays.value)
|
||||||
|
.filter(day => selectedDays.value[day])
|
||||||
|
.reduce((acc, day) => {
|
||||||
|
acc[day] = hours.value[day]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
const finalValues = {
|
||||||
|
...values,
|
||||||
|
hours: businessHours,
|
||||||
|
holidays: holidays
|
||||||
|
}
|
||||||
|
props.submitForm(finalValues)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for initial values
|
||||||
|
watch(
|
||||||
|
() => props.initialValues,
|
||||||
|
(newValues) => {
|
||||||
|
if (!newValues || Object.keys(newValues).length === 0) return
|
||||||
|
// Set business hours if provided
|
||||||
|
newValues.is_always_open = newValues.is_always_open.toString()
|
||||||
|
if (newValues.is_always_open === 'false') {
|
||||||
|
hours.value = newValues.hours || {}
|
||||||
|
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
|
||||||
|
acc[day] = true
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
// Set other form values
|
||||||
|
form.setValues(newValues)
|
||||||
|
holidays.length = 0
|
||||||
|
holidays.push(...(newValues.holidays || []))
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-5">
|
||||||
|
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||||
|
</div>
|
||||||
|
<Spinner v-if="isLoading"></Spinner>
|
||||||
|
<BusinessHoursForm :initial-values="businessHours" :submitForm="submitForm" :isNewForm="isNewForm"
|
||||||
|
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import BusinessHoursForm from './BusinessHoursForm.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
|
||||||
|
const businessHours = ref({})
|
||||||
|
const emitter = useEmitter()
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const formLoading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitForm = async (values) => {
|
||||||
|
try {
|
||||||
|
formLoading.value = true
|
||||||
|
if (props.id) {
|
||||||
|
await api.updateBusinessHours(props.id, values)
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Business hours updated successfully',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await api.createBusinessHours(values)
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Business hours created successfully',
|
||||||
|
})
|
||||||
|
router.push('/admin/business-hours')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Could not save business hours',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadCrumLabel = () => {
|
||||||
|
return props.id ? 'Edit' : 'New'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewForm = computed(() => {
|
||||||
|
return props.id ? false : true
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbLinks = [
|
||||||
|
{ path: '/admin/business-hours', label: 'Business hours' },
|
||||||
|
{ path: '#', label: breadCrumLabel() }
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (props.id) {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const resp = await api.getBusinessHours(props.id)
|
||||||
|
businessHours.value = resp.data.data
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Could not fetch business hours',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { h } from 'vue'
|
||||||
|
import dropdown from './dataTableDropdown.vue'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
export const columns = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Name')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Created at')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'relative' },
|
||||||
|
h(dropdown, {
|
||||||
|
role
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
import { MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const emit = useEmitter()
|
||||||
|
const props = defineProps({
|
||||||
|
role: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
id: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function edit (id) {
|
||||||
|
router.push({ name: 'edit-business-hours', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBusinessHours (id) {
|
||||||
|
await api.deleteBusinessHours(id)
|
||||||
|
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||||
|
model: 'business_hours'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||||
|
<span class="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="deleteBusinessHours(props.role.id)"> Delete </DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
13
frontend/src/components/admin/business_hours/formSchema.js
Normal file
13
frontend/src/components/admin/business_hours/formSchema.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Name is required.'
|
||||||
|
})
|
||||||
|
.min(1, {
|
||||||
|
message: 'Name must be at least 1 character.'
|
||||||
|
}),
|
||||||
|
description: z.string().optional(),
|
||||||
|
is_always_open: z.string().default('true').optional(),
|
||||||
|
})
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted max-w-80"
|
class="flex-1 rounded-xl px-6 py-4 border border-muted shadow-md hover:shadow-lg transition-transform duration-200 transform hover:scale-105 cursor-pointer bg-white max-w-80"
|
||||||
@click="handleClick"
|
@click="handleClick">
|
||||||
>
|
<div class="flex items-center mb-3">
|
||||||
<div class="flex items-center mb-4">
|
<component :is="icon" size="24" class="mr-2 text-primary" />
|
||||||
<component :is="icon" size="25" class="mr-2" />
|
<p class="text-lg font-semibold text-gray-700">{{ title }}</p>
|
||||||
<p class="text-lg">{{ title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{{ subTitle }}</p>
|
<p class="text-sm text-gray-500">{{ subTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -15,18 +14,9 @@
|
|||||||
import { defineProps, defineEmits } from 'vue'
|
import { defineProps, defineEmits } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: String,
|
||||||
type: String,
|
subTitle: String,
|
||||||
required: true
|
icon: Function,
|
||||||
},
|
|
||||||
subTitle: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
type: Function,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
onClick: {
|
onClick: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null
|
default: null
|
||||||
@@ -36,9 +26,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['click'])
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (props.onClick) {
|
if (props.onClick) props.onClick()
|
||||||
props.onClick()
|
|
||||||
}
|
|
||||||
emit('click')
|
emit('click')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col space-y-1">
|
<div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
|
||||||
<span class="text-2xl">{{ title }}</span>
|
<span class="font-semibold text-2xl">{{ title }}</span>
|
||||||
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mb-5">
|
|
||||||
<PageHeader title="Conversation" description="Manage conversation settings" />
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-5">
|
|
||||||
<AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
|
|
||||||
:subTitle="card.subTitle" :icon="card.icon">
|
|
||||||
</AdminMenuCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-view></router-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { Tag, TrendingUp, MessageCircleReply } from 'lucide-vue-next'
|
|
||||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
|
|
||||||
import PageHeader from '../common/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const navigateToTags = () => {
|
|
||||||
router.push('/admin/conversations/tags')
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToStatus = () => {
|
|
||||||
router.push('/admin/conversations/statuses')
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToCannedResponse = () => {
|
|
||||||
router.push('/admin/conversations/canned-responses')
|
|
||||||
}
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
title: 'Tags',
|
|
||||||
subTitle: 'Manage conversation tags.',
|
|
||||||
onClick: navigateToTags,
|
|
||||||
icon: Tag
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Canned response',
|
|
||||||
subTitle: 'Manage canned responses.',
|
|
||||||
onClick: navigateToCannedResponse,
|
|
||||||
icon: MessageCircleReply
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Status',
|
|
||||||
subTitle: 'Manage conversation statuses.',
|
|
||||||
onClick: navigateToStatus,
|
|
||||||
icon: TrendingUp
|
|
||||||
}
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<CannedResponsesForm @submit="onSubmit">
|
<CannedResponsesForm @submit="onSubmit">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<DialogFooter class="mt-7">
|
<DialogFooter class="mt-7">
|
||||||
<Button type="submit" size="sm">Save Changes</Button>
|
<Button type="submit">Save Changes</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
</CannedResponsesForm>
|
</CannedResponsesForm>
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<StatusForm @submit.prevent="onSubmit">
|
<StatusForm @submit.prevent="onSubmit">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<DialogFooter class="mt-10">
|
<DialogFooter class="mt-10">
|
||||||
<Button type="submit" size="sm"> Save changes </Button>
|
<Button type="submit"> Save changes </Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
</StatusForm>
|
</StatusForm>
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<TagsForm @submit.prevent="onSubmit">
|
<TagsForm @submit.prevent="onSubmit">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<DialogFooter class="mt-10">
|
<DialogFooter class="mt-10">
|
||||||
<Button type="submit" size="sm"> Save changes </Button>
|
<Button type="submit"> Save changes </Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
</TagsForm>
|
</TagsForm>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="General" description="General app settings" />
|
<PageHeader title="General" description="Manage general app settings" />
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -9,20 +9,25 @@ export const formSchema = z.object({
|
|||||||
message: 'Site name must be at least 1 characters.'
|
message: 'Site name must be at least 1 characters.'
|
||||||
}),
|
}),
|
||||||
lang: z.string().optional(),
|
lang: z.string().optional(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
business_hours_id: z.string().optional(),
|
||||||
|
logo_url: z.string().url({
|
||||||
|
message: 'Logo URL must be a valid URL.'
|
||||||
|
}).url().optional(),
|
||||||
root_url: z
|
root_url: z
|
||||||
.string({
|
.string({
|
||||||
required_error: 'Root URL is required.'
|
required_error: 'Root URL is required.'
|
||||||
})
|
})
|
||||||
.url({
|
.url({
|
||||||
message: 'Root URL must be a valid URL.'
|
message: 'Root URL must be a valid URL.'
|
||||||
}),
|
}).url(),
|
||||||
favicon_url: z
|
favicon_url: z
|
||||||
.string({
|
.string({
|
||||||
required_error: 'Favicon URL is required.'
|
required_error: 'Favicon URL is required.'
|
||||||
})
|
})
|
||||||
.url({
|
.url({
|
||||||
message: 'Favicon URL must be a valid URL.'
|
message: 'Favicon URL must be a valid URL.'
|
||||||
}),
|
}).url(),
|
||||||
max_file_upload_size: z
|
max_file_upload_size: z
|
||||||
.number({
|
.number({
|
||||||
required_error: 'Max upload file size is required.'
|
required_error: 'Max upload file size is required.'
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}"
|
}"
|
||||||
@submit="submitForm"
|
@submit="submitForm"
|
||||||
>
|
>
|
||||||
<Button type="submit" size="sm" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
<Button type="submit" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
||||||
</AutoForm>
|
</AutoForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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'))
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ export const formSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
smtp: z
|
smtp: z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().describe('Host').default('smtp.yourmailserver.com'),
|
host: z.string().describe('Host').default('smtp.google.com'),
|
||||||
port: z
|
port: z
|
||||||
.number({ invalid_type_error: 'Port must be a number.' })
|
.number({ invalid_type_error: 'Port must be a number.' })
|
||||||
.min(1, { message: 'Port must be at least 1.' })
|
.min(1, { message: 'Port must be at least 1.' })
|
||||||
.max(65535, { message: 'Port must be at most 65535.' })
|
.max(65535, { message: 'Port must be at most 65535.' })
|
||||||
.describe('Port')
|
.describe('Port')
|
||||||
.default(25),
|
.default(587),
|
||||||
username: z.string().describe('Username'),
|
username: z.string().describe('Username'),
|
||||||
password: z.string().describe('Password'),
|
password: z.string().describe('Password'),
|
||||||
max_conns: z
|
max_conns: z
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit="onSmtpSubmit" class="space-y-6"
|
<form @submit="onSmtpSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
|
||||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
|
|
||||||
|
|
||||||
<!-- Enabled Field -->
|
|
||||||
<FormField name="enabled" v-slot="{ value, handleChange }">
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
|
||||||
<Label>Enabled</Label>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- SMTP Host Field -->
|
<!-- SMTP Host Field -->
|
||||||
<FormField v-slot="{ componentField }" name="host">
|
<FormField v-slot="{ componentField }" name="host">
|
||||||
@@ -120,7 +106,8 @@
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>From Email Address</FormLabel>
|
<FormLabel>From Email Address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" />
|
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>"
|
||||||
|
v-bind="componentField" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
|
<FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
|
||||||
@@ -138,7 +125,20 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
<!-- Enabled Field -->
|
||||||
|
<FormField name="enabled" v-slot="{ value, handleChange }">
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox :checked="value" @update:checked="handleChange" />
|
||||||
|
<Label>Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const submitForm = async (values) => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
formLoading.value = false
|
formLoading.value = false
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
header: function () {
|
header: function () {
|
||||||
return h('div', { class: 'text-center' }, 'Modified at')
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
},
|
},
|
||||||
cell: function ({ row }) {
|
cell: function ({ row }) {
|
||||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||||
|
|||||||
91
frontend/src/components/admin/sla/CreateEditSLA.vue
Normal file
91
frontend/src/components/admin/sla/CreateEditSLA.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-5">
|
||||||
|
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||||
|
</div>
|
||||||
|
<Spinner v-if="isLoading"></Spinner>
|
||||||
|
<SLAForm :initial-values="slaData" :submitForm="submitForm" :isNewForm="isNewForm"
|
||||||
|
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import SLAForm from './SLAForm.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
|
||||||
|
const slaData = ref({})
|
||||||
|
const emitter = useEmitter()
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const formLoading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitForm = async (values) => {
|
||||||
|
try {
|
||||||
|
formLoading.value = true
|
||||||
|
if (props.id) {
|
||||||
|
await api.updateSLA(props.id, values)
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
description: 'SLA updated successfully',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await api.createSLA(values)
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Success',
|
||||||
|
description: 'SLA created successfully',
|
||||||
|
})
|
||||||
|
router.push('/admin/sla')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Could not save SLA',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadCrumLabel = () => {
|
||||||
|
return props.id ? 'Edit' : 'New'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewForm = computed(() => {
|
||||||
|
return props.id ? false : true
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbLinks = [
|
||||||
|
{ path: '/admin/sla', label: 'SLA' },
|
||||||
|
{ path: '#', label: breadCrumLabel() }
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (props.id) {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const resp = await api.getSLA(props.id)
|
||||||
|
slaData.value = resp.data.data
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Could not fetch SLA',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
65
frontend/src/components/admin/sla/SLA.vue
Normal file
65
frontend/src/components/admin/sla/SLA.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<PageHeader title="SLA" description="Manage service level agreements" />
|
||||||
|
<div class="w-8/12">
|
||||||
|
<template v-if="router.currentRoute.value.path === '/admin/sla'">
|
||||||
|
<div class="flex justify-between mb-5">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<Button @click="navigateToAddSLA">New SLA</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Spinner v-if="isLoading"></Spinner>
|
||||||
|
<DataTable :columns="columns" :data="slas" v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-view/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import DataTable from '@/components/admin/DataTable.vue'
|
||||||
|
import { columns } from './dataTableColumns.js'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import PageHeader from '../common/PageHeader.vue'
|
||||||
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const slas = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const emit = useEmitter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAll()
|
||||||
|
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshList = (data) => {
|
||||||
|
if (data?.model === 'sla') fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const resp = await api.getAllSLAs()
|
||||||
|
slas.value = resp.data.data
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToAddSLA = () => {
|
||||||
|
router.push('/admin/sla/new')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
100
frontend/src/components/admin/sla/SLAForm.vue
Normal file
100
frontend/src/components/admin/sla/SLAForm.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit="onSubmit" class="space-y-8">
|
||||||
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="SLA Name" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Describe the SLA" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="first_response_time">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First response time</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="6h" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Duration in hours or minutes to respond to a conversation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="resolution_time">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Resolution time</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="4h" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Duration in hours or minutes to resolve a conversation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { formSchema } from './formSchema.js'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialValues: {
|
||||||
|
type: Object,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
submitForm: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: () => 'Save'
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: toTypedSchema(formSchema),
|
||||||
|
initialValues: props.initialValues
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
props.submitForm(values)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialValues,
|
||||||
|
(newValues) => {
|
||||||
|
if (!newValues || Object.keys(newValues).length === 0) return
|
||||||
|
form.setValues(newValues)
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
47
frontend/src/components/admin/sla/dataTableColumns.js
Normal file
47
frontend/src/components/admin/sla/dataTableColumns.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { h } from 'vue'
|
||||||
|
import dropdown from './dataTableDropdown.vue'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
export const columns = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Name')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Created at')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'relative' },
|
||||||
|
h(dropdown, {
|
||||||
|
role
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
52
frontend/src/components/admin/sla/dataTableDropdown.vue
Normal file
52
frontend/src/components/admin/sla/dataTableDropdown.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
import { MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const emit = useEmitter()
|
||||||
|
const props = defineProps({
|
||||||
|
role: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
id: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function edit (id) {
|
||||||
|
router.push({ path: `/admin/sla/${id}/edit` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSLA (id) {
|
||||||
|
await api.deleteSLA(id)
|
||||||
|
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||||
|
model: 'sla'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||||
|
<span class="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="deleteSLA(props.role.id)"> Delete </DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
26
frontend/src/components/admin/sla/formSchema.js
Normal file
26
frontend/src/components/admin/sla/formSchema.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as z from 'zod'
|
||||||
|
import { isGoHourMinuteDuration } from '@/utils/strings'
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Name is required.'
|
||||||
|
})
|
||||||
|
.max(255, {
|
||||||
|
message: 'Name must be at most 255 characters.'
|
||||||
|
}),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(255, {
|
||||||
|
message: 'Description must be at most 255 characters.'
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
first_response_time: z.string().optional().refine(isGoHourMinuteDuration, {
|
||||||
|
message:
|
||||||
|
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||||
|
}),
|
||||||
|
resolution_time: z.string().optional().refine(isGoHourMinuteDuration, {
|
||||||
|
message:
|
||||||
|
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mb-5">
|
|
||||||
<PageHeader title="Teams" description="Manage teams, users and roles" />
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-5">
|
|
||||||
<AdminMenuCard
|
|
||||||
v-for="card in cards"
|
|
||||||
:key="card.title"
|
|
||||||
:onClick="card.onClick"
|
|
||||||
:title="card.title"
|
|
||||||
:subTitle="card.subTitle"
|
|
||||||
:icon="card.icon"
|
|
||||||
>
|
|
||||||
</AdminMenuCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-view></router-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { Users, UserRoundCog, User } from 'lucide-vue-next'
|
|
||||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
|
|
||||||
import PageHeader from '../common/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const navigateToUsers = () => {
|
|
||||||
router.push('/admin/teams/users')
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToTeams = () => {
|
|
||||||
router.push('/admin/teams/teams')
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToRoles = () => {
|
|
||||||
router.push('/admin/teams/roles')
|
|
||||||
}
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
title: 'Users',
|
|
||||||
subTitle: 'Create and manage users.',
|
|
||||||
onClick: navigateToUsers,
|
|
||||||
icon: User
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Teams',
|
|
||||||
subTitle: 'Create and manage teams.',
|
|
||||||
onClick: navigateToTeams,
|
|
||||||
icon: Users
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Roles',
|
|
||||||
subTitle: 'Create and manage roles.',
|
|
||||||
onClick: navigateToRoles,
|
|
||||||
icon: UserRoundCog
|
|
||||||
}
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
@@ -45,7 +45,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: '/admin/teams/roles', label: 'Roles' },
|
||||||
{ path: '#', label: 'Edit role' }
|
{ path: '#', label: 'Edit role' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const emitter = useEmitter()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: '/admin/teams/roles', label: 'Roles' },
|
||||||
{ path: '#', label: 'Add role' }
|
{ path: '#', label: 'Add role' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading">{{ submitLabel }}</Button>
|
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-5">
|
<PageHeader title="Roles" description="Manage roles" />
|
||||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
<div class="w-8/12">
|
||||||
|
<div 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' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const formLoading = ref(false)
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/teams', label: 'Teams' },
|
{ path: '/admin/teams/teams', label: 'Teams' },
|
||||||
{ path: '/admin/teams/teams/new', label: 'New team' }
|
{ path: '/admin/teams/teams/new', label: 'New team' }
|
||||||
]
|
]
|
||||||
@@ -39,7 +38,7 @@ const createTeam = async (values) => {
|
|||||||
router.push('/admin/teams/teams')
|
router.push('/admin/teams/teams')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Something went wrong',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
@@ -22,11 +22,18 @@ const formLoading = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/teams', label: 'Teams' },
|
{ path: '/admin/teams/teams', label: 'Teams' },
|
||||||
{ path: '#', label: 'Edit team' }
|
{ path: '#', label: 'Edit team' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const submitForm = (values) => {
|
const submitForm = (values) => {
|
||||||
updateTeam(values)
|
updateTeam(values)
|
||||||
}
|
}
|
||||||
@@ -41,7 +48,7 @@ const updateTeam = async (payload) => {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not update team',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
@@ -57,7 +64,7 @@ onMounted(async () => {
|
|||||||
team.value = resp.data.data
|
team.value = resp.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not fetch team',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
@@ -65,11 +72,4 @@ onMounted(async () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const emit = useEmitter()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
team: {
|
team: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -21,9 +25,28 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function editTeam(id) {
|
function editTeam (id) {
|
||||||
router.push({ path: `/admin/teams/teams/${id}/edit` })
|
router.push({ path: `/admin/teams/teams/${id}/edit` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteTeam (id) {
|
||||||
|
try {
|
||||||
|
await api.deleteTeam(id)
|
||||||
|
emitRefreshTeamList()
|
||||||
|
} catch (error) {
|
||||||
|
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitRefreshTeamList = () => {
|
||||||
|
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||||
|
model: 'team'
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,6 +59,7 @@ function editTeam(id) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
|
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="deleteTeam(props.team.id)"> Delete </DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,32 +11,93 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField name="auto_assign_conversations" v-slot="{ value, handleChange }">
|
<FormField name="conversation_assignment_type" v-slot="{ componentField }">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div class="flex items-center space-x-2">
|
<Select v-bind="componentField">
|
||||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
<SelectTrigger>
|
||||||
<Label>Auto assign conversations</Label>
|
<SelectValue placeholder="Select a assignment type" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem v-for="at in assignmentTypes" :key="at" :value="at">
|
||||||
|
{{ at }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Automatically assign new conversations to agents in this team in a round-robin fashion.</FormDescription>
|
<FormDescription>
|
||||||
|
Round robin: Conversations are assigned to team members in a round-robin fashion. <br>
|
||||||
|
Manual: Conversations are manually assigned to team members.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
<FormField v-slot="{ componentField }" name="timezone">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Timezone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select v-bind="componentField">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a timezone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
|
||||||
|
{{ timezone }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Team's timezone will be used to calculate SLA.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="business_hours_id">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Business hours</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select v-bind="componentField">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select business hours" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
|
||||||
|
{{ bh.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Default business hours.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue'
|
import { watch, computed, ref, onMounted } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { teamFormSchema } from './teamFormSchema.js'
|
import { teamFormSchema } from './teamFormSchema.js'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -45,8 +106,18 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
FormDescription
|
FormDescription
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const emitter = useEmitter()
|
||||||
|
const timezones = computed(() => {
|
||||||
|
return Intl.supportedValuesOf('timeZone')
|
||||||
|
})
|
||||||
|
const assignmentTypes = ['Round robin', 'Manual']
|
||||||
|
const businessHours = ref([])
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -71,6 +142,32 @@ const form = useForm({
|
|||||||
validationSchema: toTypedSchema(teamFormSchema)
|
validationSchema: toTypedSchema(teamFormSchema)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchBusinessHours()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchBusinessHours = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getAllBusinessHours()
|
||||||
|
businessHours.value = response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
// If unauthorized (no permission), show a toast message.
|
||||||
|
if (error.response.status === 403) {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Unauthorized',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: 'You do not have permission to view business hours.'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Could not fetch business hours',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
props.submitForm(values)
|
props.submitForm(values)
|
||||||
})
|
})
|
||||||
@@ -79,6 +176,7 @@ const onSubmit = form.handleSubmit((values) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.initialValues,
|
() => props.initialValues,
|
||||||
(newValues) => {
|
(newValues) => {
|
||||||
|
if (Object.keys(newValues).length === 0) return
|
||||||
form.setValues(newValues)
|
form.setValues(newValues)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
header: function () {
|
header: function () {
|
||||||
return h('div', { class: 'text-center' }, 'Modified at')
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
},
|
},
|
||||||
cell: function ({ row }) {
|
cell: function ({ row }) {
|
||||||
return h(
|
return h(
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ export const teamFormSchema = z.object({
|
|||||||
.min(2, {
|
.min(2, {
|
||||||
message: 'Team name must be at least 2 characters.'
|
message: 'Team name must be at least 2 characters.'
|
||||||
}),
|
}),
|
||||||
auto_assign_conversations: z.boolean().optional()
|
conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
|
||||||
|
business_hours_id : z.number({ required_error: 'Business hours is required.' }),
|
||||||
|
timezone: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const { toast } = useToast()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/users', label: 'Users' },
|
{ path: '/admin/teams/users', label: 'Users' },
|
||||||
{ path: '#', label: 'Add user' }
|
{ path: '#', label: 'Add user' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const formLoading = ref(false)
|
|||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams', label: 'Teams' },
|
|
||||||
{ path: '/admin/teams/users', label: 'Users' },
|
{ path: '/admin/teams/users', label: 'Users' },
|
||||||
{ path: '#', label: 'Edit user' }
|
{ path: '#', label: 'Edit user' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-5">
|
<PageHeader title="Users" description="Manage users" />
|
||||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
<div class="w-8/12">
|
||||||
|
<div 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' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
header: function () {
|
header: function () {
|
||||||
return h('div', { class: 'text-center' }, 'Modified at')
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
},
|
},
|
||||||
cell: function ({ row }) {
|
cell: function ({ row }) {
|
||||||
return h(
|
return h(
|
||||||
|
|||||||
@@ -4,24 +4,37 @@
|
|||||||
<FormItem v-auto-animate>
|
<FormItem v-auto-animate>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Template name" v-bind="componentField" />
|
<Input type="text" placeholder="Template name" v-bind="componentField" :disabled="!isOutgoingTemplate" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<div v-if="!isOutgoingTemplate">
|
||||||
|
<FormField v-slot="{ componentField }" name="subject">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Subject</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Subject for email" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField, handleChange }" name="body">
|
<FormField v-slot="{ componentField, handleChange }" name="body">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Body</FormLabel>
|
<FormLabel>Body</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
|
<CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{{ `Make sure the template has \{\{ template "content" . \}\}` }}</FormDescription>
|
<FormDescription v-if="isOutgoingTemplate">{{ `Make sure the template has \{\{ template "content" . \}\}` }}
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField name="is_default" v-slot="{ value, handleChange }">
|
<FormField name="is_default" v-slot="{ value, handleChange }" v-if="isOutgoingTemplate">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -34,12 +47,12 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue'
|
import { watch, computed } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
@@ -85,6 +98,10 @@ const onSubmit = form.handleSubmit((values) => {
|
|||||||
props.submitForm(values)
|
props.submitForm(values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isOutgoingTemplate = computed(() => {
|
||||||
|
return props.initialValues?.type === 'email_outgoing'
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for changes in initialValues and update the form.
|
// Watch for changes in initialValues and update the form.
|
||||||
watch(
|
watch(
|
||||||
() => props.initialValues,
|
() => props.initialValues,
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<PageHeader title="Email templates" description="Manage email templates" />
|
||||||
<div class="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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h } from 'vue'
|
|||||||
import dropdown from './dataTableDropdown.vue'
|
import dropdown from './dataTableDropdown.vue'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
export const columns = [
|
export const outgoingEmailTemplatesColumns = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: function () {
|
header: function () {
|
||||||
@@ -30,7 +30,44 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
header: function () {
|
header: function () {
|
||||||
return h('div', { class: 'text-center' }, 'Modified at')
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const template = row.original
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'relative' },
|
||||||
|
h(dropdown, {
|
||||||
|
template
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
export const emailNotificationTemplates = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Name')
|
||||||
|
},
|
||||||
|
cell: function ({ row }) {
|
||||||
|
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: function () {
|
||||||
|
return h('div', { class: 'text-center' }, 'Updated at')
|
||||||
},
|
},
|
||||||
cell: function ({ row }) {
|
cell: function ({ row }) {
|
||||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const deleteTemplate = async (id) => {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not delete template',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import * as z from 'zod'
|
import * as z from 'zod';
|
||||||
|
|
||||||
export const formSchema = z.object({
|
export const formSchema = z
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
<Button type="submit"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
<Button type="submit"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-for="(filter, index) in modelValue" :key="index">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center space-x-2 mb-2 flex-row justify-between">
|
<div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
|
||||||
<div class="w-1/3">
|
<div class="grid grid-cols-3 gap-2 w-full">
|
||||||
<Select v-model="filter.field" @update:modelValue="updateFieldModel(filter, $event)">
|
<!-- Field -->
|
||||||
<SelectTrigger class="w-full">
|
<Select v-model="modelFilter.field">
|
||||||
<SelectValue placeholder="Select Field" />
|
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||||
|
<SelectValue placeholder="Field" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem v-for="field in fields" :key="field.value" :value="field.value">
|
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-1/3">
|
<!-- Operator -->
|
||||||
<Select v-model="filter.operator">
|
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||||
<SelectValue placeholder="Select Operator" />
|
<SelectValue placeholder="Operator" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem v-for="operator in getFieldOperators(filter.field)" :key="operator.value"
|
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
|
||||||
:value="operator.value">
|
{{ op }}
|
||||||
{{ operator.label }}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|||||||
53
frontend/src/components/common/SimpleTable.vue
Normal file
53
frontend/src/components/common/SimpleTable.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{{ header }}
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="relative px-6 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="(item, index) in data" :key="index">
|
||||||
|
<td v-for="key in keys" :key="key" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{{ item[key] }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Trash2 } from 'lucide-vue-next';
|
||||||
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
headers: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleteItem']);
|
||||||
|
|
||||||
|
function deleteItem(item) {
|
||||||
|
emit('deleteItem', item);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,18 +2,19 @@
|
|||||||
<div class="relative" v-if="conversationStore.messages.data">
|
<div class="relative" v-if="conversationStore.messages.data">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-4 border-b h-[47px] flex items-center justify-between shadow shadow-gray-100">
|
<div class="px-4 border-b h-[44px] flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3 text-sm">
|
<div class="flex items-center space-x-3 text-sm">
|
||||||
<div class="font-semibold">
|
<div class="font-medium">
|
||||||
{{ conversationStore.current.subject }}
|
{{ conversationStore.current.subject }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Badge variant="primary">
|
<div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
|
||||||
{{ conversationStore.current.status }}
|
<GalleryVerticalEnd size="14" class="text-secondary" />
|
||||||
</Badge>
|
<span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
|
||||||
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
|
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
|
||||||
@@ -37,13 +38,16 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
GalleryVerticalEnd,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import MessageList from '@/components/message/MessageList.vue'
|
import MessageList from '@/components/message/MessageList.vue'
|
||||||
import ReplyBox from './ReplyBox.vue'
|
import ReplyBox from './ReplyBox.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col">
|
<div class="h-screen flex flex-col">
|
||||||
<!-- Filters -->
|
|
||||||
<div class="shrink-0">
|
<div class="flex justify-between px-2 py-2 w-full">
|
||||||
<ConversationListFilters @updateFilters="handleUpdateFilters" />
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger class="cursor-pointer">
|
||||||
|
<Button variant="ghost">
|
||||||
|
{{ conversationStore.getListStatus }}
|
||||||
|
<ChevronDown class="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
|
||||||
|
@click="handleStatusChange(status)">
|
||||||
|
{{ status.label }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger class="cursor-pointer">
|
||||||
|
<Button variant="ghost">
|
||||||
|
{{ conversationStore.getListSortField }}
|
||||||
|
<ChevronDown class="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('started_first')">Started first</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('started_last')">Started last</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('waiting_longest')">Waiting longest</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('next_sla_target')">Next SLA target</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="handleSortChange('priority_first')">Priority first</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty list -->
|
<!-- Empty -->
|
||||||
<EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
|
<EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
|
||||||
message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
|
message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
|
||||||
|
|
||||||
|
|
||||||
<!-- List -->
|
<!-- List -->
|
||||||
<div class="flex-grow overflow-y-auto">
|
<div class="flex-grow overflow-y-auto">
|
||||||
<EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
|
<EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
|
||||||
@@ -17,12 +46,15 @@
|
|||||||
|
|
||||||
<!-- Items -->
|
<!-- Items -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
|
<div class="space-y-5 px-2">
|
||||||
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
|
<ConversationListItem class="mt-2" :conversation="conversation"
|
||||||
: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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
|
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
|
||||||
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
|
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
|
||||||
@click="router.push('/conversations/' + conversation.uuid)">
|
@click="router.push('/conversations/' + conversation.uuid)">
|
||||||
|
|
||||||
<div class="pl-3">
|
<div class="pl-3">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-3 w-full border-b pb-2">
|
<div class="ml-3 w-full pb-2">
|
||||||
<div class="flex justify-between pt-2 pr-3">
|
<div class="flex justify-between pt-2 pr-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-600 flex gap-x-1">
|
<p class="text-xs text-gray-600 flex gap-x-1">
|
||||||
@@ -42,6 +42,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex space-x-2 mt-2">
|
||||||
|
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
|
||||||
|
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,6 +56,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { formatTime } from '@/utils/datetime'
|
import { formatTime } from '@/utils/datetime'
|
||||||
import { Mail, CheckCheck } from 'lucide-vue-next'
|
import { Mail, CheckCheck } from 'lucide-vue-next'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 mb-5">
|
||||||
|
<p class="font-medium">SLA policy</p>
|
||||||
|
<p v-if="conversation.sla_policy_name">
|
||||||
|
{{ conversation.sla_policy_name }}
|
||||||
|
</p>
|
||||||
|
<p v-else>-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 mb-5">
|
<div class="flex flex-col gap-1 mb-5">
|
||||||
<p class="font-medium">Reference number</p>
|
<p class="font-medium">Reference number</p>
|
||||||
<p>
|
<p>
|
||||||
#{{ conversation.reference_number }}
|
{{ conversation.reference_number }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 mb-5">
|
<div class="flex flex-col gap-1 mb-5">
|
||||||
@@ -14,6 +23,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1 mb-5">
|
<div class="flex flex-col gap-1 mb-5">
|
||||||
<p class="font-medium">First reply at</p>
|
<p class="font-medium">First reply at</p>
|
||||||
|
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" />
|
||||||
<p v-if="conversation.first_reply_at">
|
<p v-if="conversation.first_reply_at">
|
||||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -22,6 +32,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1 mb-5">
|
<div class="flex flex-col gap-1 mb-5">
|
||||||
<p class="font-medium">Resolved at</p>
|
<p class="font-medium">Resolved at</p>
|
||||||
|
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
|
||||||
<p v-if="conversation.resolved_at">
|
<p v-if="conversation.resolved_at">
|
||||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -35,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>
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex gap-x-5">
|
<div class="flex">
|
||||||
<Card class="w-1/6 box" v-for="(value, key) in counts" :key="key">
|
<div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5">
|
||||||
<CardHeader>
|
<div class="flex items-center space-x-2">
|
||||||
<CardTitle class="text-2xl">
|
<p class="text-2xl">{{title}}</p>
|
||||||
{{ value }}
|
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||||
</CardTitle>
|
<span class="blinking-dot"></span>
|
||||||
<CardDescription>
|
<strong class="uppercase tracking-wider">Live</strong>
|
||||||
{{ labels[key] }}
|
</div>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div class="flex">
|
||||||
</Card>
|
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
|
||||||
|
<span class="text-muted-foreground">{{ labels[key] }}</span>
|
||||||
|
<span class="text-2xl font-medium">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
counts: {
|
counts: { type: Object, required: true },
|
||||||
type: Object,
|
labels: { type: Object, required: true },
|
||||||
required: true
|
title: { type: String, required: true }
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
|
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-3xl space-y-1">
|
<span class="font-medium text-xl space-y-1">
|
||||||
<p>Hi, {{ userStore.getFullName }}</p>
|
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
|
||||||
<p class="text-sm-muted">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
|
<p class="text-muted-foreground text-lg">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user