mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 21:13:47 +00:00
feat: Macros, macros not completely replace canned responses as they do the same job as canned repsonse.
- tiptap editor fixes for hardlines. - set app font to jakarta, fix shadcn components having a different font. - UI improvements on /admin - UI improvements on inbox tab.
This commit is contained in:
@@ -20,7 +20,7 @@ Self-hosted 100% open-source support desk. Single binary with minimal dependenci
|
||||
| **SLA** | Configure and manage service level agreements. |
|
||||
| **CSAT** | Measure customer satisfaction with post-interaction surveys. |
|
||||
| **Reports** | Gain insights and analyze support performance, with complete freedom to integrate analytics tools like Metabase for generating custom reports. |
|
||||
| **Canned Responses** | Save and reuse common replies for efficiency. |
|
||||
| **Macros** | Save and reuse common replies and common actions for effciency |
|
||||
| **Auto Assignment** | Automatically assign tickets to agents based on defined rules. |
|
||||
| **Snooze Conversations** | Temporarily pause conversations and set reminders to revisit them later. |
|
||||
| **Automation Rules** | Define rules to automate workflows on conversation creation, updates, or hourly triggers. |
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/cannedresp/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func handleGetCannedResponses(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
c []cmodels.CannedResponse
|
||||
)
|
||||
|
||||
c, err := app.cannedResp.GetAll()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(c)
|
||||
}
|
||||
|
||||
func handleCreateCannedResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cannedResponse = cmodels.CannedResponse{}
|
||||
)
|
||||
|
||||
if err := r.Decode(&cannedResponse, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Title == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err := app.cannedResp.Create(cannedResponse.Title, cannedResponse.Content)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(cannedResponse)
|
||||
}
|
||||
|
||||
func handleDeleteCannedResponse(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 canned response `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.cannedResp.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleUpdateCannedResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cannedResponse = cmodels.CannedResponse{}
|
||||
)
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid canned response `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&cannedResponse, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Title == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err = app.cannedResp.Update(id, cannedResponse.Title, cannedResponse.Content); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(cannedResponse)
|
||||
}
|
||||
@@ -481,23 +481,20 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
return r.SendEnvelope("Status updated successfully")
|
||||
}
|
||||
|
||||
// handleAddConversationTags adds tags to a conversation.
|
||||
func handleAddConversationTags(r *fastglue.Request) error {
|
||||
// handleUpdateConversationtags updates conversation tags.
|
||||
func handleUpdateConversationtags(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
tagIDs = []int{}
|
||||
tagJSON = r.RequestCtx.PostArgs().Peek("tag_ids")
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
app = r.Context.(*App)
|
||||
tagNames = []string{}
|
||||
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
// Parse tag IDs from JSON
|
||||
err := json.Unmarshal(tagJSON, &tagIDs)
|
||||
if err != nil {
|
||||
app.lo.Error("unmarshalling tag ids", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
|
||||
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
|
||||
app.lo.Error("error unmarshalling tags JSON", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -516,7 +513,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
|
||||
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, tagNames); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Tags added successfully")
|
||||
|
||||
@@ -58,7 +58,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
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/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
|
||||
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
|
||||
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
|
||||
@@ -86,11 +86,13 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// Media.
|
||||
g.POST("/api/v1/media", auth(handleMediaUpload))
|
||||
|
||||
// Canned response.
|
||||
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
|
||||
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses:manage"))
|
||||
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses:manage"))
|
||||
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses:manage"))
|
||||
// Macros.
|
||||
g.GET("/api/v1/macros", auth(handleGetMacros))
|
||||
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
|
||||
g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage"))
|
||||
g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage"))
|
||||
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
|
||||
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
|
||||
|
||||
// User.
|
||||
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||
|
||||
27
cmd/init.go
27
cmd/init.go
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/autoassigner"
|
||||
"github.com/abhinavxd/libredesk/internal/automation"
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
"github.com/abhinavxd/libredesk/internal/cannedresp"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/priority"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/status"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
"github.com/abhinavxd/libredesk/internal/macro"
|
||||
"github.com/abhinavxd/libredesk/internal/media"
|
||||
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
|
||||
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
|
||||
@@ -196,6 +196,7 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
|
||||
// initConversations inits conversation manager.
|
||||
func initConversations(
|
||||
i18n *i18n.I18n,
|
||||
sla *sla.Manager,
|
||||
status *status.Manager,
|
||||
priority *priority.Manager,
|
||||
hub *ws.Hub,
|
||||
@@ -208,7 +209,7 @@ func initConversations(
|
||||
automationEngine *automation.Engine,
|
||||
template *tmpl.Manager,
|
||||
) *conversation.Manager {
|
||||
c, err := conversation.New(hub, i18n, notif, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||
DB: db,
|
||||
Lo: initLogger("conversation_manager"),
|
||||
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
||||
@@ -246,17 +247,17 @@ func initView(db *sqlx.DB) *view.Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// initCannedResponse inits canned response manager.
|
||||
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
||||
var lo = initLogger("canned-response")
|
||||
c, err := cannedresp.New(cannedresp.Opts{
|
||||
// initMacro inits macro manager.
|
||||
func initMacro(db *sqlx.DB) *macro.Manager {
|
||||
var lo = initLogger("macro")
|
||||
m, err := macro.New(macro.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing canned responses manager: %v", err)
|
||||
log.Fatalf("error initializing macro manager: %v", err)
|
||||
}
|
||||
return c
|
||||
return m
|
||||
}
|
||||
|
||||
// initBusinessHours inits business hours manager.
|
||||
@@ -414,15 +415,9 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
|
||||
}
|
||||
|
||||
// initAutomationEngine initializes the automation engine.
|
||||
func initAutomationEngine(db *sqlx.DB, userManager *user.Manager) *automation.Engine {
|
||||
func initAutomationEngine(db *sqlx.DB) *automation.Engine {
|
||||
var lo = initLogger("automation_engine")
|
||||
|
||||
systemUser, err := userManager.GetSystemUser()
|
||||
if err != nil {
|
||||
log.Fatalf("error fetching system user: %v", err)
|
||||
}
|
||||
|
||||
engine, err := automation.New(systemUser, automation.Opts{
|
||||
engine, err := automation.New(automation.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
|
||||
306
cmd/macro.go
Normal file
306
cmd/macro.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
autoModels "github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/macro/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetMacros returns all macros.
|
||||
func handleGetMacros(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
macros, err := app.macro.GetAll()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
for i, m := range macros {
|
||||
var actions []autoModels.RuleAction
|
||||
if err := json.Unmarshal(m.Actions, &actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
|
||||
}
|
||||
// Set display values for actions as the value field can contain DB IDs
|
||||
if err := setDisplayValues(app, actions); err != nil {
|
||||
app.lo.Warn("error setting display values", "error", err)
|
||||
}
|
||||
if macros[i].Actions, err = json.Marshal(actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(macros)
|
||||
}
|
||||
|
||||
// handleGetMacro returns a macro.
|
||||
func handleGetMacro(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 macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
macro, err := app.macro.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
var actions []autoModels.RuleAction
|
||||
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
|
||||
}
|
||||
// Set display values for actions as the value field can contain DB IDs
|
||||
if err := setDisplayValues(app, actions); err != nil {
|
||||
app.lo.Warn("error setting display values", "error", err)
|
||||
}
|
||||
if macro.Actions, err = json.Marshal(actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleCreateMacro creates new macro.
|
||||
func handleCreateMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
macro = models.Macro{}
|
||||
)
|
||||
|
||||
if err := r.Decode(¯o, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateMacro(macro); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleUpdateMacro updates a macro.
|
||||
func handleUpdateMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
macro = models.Macro{}
|
||||
)
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(¯o, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateMacro(macro); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleDeleteMacro deletes macro.
|
||||
func handleDeleteMacro(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 macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.macro.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("Macro deleted successfully")
|
||||
}
|
||||
|
||||
// handleApplyMacro applies macro actions to a conversation.
|
||||
func handleApplyMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
incomingActions = []autoModels.RuleAction{}
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
conversation, err := app.conversation.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
macro, err := app.macro.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Decode incoming actions.
|
||||
if err := r.Decode(&incomingActions, "json"); err != nil {
|
||||
app.lo.Error("error unmashalling incoming actions", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Make sure no duplicate action types are present.
|
||||
actionTypes := make(map[string]bool, len(incomingActions))
|
||||
for _, act := range incomingActions {
|
||||
if actionTypes[act.Type] {
|
||||
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate action types not allowed", nil, envelope.InputError)
|
||||
}
|
||||
actionTypes[act.Type] = true
|
||||
}
|
||||
|
||||
// Validate action permissions.
|
||||
for _, act := range incomingActions {
|
||||
if !isMacroActionAllowed(act.Type) {
|
||||
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
|
||||
}
|
||||
if !hasActionPermission(act.Type, user.Permissions) {
|
||||
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply actions.
|
||||
successCount := 0
|
||||
for _, act := range incomingActions {
|
||||
if err := app.conversation.ApplyAction(act, conversation, user); err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Increment usage count.
|
||||
app.macro.IncrementUsageCount(macro.ID)
|
||||
|
||||
if successCount < len(incomingActions) {
|
||||
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
|
||||
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
|
||||
})
|
||||
}
|
||||
|
||||
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
|
||||
"message": "Macro applied successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// hasActionPermission checks user permission for given action
|
||||
func hasActionPermission(action string, userPerms []string) bool {
|
||||
requiredPerm, exists := autoModels.ActionPermissions[action]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(userPerms, requiredPerm)
|
||||
}
|
||||
|
||||
// setDisplayValues sets display values for actions.
|
||||
func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
|
||||
getters := map[string]func(int) (string, error){
|
||||
autoModels.ActionAssignTeam: func(id int) (string, error) {
|
||||
t, err := app.team.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("team not found for macro action", "team_id", id)
|
||||
return "", err
|
||||
}
|
||||
return t.Name, nil
|
||||
},
|
||||
autoModels.ActionAssignUser: func(id int) (string, error) {
|
||||
u, err := app.user.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("user not found for macro action", "user_id", id)
|
||||
return "", err
|
||||
}
|
||||
return u.FullName(), nil
|
||||
},
|
||||
autoModels.ActionSetPriority: func(id int) (string, error) {
|
||||
p, err := app.priority.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("priority not found for macro action", "priority_id", id)
|
||||
return "", err
|
||||
}
|
||||
return p.Name, nil
|
||||
},
|
||||
autoModels.ActionSetStatus: func(id int) (string, error) {
|
||||
s, err := app.status.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("status not found for macro action", "status_id", id)
|
||||
return "", err
|
||||
}
|
||||
return s.Name, nil
|
||||
},
|
||||
}
|
||||
for i := range actions {
|
||||
actions[i].DisplayValue = []string{}
|
||||
if getter, ok := getters[actions[i].Type]; ok {
|
||||
id, _ := strconv.Atoi(actions[i].Value[0])
|
||||
if name, err := getter(id); err == nil {
|
||||
actions[i].DisplayValue = append(actions[i].DisplayValue, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMacro validates an incoming macro.
|
||||
func validateMacro(macro models.Macro) error {
|
||||
if macro.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
|
||||
}
|
||||
|
||||
var act []autoModels.RuleAction
|
||||
if err := json.Unmarshal(macro.Actions, &act); err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
|
||||
}
|
||||
for _, a := range act {
|
||||
if len(a.Value) == 0 {
|
||||
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMacroActionAllowed returns true if the action is allowed in a macro.
|
||||
func isMacroActionAllowed(action string) bool {
|
||||
switch action {
|
||||
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
|
||||
return false
|
||||
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
15
cmd/main.go
15
cmd/main.go
@@ -14,12 +14,12 @@ import (
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
"github.com/abhinavxd/libredesk/internal/colorlog"
|
||||
"github.com/abhinavxd/libredesk/internal/csat"
|
||||
"github.com/abhinavxd/libredesk/internal/macro"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
"github.com/abhinavxd/libredesk/internal/sla"
|
||||
"github.com/abhinavxd/libredesk/internal/view"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/automation"
|
||||
"github.com/abhinavxd/libredesk/internal/cannedresp"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/priority"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/status"
|
||||
@@ -67,7 +67,7 @@ type App struct {
|
||||
tag *tag.Manager
|
||||
inbox *inbox.Manager
|
||||
tmpl *template.Manager
|
||||
cannedResp *cannedresp.Manager
|
||||
macro *macro.Manager
|
||||
conversation *conversation.Manager
|
||||
automation *automation.Engine
|
||||
businessHours *businesshours.Manager
|
||||
@@ -149,15 +149,14 @@ func main() {
|
||||
businessHours = initBusinessHours(db)
|
||||
user = initUser(i18n, db)
|
||||
notifier = initNotifier(user)
|
||||
automation = initAutomationEngine(db, user)
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
conversation = initConversations(i18n, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
|
||||
// Set stores.
|
||||
wsHub.SetConversationStore(conversation)
|
||||
automation.SetConversationStore(conversation, sla)
|
||||
automation.SetConversationStore(conversation)
|
||||
|
||||
// Start inbox receivers.
|
||||
startInboxes(ctx, inbox, conversation)
|
||||
@@ -209,8 +208,8 @@ func main() {
|
||||
authz: initAuthz(),
|
||||
role: initRole(db),
|
||||
tag: initTag(db),
|
||||
macro: initMacro(db),
|
||||
ai: initAI(db),
|
||||
cannedResp: initCannedResponse(db),
|
||||
}
|
||||
|
||||
// Init fastglue and set app in ctx.
|
||||
@@ -223,7 +222,7 @@ func main() {
|
||||
initHandlers(g, wsHub)
|
||||
|
||||
s := &fasthttp.Server{
|
||||
Name: "server",
|
||||
Name: "libredesk",
|
||||
ReadTimeout: ko.MustDuration("app.server.read_timeout"),
|
||||
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
|
||||
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -20,10 +20,8 @@
|
||||
"@radix-icons/vue": "^1.0.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tanstack/vue-table": "^8.19.2",
|
||||
"@tiptap/extension-hard-break": "^2.11.0",
|
||||
"@tiptap/extension-image": "^2.5.9",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/extension-list-item": "^2.4.0",
|
||||
"@tiptap/extension-ordered-list": "^2.4.0",
|
||||
"@tiptap/extension-placeholder": "^2.4.0",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
|
||||
6
frontend/pnpm-lock.yaml
generated
6
frontend/pnpm-lock.yaml
generated
@@ -23,18 +23,12 @@ importers:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.19.2
|
||||
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
|
||||
'@tiptap/extension-hard-break':
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^2.5.9
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^2.9.1
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
|
||||
'@tiptap/extension-list-item':
|
||||
specifier: ^2.4.0
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-ordered-list':
|
||||
specifier: ^2.4.0
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<Sidebar
|
||||
:isLoading="false"
|
||||
:open="sidebarOpen"
|
||||
@@ -10,30 +9,21 @@
|
||||
@edit-view="editView"
|
||||
@delete-view="deleteView"
|
||||
>
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||
<ResizableHandle id="resize-handle-1" />
|
||||
<ResizablePanel id="resize-panel-2">
|
||||
<div class="w-full h-screen">
|
||||
<PageHeader />
|
||||
<RouterView />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||
</ResizablePanelGroup>
|
||||
<div class="w-full h-screen border-l">
|
||||
<PageHeader />
|
||||
<RouterView />
|
||||
</div>
|
||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||
</Sidebar>
|
||||
<div class="font-jakarta">
|
||||
<Command />
|
||||
</div>
|
||||
<Command />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { initWS } from '@/websocket.js'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
@@ -42,11 +32,13 @@ import { useInboxStore } from '@/stores/inbox'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import PageHeader from './components/common/PageHeader.vue'
|
||||
import ViewForm from '@/components/ViewForm.vue'
|
||||
import api from '@/api'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
import Command from '@/components/command/command.vue'
|
||||
import Command from '@/components/command/CommandBox.vue'
|
||||
|
||||
const { toast } = useToast()
|
||||
const emitter = useEmitter()
|
||||
@@ -57,7 +49,8 @@ const usersStore = useUsersStore()
|
||||
const teamStore = useTeamStore()
|
||||
const inboxStore = useInboxStore()
|
||||
const slaStore = useSlaStore()
|
||||
const router = useRouter()
|
||||
const macroStore = useMacroStore()
|
||||
const tagStore = useTagStore()
|
||||
const userViews = ref([])
|
||||
const view = ref({})
|
||||
const openCreateViewForm = ref(false)
|
||||
@@ -66,8 +59,6 @@ initWS()
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
listenViewRefresh()
|
||||
getCurrentUser()
|
||||
getUserViews()
|
||||
initStores()
|
||||
})
|
||||
|
||||
@@ -76,14 +67,19 @@ onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||
})
|
||||
|
||||
// initialize data stores
|
||||
const initStores = async () => {
|
||||
await Promise.all([
|
||||
await Promise.allSettled([
|
||||
userStore.getCurrentUser(),
|
||||
getUserViews(),
|
||||
conversationStore.fetchStatuses(),
|
||||
conversationStore.fetchPriorities(),
|
||||
usersStore.fetchUsers(),
|
||||
teamStore.fetchTeams(),
|
||||
inboxStore.fetchInboxes(),
|
||||
slaStore.fetchSlas()
|
||||
slaStore.fetchSlas(),
|
||||
macroStore.loadMacros(),
|
||||
tagStore.fetchTags()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -98,7 +94,6 @@ const deleteView = async (view) => {
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
variant: 'success',
|
||||
description: 'View deleted successfully'
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -123,14 +118,6 @@ const getUserViews = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentUser = () => {
|
||||
userStore.getCurrentUser().catch((err) => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<div class="font-inter">
|
||||
<div class="!font-jakarta">
|
||||
<RouterView />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -182,10 +182,24 @@ const sendMessage = (uuid, data) =>
|
||||
})
|
||||
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
|
||||
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
|
||||
const getCannedResponses = () => http.get('/api/v1/canned-responses')
|
||||
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
|
||||
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
|
||||
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
|
||||
const getAllMacros = () => http.get('/api/v1/macros')
|
||||
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
|
||||
const createMacro = (data) => http.post('/api/v1/macros', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
|
||||
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getTeamUnassignedConversations = (teamID, params) =>
|
||||
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
|
||||
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
|
||||
@@ -290,10 +304,12 @@ export default {
|
||||
getConversationMessages,
|
||||
getCurrentUser,
|
||||
getCurrentUserTeams,
|
||||
getCannedResponses,
|
||||
createCannedResponse,
|
||||
updateCannedResponse,
|
||||
deleteCannedResponse,
|
||||
getAllMacros,
|
||||
getMacro,
|
||||
createMacro,
|
||||
updateMacro,
|
||||
deleteMacro,
|
||||
applyMacro,
|
||||
updateCurrentUser,
|
||||
updateAssignee,
|
||||
updateConversationStatus,
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 1rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 100px;
|
||||
@apply bg-slate-50;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -20,7 +26,6 @@ body {
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<Dialog :open="openDialog" @update:open="openDialog = false">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogHeader class="space-y-1">
|
||||
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
|
||||
<DialogDescription
|
||||
>Views let you create custom filters and save them for reuse.</DialogDescription
|
||||
>
|
||||
<DialogDescription>
|
||||
Views let you create custom filters and save them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="grid gap-4 py-4">
|
||||
@@ -50,7 +50,7 @@
|
||||
<FormControl>
|
||||
<Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Add filters to customize view.</FormDescription>
|
||||
<FormDescription>Add multiple filters to customize view.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
} 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 Filter from '@/components/common/FilterBuilder.vue'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-5">
|
||||
<div class="w-48">
|
||||
|
||||
<!-- Type -->
|
||||
<Select
|
||||
v-model="action.type"
|
||||
@update:modelValue="(value) => handleFieldChange(value, index)"
|
||||
@@ -31,12 +33,24 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Value -->
|
||||
<div
|
||||
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
|
||||
class="w-full"
|
||||
>
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames"
|
||||
placeholder="Select tag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-48"
|
||||
v-if="action.type && conversationActions[action.type]?.type === 'select'"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="action.value"
|
||||
v-model="action.value[0]"
|
||||
:items="conversationActions[action.type]?.options"
|
||||
placeholder="Select"
|
||||
@select="handleValueChange($event, index)"
|
||||
@@ -100,7 +114,7 @@
|
||||
>
|
||||
<QuillEditor
|
||||
theme="snow"
|
||||
v-model:content="action.value"
|
||||
v-model:content="action.value[0]"
|
||||
contentType="html"
|
||||
@update:content="(value) => handleValueChange(value, index)"
|
||||
class="h-32 mb-12"
|
||||
@@ -119,6 +133,7 @@
|
||||
import { toRefs } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -131,6 +146,7 @@ import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -142,10 +158,11 @@ const props = defineProps({
|
||||
|
||||
const { actions } = toRefs(props)
|
||||
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
|
||||
const tagsStore = useTagStore()
|
||||
const { conversationActions } = useConversationFilters()
|
||||
|
||||
const handleFieldChange = (value, index) => {
|
||||
actions.value[index].value = ''
|
||||
actions.value[index].value = []
|
||||
actions.value[index].type = value
|
||||
emitUpdate(index)
|
||||
}
|
||||
@@ -154,7 +171,7 @@ const handleValueChange = (value, index) => {
|
||||
if (typeof value === 'object') {
|
||||
value = value.value
|
||||
}
|
||||
actions.value[index].value = value
|
||||
actions.value[index].value = [value]
|
||||
emitUpdate(index)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/automations'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="ml-auto">
|
||||
@@ -11,7 +10,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/business-hours'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
@@ -16,7 +14,6 @@
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button class="ml-auto">New canned response</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New canned response</DialogTitle>
|
||||
<DialogDescription>Set title and content, click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="cannedResponses" />
|
||||
</div>
|
||||
</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 { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import CannedResponsesForm from './CannedResponsesForm.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const formLoading = ref(false)
|
||||
const cannedResponses = ref([])
|
||||
const emit = useEmitter()
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getCannedResponses()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
form.setValues({
|
||||
title: "",
|
||||
content: "",
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'canned_responses') getCannedResponses()
|
||||
}
|
||||
|
||||
const getCannedResponses = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.createCannedResponse(values)
|
||||
dialogOpen.value = false
|
||||
getCannedResponses()
|
||||
} catch (error) {
|
||||
console.error('Failed to create canned response:', error)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<form class="space-y-6">
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription></FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleInput }" name="content">
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<QuillEditor theme="snow" v-model:content="componentField.modelValue" contentType="html"
|
||||
@update:content="handleInput"></QuillEditor>
|
||||
</FormControl>
|
||||
<FormDescription></FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Form submit button slot -->
|
||||
<slot name="footer"></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
</script>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<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>
|
||||
<DialogTrigger as-child>
|
||||
<DropdownMenuItem> Edit </DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DropdownMenuItem @click="deleteCannedResponse"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DialogContent class="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit canned response</DialogTitle>
|
||||
<DialogDescription>Edit title and content, click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, ref } from 'vue'
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import CannedResponsesForm from './CannedResponsesForm.vue'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
import { formSchema } from './formSchema.js'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const emit = useEmitter()
|
||||
|
||||
const props = defineProps({
|
||||
cannedResponse: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
await api.updateCannedResponse(props.cannedResponse.id, values)
|
||||
dialogOpen.value = false
|
||||
emitRefreshCannedResponseList()
|
||||
})
|
||||
|
||||
const deleteCannedResponse = async () => {
|
||||
await api.deleteCannedResponse(props.cannedResponse.id)
|
||||
dialogOpen.value = false
|
||||
emitRefreshCannedResponseList()
|
||||
}
|
||||
|
||||
const emitRefreshCannedResponseList = () => {
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'canned_responses'
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.cannedResponse,
|
||||
(newValues) => {
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
title: z
|
||||
.string({
|
||||
required_error: 'Title is required.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Title must be at least 1 character.'
|
||||
}),
|
||||
content: z
|
||||
.string({
|
||||
required_error: 'Content is required.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Content must be atleast 3 characters.'
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<MacroForm
|
||||
:submitForm="onSubmit"
|
||||
:isLoading="formLoading"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
const formLoading = ref(false)
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
||||
{ path: '#', label: 'New macro' }
|
||||
]
|
||||
|
||||
const onSubmit = (values) => {
|
||||
createMacro(values)
|
||||
}
|
||||
|
||||
const createMacro = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.createMacro(values)
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
variant: 'success',
|
||||
description: 'Macro created successfully'
|
||||
})
|
||||
router.push('/admin/conversations/macros')
|
||||
} catch (error) {
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<MacroForm :initialValues="macro" :submitForm="submitForm" :isLoading="formLoading" v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import api from '@/api'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const macro = ref({})
|
||||
const isLoading = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const emitter = useEmitter()
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
||||
{ path: '#', label: 'Edit macro' }
|
||||
]
|
||||
|
||||
const submitForm = (values) => {
|
||||
updateMacro(values)
|
||||
}
|
||||
|
||||
const updateMacro = async (payload) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.updateMacro(macro.value.id, payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
description: 'Macro updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not update macro',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getMacro(props.id)
|
||||
macro.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
195
frontend/src/components/admin/conversation/macros/MacroForm.vue
Normal file
195
frontend/src/components/admin/conversation/macros/MacroForm.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Macro name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="message_content">
|
||||
<FormItem>
|
||||
<FormLabel>Response to be sent when macro is used</FormLabel>
|
||||
<FormControl>
|
||||
<QuillEditor
|
||||
v-model:content="componentField.modelValue"
|
||||
placeholder="Add a response (optional)"
|
||||
theme="snow"
|
||||
contentType="html"
|
||||
class="h-32 mb-12"
|
||||
@update:content="(value) => componentField.onChange(value)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="visibility">
|
||||
<FormItem>
|
||||
<FormLabel>Visibility</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="team">Team</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
|
||||
<FormItem>
|
||||
<FormLabel>Team</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox v-bind="componentField" :items="tStore.options" placeholder="Select team">
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
<span v-else>Select team</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox v-bind="componentField" :items="uStore.options" placeholder="Select user">
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="selected.avatar_url" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>Select user</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="actions">
|
||||
<FormItem>
|
||||
<FormLabel> Actions </FormLabel>
|
||||
<FormControl>
|
||||
<ActionBuilder v-bind="componentField" :config="actionConfig" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import ActionBuilder from '@/components/common/ActionBuilder.vue'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
|
||||
const { conversationActions } = useConversationFilters()
|
||||
const formLoading = ref(false)
|
||||
const uStore = useUsersStore()
|
||||
const tStore = useTeamStore()
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: 'Submit'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const actionConfig = ref({
|
||||
actions: conversationActions,
|
||||
typePlaceholder: 'Select action type',
|
||||
valuePlaceholder: 'Select value',
|
||||
addButtonText: 'Add new action'
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (Object.keys(newValues).length === 0) return
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
65
frontend/src/components/admin/conversation/macros/Macros.vue
Normal file
65
frontend/src/components/admin/conversation/macros/Macros.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div v-if="router.currentRoute.value.path === '/admin/conversations/macros'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="toggleForm"> New macro </Button>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable v-else :columns="columns" :data="macros" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</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 { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const formLoading = ref(false)
|
||||
const macros = ref([])
|
||||
const emit = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
getMacros()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const toggleForm = () => {
|
||||
router.push('/admin/conversations/macros/new')
|
||||
}
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'macros') getMacros()
|
||||
}
|
||||
|
||||
const getMacros = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getAllMacros()
|
||||
macros.value = resp.data.data
|
||||
} catch (error) {
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,12 +4,30 @@ import { format } from 'date-fns'
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
accessorKey: 'name',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Title')
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('title'))
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Visibility')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, row.getValue('visibility'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'usage_count',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Usage')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, row.getValue('usage_count'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -34,12 +52,12 @@ export const columns = [
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const cannedResponse = row.original
|
||||
const macro = row.original
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'relative' },
|
||||
h(dropdown, {
|
||||
cannedResponse
|
||||
macro
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<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="editMacro">Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="deleteMacro">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<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 '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
const props = defineProps({
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const deleteMacro = async () => {
|
||||
await api.deleteMacro(props.macro.id)
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'macros'
|
||||
})
|
||||
}
|
||||
|
||||
const editMacro = () => {
|
||||
router.push({ path: `/admin/conversations/macros/${props.macro.id}/edit` })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
const actionSchema = z.array(
|
||||
z.object({
|
||||
type: z.string().min(1, 'Action type required'),
|
||||
value: z.array(z.string().min(1, 'Action value required')).min(1, 'Action value required'),
|
||||
})
|
||||
)
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string().min(1, 'Macro name is required'),
|
||||
message_content: z.string().optional(),
|
||||
actions: actionSchema,
|
||||
visibility: z.enum(['all', 'team', 'user']),
|
||||
team_id: z.string().nullable().optional(),
|
||||
user_id: z.string().nullable().optional(),
|
||||
})
|
||||
@@ -1,33 +1,30 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button class="ml-auto">New Status</Button>
|
||||
<Button class="ml-auto">New Status</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status</DialogTitle>
|
||||
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<StatusForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</StatusForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div>
|
||||
<DataTable :columns="columns" :data="statuses" />
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status</DialogTitle>
|
||||
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<StatusForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</StatusForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div>
|
||||
<DataTable :columns="columns" :data="statuses" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
@@ -27,7 +25,6 @@
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="tags" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
<div class="flex justify-center items-center flex-col w-8/12">
|
||||
<div class="flex justify-center items-center flex-col">
|
||||
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -242,7 +242,7 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not update settings',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/inboxes'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end w-full mb-4">
|
||||
@@ -15,7 +13,6 @@
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -50,7 +47,7 @@ const getInboxes = async () => {
|
||||
data.value = response.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch inboxes',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isGoDuration } from '@/utils/strings'
|
||||
export const formSchema = z.object({
|
||||
name: z.string().describe('Name').default(''),
|
||||
from: z.string().describe('From address').default(''),
|
||||
csat_enabled: z.boolean().describe('Enable CSAT'),
|
||||
csat_enabled: z.boolean().describe('Enable CSAT').optional(),
|
||||
imap: z
|
||||
.object({
|
||||
host: z.string().describe('Host').default('imap.gmail.com'),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="formLoading" class="mx-auto" />
|
||||
<NotificationsForm
|
||||
v-else
|
||||
:initial-values="initialValues"
|
||||
:submit-form="submitForm"
|
||||
:isLoading="formLoading"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -23,51 +23,53 @@ const formLoading = ref(false)
|
||||
const emitter = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
getNotificationSettings()
|
||||
getNotificationSettings()
|
||||
})
|
||||
|
||||
const getNotificationSettings = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getEmailNotificationSettings()
|
||||
initialValues.value = Object.fromEntries(
|
||||
Object.entries(resp.data.data).map(([key, value]) => [key.replace('notification.email.', ''), value])
|
||||
)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getEmailNotificationSettings()
|
||||
initialValues.value = Object.fromEntries(
|
||||
Object.entries(resp.data.data).map(([key, value]) => [
|
||||
key.replace('notification.email.', ''),
|
||||
value
|
||||
])
|
||||
)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const updatedValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => {
|
||||
if (key === 'password' && value.includes('•')) {
|
||||
return [`notification.email.${key}`, '']
|
||||
}
|
||||
return [`notification.email.${key}`, value]
|
||||
})
|
||||
);
|
||||
await api.updateEmailNotificationSettings(updatedValues)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: "Saved successfully"
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
try {
|
||||
formLoading.value = true
|
||||
const updatedValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => {
|
||||
if (key === 'password' && value.includes('•')) {
|
||||
return [`notification.email.${key}`, '']
|
||||
}
|
||||
return [`notification.email.${key}`, value]
|
||||
})
|
||||
)
|
||||
await api.updateEmailNotificationSettings(updatedValues)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: 'Saved successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,22 +1,19 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div>
|
||||
<Button @click="navigateToAddOIDC">New OIDC</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="oidc" v-else />
|
||||
<Button @click="navigateToAddOIDC">New OIDC</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="oidc" v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
<template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -37,29 +34,29 @@ const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
fetchAll()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'sla') fetchAll()
|
||||
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
|
||||
}
|
||||
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')
|
||||
router.push('/admin/sla/new')
|
||||
}
|
||||
</script>
|
||||
@@ -100,7 +100,7 @@ const permissions = ref([
|
||||
{ name: 'status:manage', label: 'Manage Conversation Statuses' },
|
||||
{ name: 'oidc:manage', label: 'Manage SSO Configuration' },
|
||||
{ name: 'tags:manage', label: 'Manage Tags' },
|
||||
{ name: 'canned_responses:manage', label: 'Manage Canned Responses' },
|
||||
{ name: 'macros:manage', label: 'Manage Macros' },
|
||||
{ name: 'users:manage', label: 'Manage Users' },
|
||||
{ name: 'teams:manage', label: 'Manage Teams' },
|
||||
{ name: 'automations:manage', label: 'Manage Automations' },
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<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>
|
||||
@@ -11,7 +9,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -23,21 +20,15 @@ import { handleHTTPError } from '@/utils/http'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import api from '@/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const emit = useEmitter()
|
||||
const router = useRouter()
|
||||
const roles = ref([])
|
||||
const isLoading = ref(false)
|
||||
const breadcrumbLinks = [
|
||||
|
||||
{ path: '#', label: 'Roles' }
|
||||
]
|
||||
|
||||
const getRoles = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddTeam"> New team </Button>
|
||||
</div>
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddTeam"> New team </Button>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -24,7 +21,6 @@ import { handleHTTPError } from '@/utils/http'
|
||||
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -33,11 +29,6 @@ import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const breadcrumbLinks = [
|
||||
|
||||
{ path: '/admin/teams/', label: 'Teams' }
|
||||
]
|
||||
|
||||
const emit = useEmitter()
|
||||
const router = useRouter()
|
||||
const data = ref([])
|
||||
|
||||
@@ -121,9 +121,9 @@ const roles = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [teamsResp, rolesResp] = await Promise.all([api.getTeams(), api.getRoles()])
|
||||
teams.value = teamsResp.data.data
|
||||
roles.value = rolesResp.data.data
|
||||
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
|
||||
teams.value = teamsResp.value.data.data
|
||||
roles.value = rolesResp.value.data.data
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<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>
|
||||
@@ -13,7 +11,6 @@
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -25,8 +22,6 @@ import { handleHTTPError } from '@/utils/http'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
@@ -36,10 +31,6 @@ const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
const data = ref([])
|
||||
const emit = useEmitter()
|
||||
const breadcrumbLinks = [
|
||||
|
||||
{ path: '#', label: 'Users' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
getData()
|
||||
@@ -55,7 +46,7 @@ const getData = async () => {
|
||||
data.value = response.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Uh oh! Could not fetch users.',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<template>
|
||||
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/templates'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
@@ -27,7 +25,6 @@
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
<template>
|
||||
<div class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer">
|
||||
<div v-for="attachment in attachments" :key="attachment.uuid"
|
||||
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]">
|
||||
<!-- Filename tooltip -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ getAttachmentName(attachment.filename) }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ attachment.filename }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div>
|
||||
{{ formatBytes(attachment.size) }}
|
||||
<div class="flex flex-wrap gap-2 px-2 py-1">
|
||||
<TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.uuid"
|
||||
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
|
||||
>
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<PaperclipIcon size="16" class="text-gray-500 group-hover:text-primary" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
|
||||
>
|
||||
{{ getAttachmentName(attachment.filename) }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="text-sm">{{ attachment.filename }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatBytes(attachment.size) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="onDelete(attachment.uuid)"
|
||||
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div @click="onDelete(attachment.uuid)">
|
||||
<X size="13" />
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatBytes } from '@/utils/file.js'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps({
|
||||
@@ -40,6 +53,24 @@ defineProps({
|
||||
})
|
||||
|
||||
const getAttachmentName = (name) => {
|
||||
return name.substring(0, 20)
|
||||
return name.length > 20 ? name.substring(0, 17) + '...' : name
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachment-list-move,
|
||||
.attachment-list-enter-active,
|
||||
.attachment-list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.attachment-list-enter-from,
|
||||
.attachment-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.attachment-list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
331
frontend/src/components/command/CommandBox.vue
Normal file
331
frontend/src/components/command/CommandBox.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<CommandDialog :open="open" @update:open="handleOpenChange">
|
||||
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
|
||||
<CommandList class="!min-h-[400px]">
|
||||
<CommandEmpty>
|
||||
<p class="text-muted-foreground">No command available</p>
|
||||
</CommandEmpty>
|
||||
|
||||
<!-- Commands requiring a conversation to be open -->
|
||||
<CommandGroup
|
||||
heading="Conversations"
|
||||
value="conversations"
|
||||
v-if="nestedCommand === null && conversationStore.current"
|
||||
>
|
||||
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
|
||||
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
|
||||
<CommandItem value="apply-macro" @select="setNestedCommand('apply-macro')">
|
||||
Apply macro
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
|
||||
<CommandItem value="1 hour" @select="handleSnooze(60)">1 hour</CommandItem>
|
||||
<CommandItem value="3 hours" @select="handleSnooze(180)">3 hours</CommandItem>
|
||||
<CommandItem value="6 hours" @select="handleSnooze(360)">6 hours</CommandItem>
|
||||
<CommandItem value="12 hours" @select="handleSnooze(720)">12 hours</CommandItem>
|
||||
<CommandItem value="1 day" @select="handleSnooze(1440)">1 day</CommandItem>
|
||||
<CommandItem value="2 days" @select="handleSnooze(2880)">2 days</CommandItem>
|
||||
<CommandItem value="pick date & time" @select="showCustomDialog">
|
||||
Pick date & time
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<!-- Macros -->
|
||||
<!-- TODO move to a separate component -->
|
||||
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
|
||||
<CommandGroup heading="Apply macro" class="pb-2">
|
||||
<div class="min-h-[400px] overflow-auto">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-4 border-r border-border/30 pr-2">
|
||||
<CommandItem
|
||||
v-for="(macro, index) in macroStore.macroOptions"
|
||||
:key="macro.value"
|
||||
:value="macro.label"
|
||||
:data-index="index"
|
||||
@select="handleApplyMacro(macro)"
|
||||
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
|
||||
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
|
||||
>
|
||||
<div class="flex items-center space-x-2 justify-start">
|
||||
<Zap :size="18" class="text-primary" />
|
||||
<span class="text-sm overflow">{{ macro.label }}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
|
||||
<div class="col-span-8 pl-2">
|
||||
<div class="space-y-3 text-xs">
|
||||
<div v-if="replyContent" class="space-y-1">
|
||||
<p class="text-xs font-semibold text-primary">Reply Preview</p>
|
||||
<div
|
||||
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
|
||||
v-html="replyContent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="otherActions.length > 0" class="space-y-1">
|
||||
<p class="text-xs font-semibold text-primary">Actions</p>
|
||||
<div class="space-y-1.5 max-w-sm">
|
||||
<div
|
||||
v-for="action in otherActions"
|
||||
:key="action.type"
|
||||
class="flex items-center gap-2 px-2 py-1.5 bg-muted/30 hover:bg-accent hover:text-accent-foreground rounded-md text-xs transition-all duration-200 group"
|
||||
>
|
||||
<div
|
||||
class="p-1 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-colors duration-200"
|
||||
>
|
||||
<User
|
||||
v-if="action.type === 'assign_user'"
|
||||
:size="10"
|
||||
class="shrink-0 text-primary"
|
||||
/>
|
||||
<Users
|
||||
v-else-if="action.type === 'assign_team'"
|
||||
:size="10"
|
||||
class="shrink-0 text-primary"
|
||||
/>
|
||||
<Pin
|
||||
v-else-if="action.type === 'set_status'"
|
||||
:size="10"
|
||||
class="shrink-0 text-primary"
|
||||
/>
|
||||
<Rocket
|
||||
v-else-if="action.type === 'set_priority'"
|
||||
:size="10"
|
||||
class="shrink-0 text-primary"
|
||||
/>
|
||||
<Tags
|
||||
v-else-if="action.type === 'set_tags'"
|
||||
:size="10"
|
||||
class="shrink-0 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate">{{ getActionLabel(action) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!replyContent && otherActions.length === 0"
|
||||
class="flex items-center justify-center h-20"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground italic">
|
||||
Select a macro to view details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
|
||||
<!-- Navigation -->
|
||||
<!-- TODO: Move to a separate component -->
|
||||
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
|
||||
<span><kbd>Enter</kbd> select</span>
|
||||
<span><kbd>↑</kbd>/<kbd>↓</kbd> navigate</span>
|
||||
<span><kbd>Esc</kbd> close</span>
|
||||
<span><kbd>Backspace</kbd> parent</span>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
|
||||
<!-- Date Picker for Custom Snooze -->
|
||||
<!-- TODO: Move to a separate component -->
|
||||
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pick Snooze Time</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" class="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ selectedDate ? selectedDate : 'Pick a date' }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar mode="single" v-model="selectedDate" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div class="grid gap-2">
|
||||
<Label>Time</Label>
|
||||
<Input type="time" v-model="selectedTime" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button @click="handleCustomSnooze">Snooze</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { CalendarIcon } from 'lucide-vue-next'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||
import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const macroStore = useMacroStore()
|
||||
|
||||
const open = ref(false)
|
||||
const emitter = useEmitter()
|
||||
const nestedCommand = ref(null)
|
||||
const showDatePicker = ref(false)
|
||||
const selectedDate = ref(null)
|
||||
const selectedTime = ref('12:00')
|
||||
|
||||
const keys = useMagicKeys()
|
||||
const cmdK = keys['meta+k']
|
||||
const ctrlK = keys['ctrl+k']
|
||||
const highlightedMacro = ref(null)
|
||||
|
||||
function handleApplyMacro(macro) {
|
||||
conversationStore.setMacro(macro)
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
const getActionLabel = computed(() => (action) => {
|
||||
const prefixes = {
|
||||
assign_user: 'Assign to user',
|
||||
assign_team: 'Assign to team',
|
||||
set_status: 'Set status',
|
||||
set_priority: 'Set priority',
|
||||
set_tags: 'Set tags'
|
||||
}
|
||||
return `${prefixes[action.type]}: ${action.display_value.length > 0 ? action.display_value.join(', ') : action.value.join(', ')}`
|
||||
})
|
||||
|
||||
const replyContent = computed(() => highlightedMacro.value?.message_content || '')
|
||||
|
||||
const otherActions = computed(
|
||||
() =>
|
||||
highlightedMacro.value?.actions?.filter(
|
||||
(a) => a.type !== 'send_private_note' && a.type !== 'send_reply'
|
||||
) || []
|
||||
)
|
||||
|
||||
function handleOpenChange() {
|
||||
if (!open.value) nestedCommand.value = null
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function setNestedCommand(command) {
|
||||
nestedCommand.value = command
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
|
||||
}
|
||||
|
||||
async function handleSnooze(minutes) {
|
||||
await conversationStore.snoozeConversation(formatDuration(minutes))
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
async function resolveConversation() {
|
||||
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
function showCustomDialog() {
|
||||
handleOpenChange()
|
||||
showDatePicker.value = true
|
||||
}
|
||||
|
||||
function closeDatePicker() {
|
||||
showDatePicker.value = false
|
||||
}
|
||||
|
||||
function handleCustomSnooze() {
|
||||
const [hours, minutes] = selectedTime.value.split(':')
|
||||
const snoozeDate = new Date(selectedDate.value)
|
||||
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
|
||||
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
|
||||
|
||||
if (diffMinutes <= 0) {
|
||||
alert('Select a future time')
|
||||
return
|
||||
}
|
||||
handleSnooze(diffMinutes)
|
||||
closeDatePicker()
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
function onInputKeydown(e) {
|
||||
if (e.key === 'Backspace') {
|
||||
const inputVal = e.target.value || ''
|
||||
if (!inputVal && nestedCommand.value !== null) {
|
||||
e.preventDefault()
|
||||
nestedCommand.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preventDefaultKey(event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
|
||||
setNestedCommand(command)
|
||||
open.value = true
|
||||
})
|
||||
window.addEventListener('keydown', preventDefaultKey)
|
||||
|
||||
watchHighlightedMacro()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
|
||||
window.removeEventListener('keydown', preventDefaultKey)
|
||||
})
|
||||
|
||||
watch([cmdK, ctrlK], ([mac, win]) => {
|
||||
if (mac || win) handleOpenChange()
|
||||
})
|
||||
|
||||
const watchHighlightedMacro = () => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const highlightedEl = document.querySelector('[data-highlighted]')?.getAttribute('data-index')
|
||||
highlightedMacro.value = highlightedEl ? macroStore.macroOptions[highlightedEl] : null
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<CommandDialog :open="open" @update:open="handleOpenChange">
|
||||
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<p class="text-muted-foreground">No command available</p>
|
||||
</CommandEmpty>
|
||||
|
||||
<!-- Commands requiring a conversation to be open -->
|
||||
<CommandGroup heading="Conversations" value="conversations"
|
||||
v-if="nestedCommand === null && conversationStore.current">
|
||||
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
|
||||
Snooze
|
||||
</CommandItem>
|
||||
<CommandItem value="conv-resolve" @select="resolveConversation">
|
||||
Resolve
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
|
||||
<CommandItem value="snooze-1h" @select="handleSnooze(60)">1 hour</CommandItem>
|
||||
<CommandItem value="snooze-3h" @select="handleSnooze(180)">3 hours</CommandItem>
|
||||
<CommandItem value="snooze-6h" @select="handleSnooze(360)">6 hours</CommandItem>
|
||||
<CommandItem value="snooze-12h" @select="handleSnooze(720)">12 hours</CommandItem>
|
||||
<CommandItem value="snooze-1d" @select="handleSnooze(1440)">1 day</CommandItem>
|
||||
<CommandItem value="snooze-2d" @select="handleSnooze(2880)">2 days</CommandItem>
|
||||
<CommandItem value="snooze-custom" @select="showCustomDialog">Pick date & time</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
|
||||
<span><kbd>Enter</kbd> select</span>
|
||||
<span><kbd>↑</kbd>/<kbd>↓</kbd> navigate</span>
|
||||
<span><kbd>Esc</kbd> close</span>
|
||||
<span><kbd>Backspace</kbd> parent</span>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pick Snooze Time</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" class="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ selectedDate ? selectedDate : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar mode="single" v-model="selectedDate" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div class="grid gap-2">
|
||||
<Label>Time</Label>
|
||||
<Input type="time" v-model="selectedTime" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button @click="handleCustomSnooze">Snooze</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { CalendarIcon } from 'lucide-vue-next'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const open = ref(false)
|
||||
const emitter = useEmitter()
|
||||
const nestedCommand = ref(null)
|
||||
const showDatePicker = ref(false)
|
||||
const selectedDate = ref(null)
|
||||
const selectedTime = ref('12:00')
|
||||
|
||||
const keys = useMagicKeys()
|
||||
const cmdK = keys['meta+k']
|
||||
const ctrlK = keys['ctrl+k']
|
||||
|
||||
function handleOpenChange () {
|
||||
if (!open.value) nestedCommand.value = null
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function setNestedCommand (command) {
|
||||
nestedCommand.value = command
|
||||
}
|
||||
|
||||
function formatDuration (minutes) {
|
||||
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
|
||||
}
|
||||
|
||||
async function handleSnooze (minutes) {
|
||||
await conversationStore.snoozeConversation(formatDuration(minutes))
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
async function resolveConversation () {
|
||||
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
function showCustomDialog () {
|
||||
handleOpenChange()
|
||||
showDatePicker.value = true
|
||||
}
|
||||
|
||||
function closeDatePicker () {
|
||||
showDatePicker.value = false
|
||||
}
|
||||
|
||||
function handleCustomSnooze () {
|
||||
const [hours, minutes] = selectedTime.value.split(':')
|
||||
const snoozeDate = new Date(selectedDate.value)
|
||||
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
|
||||
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
|
||||
|
||||
if (diffMinutes <= 0) {
|
||||
alert('Select a future time')
|
||||
return
|
||||
}
|
||||
handleSnooze(diffMinutes)
|
||||
closeDatePicker()
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
function onInputKeydown (e) {
|
||||
if (e.key === 'Backspace') {
|
||||
const inputVal = e.target.value || ''
|
||||
if (!inputVal && nestedCommand.value !== null) {
|
||||
e.preventDefault()
|
||||
nestedCommand.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preventDefaultKey (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
|
||||
setNestedCommand(command)
|
||||
open.value = true
|
||||
})
|
||||
window.addEventListener('keydown', preventDefaultKey)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
|
||||
window.removeEventListener('keydown', preventDefaultKey)
|
||||
})
|
||||
|
||||
watch([cmdK, ctrlK], ([mac, win]) => {
|
||||
if (mac || win) handleOpenChange()
|
||||
})
|
||||
</script>
|
||||
186
frontend/src/components/common/ActionBuilder.vue
Normal file
186
frontend/src/components/common/ActionBuilder.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="space-y-5 rounded-lg">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(action, index) in model" :key="index" class="space-y-5">
|
||||
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-5">
|
||||
<div class="w-48">
|
||||
<Select
|
||||
v-model="action.type"
|
||||
@update:modelValue="(value) => updateField(value, index)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="config.typePlaceholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="(actionConfig, key) in config.actions"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ actionConfig.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="action.type && config.actions[action.type]?.type === 'select'"
|
||||
class="w-48"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="action.value[0]"
|
||||
:items="config.actions[action.type].options"
|
||||
:placeholder="config.valuePlaceholder"
|
||||
@update:modelValue="(value) => updateValue(value, index)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="action.type === 'assign_user'">
|
||||
<div class="flex items-center flex-1 gap-2 ml-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback
|
||||
>{{ item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="action.type === 'assign_team'">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="action.type === 'assign_user'">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage
|
||||
:src="selected.avatar_url"
|
||||
:alt="selected.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>{{
|
||||
selected.label.slice(0, 2).toUpperCase()
|
||||
}}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>Select user</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="action.type === 'assign_team'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
<span v-else>Select team</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selected">
|
||||
{{ selected.label }}
|
||||
</div>
|
||||
<div v-else>Select</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<X class="cursor-pointer w-4" @click="remove(index)" />
|
||||
</div>
|
||||
|
||||
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames"
|
||||
placeholder="Select tag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
|
||||
class="pl-0 shadow"
|
||||
>
|
||||
<QuillEditor
|
||||
v-model:content="action.value[0]"
|
||||
theme="snow"
|
||||
contentType="html"
|
||||
@update:content="(value) => updateValue(value, index)"
|
||||
class="h-32 mb-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
|
||||
const model = defineModel({
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
})
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const tagsStore = useTagStore()
|
||||
|
||||
const updateField = (value, index) => {
|
||||
const newModel = [...model.value]
|
||||
newModel[index] = { type: value, value: [] }
|
||||
model.value = newModel
|
||||
}
|
||||
|
||||
const updateValue = (value, index) => {
|
||||
const newModel = [...model.value]
|
||||
newModel[index] = {
|
||||
...newModel[index],
|
||||
value: [value?.value ?? value]
|
||||
}
|
||||
model.value = newModel
|
||||
}
|
||||
|
||||
const remove = (index) => {
|
||||
model.value = model.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const add = () => {
|
||||
model.value = [...model.value, { type: '', value: [] }]
|
||||
}
|
||||
</script>
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
|
||||
<div class="grid grid-cols-3 gap-2 w-full">
|
||||
<!-- Field -->
|
||||
<Select v-model="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Operator -->
|
||||
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Value -->
|
||||
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<Select v-if="getFieldOptions(modelFilter).length > 0" v-model="modelFilter.value">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in getFieldOptions(modelFilter)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-else v-model="modelFilter.value" class="bg-transparent hover:bg-slate-100" placeholder="Value"
|
||||
type="text" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
|
||||
<X class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3">
|
||||
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
|
||||
<Plus class="w-3 h-3 mr-1" /> Add filter
|
||||
</Button>
|
||||
<div class="flex gap-2" v-if="showButtons">
|
||||
<Button variant="ghost" @click="clearFilters">Reset</Button>
|
||||
<Button @click="applyFilters">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
showButtons: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['apply', 'clear'])
|
||||
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
|
||||
|
||||
const createFilter = () => ({ field: '', operator: '', value: '' })
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value.length === 0) {
|
||||
modelValue.value.push(createFilter())
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
modelValue.value = []
|
||||
})
|
||||
|
||||
const getModel = (field) => {
|
||||
const fieldConfig = props.fields.find(f => f.field === field)
|
||||
return fieldConfig?.model || ''
|
||||
}
|
||||
watch(() => modelValue.value, (filters) => {
|
||||
filters.forEach(filter => {
|
||||
if (filter.field && !filter.model) {
|
||||
filter.model = getModel(filter.field)
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const addFilter = () => modelValue.value.push(createFilter())
|
||||
const removeFilter = (index) => modelValue.value.splice(index, 1)
|
||||
const applyFilters = () => emit('apply', validFilters.value)
|
||||
const clearFilters = () => {
|
||||
modelValue.value = []
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const validFilters = computed(() => {
|
||||
return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
|
||||
})
|
||||
|
||||
const getFieldOptions = (fieldValue) => {
|
||||
const field = props.fields.find(f => f.field === fieldValue.field)
|
||||
return field?.options || []
|
||||
}
|
||||
|
||||
const getFieldOperators = (modelFilter) => {
|
||||
const field = props.fields.find(f => f.field === modelFilter.field)
|
||||
return field?.operators || []
|
||||
}
|
||||
</script>
|
||||
207
frontend/src/components/common/FilterBuilder.vue
Normal file
207
frontend/src/components/common/FilterBuilder.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(modelFilter, index) in modelValue"
|
||||
:key="index"
|
||||
class="group flex items-center gap-3"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-2 w-full">
|
||||
<!-- Field -->
|
||||
<Select v-model="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Operator -->
|
||||
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Value -->
|
||||
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<ComboBox
|
||||
v-if="getFieldOptions(modelFilter).length > 0"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
placeholder="Select"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="modelFilter.field === 'assigned_user_id'">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar class="w-6 h-6">
|
||||
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modelFilter.field === 'assigned_team_id'">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="modelFilter.field === 'assigned_user_id'">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-1">
|
||||
<Avatar class="w-6 h-6">
|
||||
<AvatarImage :src="selected.avatar_url" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{
|
||||
selected.label.slice(0, 2).toUpperCase()
|
||||
}}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>Select user</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modelFilter.field === 'assigned_team_id'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
<span v-else>Select team</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selected">
|
||||
{{ selected.label }}
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
<Input
|
||||
v-else
|
||||
v-model="modelFilter.value"
|
||||
class="bg-transparent hover:bg-slate-100"
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-show="modelValue.length > 1"
|
||||
@click="removeFilter(index)"
|
||||
class="p-1 hover:bg-slate-100 rounded"
|
||||
>
|
||||
<X class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3">
|
||||
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
|
||||
<Plus class="w-3 h-3 mr-1" /> Add filter
|
||||
</Button>
|
||||
<div class="flex gap-2" v-if="showButtons">
|
||||
<Button variant="ghost" @click="clearFilters">Reset</Button>
|
||||
<Button @click="applyFilters">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
showButtons: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['apply', 'clear'])
|
||||
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
|
||||
|
||||
const createFilter = () => ({ field: '', operator: '', value: '' })
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value.length === 0) {
|
||||
modelValue.value.push(createFilter())
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
modelValue.value = []
|
||||
})
|
||||
|
||||
const getModel = (field) => {
|
||||
const fieldConfig = props.fields.find((f) => f.field === field)
|
||||
return fieldConfig?.model || ''
|
||||
}
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(filters) => {
|
||||
filters.forEach((filter) => {
|
||||
if (filter.field && !filter.model) {
|
||||
filter.model = getModel(filter.field)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const addFilter = () => modelValue.value.push(createFilter())
|
||||
const removeFilter = (index) => modelValue.value.splice(index, 1)
|
||||
const applyFilters = () => emit('apply', validFilters.value)
|
||||
const clearFilters = () => {
|
||||
modelValue.value = []
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const validFilters = computed(() => {
|
||||
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
|
||||
})
|
||||
|
||||
const getFieldOptions = (fieldValue) => {
|
||||
const field = props.fields.find((f) => f.field === fieldValue.field)
|
||||
return field?.options || []
|
||||
}
|
||||
|
||||
const getFieldOperators = (modelFilter) => {
|
||||
const field = props.fields.find((f) => f.field === modelFilter.field)
|
||||
return field?.operators || []
|
||||
}
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
|
||||
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
|
||||
<template v-slot="{ navigate, isActive }">
|
||||
<Button
|
||||
as="a"
|
||||
:href="item.href"
|
||||
variant="ghost"
|
||||
:class="
|
||||
cn(
|
||||
'w-full text-left justify-start h-16 pl-3',
|
||||
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
|
||||
)
|
||||
"
|
||||
@click="navigate"
|
||||
>
|
||||
<div class="flex flex-col items-start space-y-1">
|
||||
<span class="text-sm">{{ item.title }}</span>
|
||||
<p class="text-muted-foreground text-xs break-words whitespace-normal">{{ item.description }}</p>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
defineProps({
|
||||
navItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const isChildActive = (href) => {
|
||||
return route.path.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="conversationStore.messages.data">
|
||||
<!-- Header -->
|
||||
<div class="p-3 border-b flex items-center justify-between">
|
||||
<div class="p-2 border-b flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<div class="font-medium">
|
||||
{{ conversationStore.current.subject }}
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
|
||||
<DropdownMenuItem v-for="status in conversationStore.statusOptions" :key="status.value"
|
||||
@click="handleUpdateStatus(status.label)">
|
||||
{{ status.label }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
@@ -95,18 +94,26 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
|
||||
|
||||
const editorConfig = {
|
||||
extensions: [
|
||||
// Lists are unstyled in tailwind, so we need to add classes to them.
|
||||
StarterKit.configure({
|
||||
hardBreak: false
|
||||
}),
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (this.editor.isActive('orderedList') || this.editor.isActive('bulletList')) {
|
||||
return this.editor.chain().createParagraphNear().run()
|
||||
}
|
||||
return this.editor.commands.setHardBreak()
|
||||
}
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: 'list-disc ml-6 my-2'
|
||||
}
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: 'list-decimal ml-6 my-2'
|
||||
}
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: 'pl-1'
|
||||
}
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: 'text-xl font-bold mt-4 mb-2'
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -194,7 +201,7 @@ watch(
|
||||
if (!props.clearContent) return
|
||||
editor.value?.commands.clearContent()
|
||||
editor.value?.commands.focus()
|
||||
// `onUpdate` is not called when clearing content, so we need to manually reset the values.
|
||||
// `onUpdate` is not called when clearing content, so need to reset the content here.
|
||||
htmlContent.value = ''
|
||||
textContent.value = ''
|
||||
cursorPosition.value = 0
|
||||
@@ -238,10 +245,10 @@ onUnmounted(() => {
|
||||
|
||||
// Editor height
|
||||
.ProseMirror {
|
||||
min-height: 150px !important;
|
||||
max-height: 100% !important;
|
||||
min-height: 200px !important;
|
||||
max-height: 60% !important;
|
||||
overflow-y: scroll !important;
|
||||
padding: 10px 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@@ -254,8 +261,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
br.ProseMirror-trailingBreak {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,26 +5,6 @@
|
||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event">
|
||||
<DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6">
|
||||
<div v-if="isEditorFullscreen">
|
||||
<div
|
||||
v-if="filteredCannedResponses.length > 0"
|
||||
class="w-full overflow-hidden p-2 border-t backdrop-blur"
|
||||
>
|
||||
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<li
|
||||
v-for="(response, index) in filteredCannedResponses"
|
||||
:key="response.id"
|
||||
:class="[
|
||||
'cursor-pointer rounded p-1 hover:bg-secondary',
|
||||
{ 'bg-secondary': index === selectedResponseIndex }
|
||||
]"
|
||||
@click="selectCannedResponse(response.content)"
|
||||
@mouseenter="selectedResponseIndex = index"
|
||||
>
|
||||
<span class="font-semibold">{{ response.title }}</span> -
|
||||
{{ getTextFromHTML(response.content).slice(0, 150) }}...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Editor
|
||||
v-model:selectedText="selectedText"
|
||||
v-model:isBold="isBold"
|
||||
@@ -33,7 +13,6 @@
|
||||
v-model:textContent="textContent"
|
||||
:placeholder="editorPlaceholder"
|
||||
:aiPrompts="aiPrompts"
|
||||
@keydown="handleKeydown"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
:contentToSet="contentToSet"
|
||||
v-model:cursorPosition="cursorPosition"
|
||||
@@ -45,28 +24,6 @@
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Canned responses on non-fullscreen editor -->
|
||||
<div
|
||||
v-if="filteredCannedResponses.length > 0 && !isEditorFullscreen"
|
||||
class="w-full overflow-hidden p-2 border-t backdrop-blur"
|
||||
>
|
||||
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<li
|
||||
v-for="(response, index) in filteredCannedResponses"
|
||||
:key="response.id"
|
||||
:class="[
|
||||
'cursor-pointer rounded p-1 hover:bg-secondary',
|
||||
{ 'bg-secondary': index === selectedResponseIndex }
|
||||
]"
|
||||
@click="selectCannedResponse(response.content)"
|
||||
@mouseenter="selectedResponseIndex = index"
|
||||
>
|
||||
<span class="font-semibold">{{ response.title }}</span> -
|
||||
{{ getTextFromHTML(response.content).slice(0, 150) }}...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Main Editor non-fullscreen -->
|
||||
<div class="border-t" v-if="!isEditorFullscreen">
|
||||
<!-- Message type toggle -->
|
||||
@@ -94,7 +51,6 @@
|
||||
v-model:textContent="textContent"
|
||||
:placeholder="editorPlaceholder"
|
||||
:aiPrompts="aiPrompts"
|
||||
@keydown="handleKeydown"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
:contentToSet="contentToSet"
|
||||
@send="handleSend"
|
||||
@@ -104,18 +60,30 @@
|
||||
:insertContent="insertContent"
|
||||
/>
|
||||
|
||||
<!-- Macro preview -->
|
||||
<MacroActionsPreview
|
||||
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
|
||||
:actions="conversationStore.conversation.macro.actions"
|
||||
:onRemove="conversationStore.removeMacroAction"
|
||||
/>
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete" />
|
||||
<AttachmentsPreview
|
||||
:attachments="attachments"
|
||||
:onDelete="handleOnFileDelete"
|
||||
v-if="attachments.length > 0"
|
||||
/>
|
||||
|
||||
<!-- Bottom menu bar -->
|
||||
<ReplyBoxBottomMenuBar
|
||||
class="mt-1"
|
||||
:handleFileUpload="handleFileUpload"
|
||||
:handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
@toggleBold="toggleBold"
|
||||
@toggleItalic="toggleItalic"
|
||||
:hasText="hasText"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
>
|
||||
@@ -125,25 +93,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { Fullscreen } from 'lucide-vue-next'
|
||||
import api from '@/api'
|
||||
|
||||
import { getTextFromHTML } from '@/utils/strings'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
|
||||
import MacroActionsPreview from '../macro/MacroActionsPreview.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
|
||||
const insertContent = ref(null)
|
||||
const setInlineImage = ref(null)
|
||||
const clearEditorContent = ref(false)
|
||||
@@ -155,22 +122,14 @@ const textContent = ref('')
|
||||
const contentToSet = ref('')
|
||||
const isBold = ref(false)
|
||||
const isItalic = ref(false)
|
||||
|
||||
const uploadedFiles = ref([])
|
||||
const messageType = ref('reply')
|
||||
|
||||
const filteredCannedResponses = ref([])
|
||||
const selectedResponseIndex = ref(-1)
|
||||
const cannedResponsesRef = ref(null)
|
||||
const cannedResponses = ref([])
|
||||
|
||||
const aiPrompts = ref([])
|
||||
|
||||
const editorPlaceholder =
|
||||
"Press Enter to add a new line; Press '/' to select a Canned Response; Press Ctrl + Enter to send."
|
||||
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCannedResponses(), fetchAiPrompts()])
|
||||
await fetchAiPrompts()
|
||||
})
|
||||
|
||||
const fetchAiPrompts = async () => {
|
||||
@@ -186,19 +145,6 @@ const fetchAiPrompts = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCannedResponses = async () => {
|
||||
try {
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiPromptSelected = async (key) => {
|
||||
try {
|
||||
const resp = await api.aiCompletion({
|
||||
@@ -224,35 +170,20 @@ const toggleItalic = () => {
|
||||
}
|
||||
|
||||
const attachments = computed(() => {
|
||||
return uploadedFiles.value.filter((upload) => upload.disposition === 'attachment')
|
||||
return conversationStore.conversation.mediaFiles.filter(
|
||||
(upload) => upload.disposition === 'attachment'
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for text content changes and filter canned responses
|
||||
watch(textContent, (newVal) => {
|
||||
filterCannedResponses(newVal)
|
||||
const enableSend = computed(() => {
|
||||
return textContent.value.trim().length > 0 ||
|
||||
conversationStore.conversation?.macro?.actions?.length > 0
|
||||
? true
|
||||
: false
|
||||
})
|
||||
|
||||
const filterCannedResponses = (input) => {
|
||||
// Extract the text after the last `/`
|
||||
const lastSlashIndex = input.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1) {
|
||||
const searchText = input.substring(lastSlashIndex + 1).trim()
|
||||
|
||||
// Filter canned responses based on the search text
|
||||
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
|
||||
response.title.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
// Reset the selected response index
|
||||
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1
|
||||
} else {
|
||||
filteredCannedResponses.value = []
|
||||
selectedResponseIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const hasText = computed(() => {
|
||||
return textContent.value.trim().length > 0 ? true : false
|
||||
const hasTextContent = computed(() => {
|
||||
return textContent.value.trim().length > 0
|
||||
})
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
@@ -264,7 +195,7 @@ const handleFileUpload = (event) => {
|
||||
linked_model: 'messages'
|
||||
})
|
||||
.then((resp) => {
|
||||
uploadedFiles.value.push(resp.data.data)
|
||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -290,7 +221,7 @@ const handleInlineImageUpload = (event) => {
|
||||
alt: resp.data.data.filename,
|
||||
title: resp.data.data.uuid
|
||||
}
|
||||
uploadedFiles.value.push(resp.data.data)
|
||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -304,29 +235,42 @@ const handleInlineImageUpload = (event) => {
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(htmlContent.value)
|
||||
|
||||
// Check which images are still in editor before sending.
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(htmlContent.value, 'text/html')
|
||||
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
|
||||
.map((img) => img.getAttribute('title'))
|
||||
.filter(Boolean)
|
||||
// Send message if there is text content in the editor.
|
||||
if (hasTextContent.value) {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(htmlContent.value)
|
||||
|
||||
uploadedFiles.value = uploadedFiles.value.filter(
|
||||
(file) =>
|
||||
// Keep if:
|
||||
// 1. Not an inline image OR
|
||||
// 2. Is an inline image that exists in editor
|
||||
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
|
||||
)
|
||||
// Check which images are still in editor before sending.
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(htmlContent.value, 'text/html')
|
||||
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
|
||||
.map((img) => img.getAttribute('title'))
|
||||
.filter(Boolean)
|
||||
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: uploadedFiles.value.map((file) => file.id)
|
||||
})
|
||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||
(file) =>
|
||||
// Keep if:
|
||||
// 1. Not an inline image OR
|
||||
// 2. Is an inline image that exists in editor
|
||||
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
|
||||
)
|
||||
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id)
|
||||
})
|
||||
}
|
||||
|
||||
// Apply macro if it exists.
|
||||
if (conversationStore.conversation?.macro?.actions?.length > 0) {
|
||||
await api.applyMacro(
|
||||
conversationStore.current.uuid,
|
||||
conversationStore.conversation.macro.id,
|
||||
conversationStore.conversation.macro.actions
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
@@ -335,6 +279,8 @@ const handleSend = async () => {
|
||||
})
|
||||
} finally {
|
||||
clearEditorContent.value = true
|
||||
conversationStore.resetMacro()
|
||||
conversationStore.resetMediaFiles()
|
||||
nextTick(() => {
|
||||
clearEditorContent.value = false
|
||||
})
|
||||
@@ -343,49 +289,23 @@ const handleSend = async () => {
|
||||
}
|
||||
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
uploadedFiles.value = uploadedFiles.value.filter((item) => item.uuid !== uuid)
|
||||
}
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (filteredCannedResponses.value.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
selectedResponseIndex.value =
|
||||
(selectedResponseIndex.value + 1) % filteredCannedResponses.value.length
|
||||
scrollToSelectedItem()
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
selectedResponseIndex.value =
|
||||
(selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) %
|
||||
filteredCannedResponses.value.length
|
||||
scrollToSelectedItem()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectCannedResponse(filteredCannedResponses.value[selectedResponseIndex.value].content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToSelectedItem = () => {
|
||||
const list = cannedResponsesRef.value
|
||||
const selectedItem = list.children[selectedResponseIndex.value]
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectCannedResponse = (content) => {
|
||||
contentToSet.value = content
|
||||
filteredCannedResponses.value = []
|
||||
selectedResponseIndex.value = -1
|
||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||
(item) => item.uuid !== uuid
|
||||
)
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
insertContent.value = undefined
|
||||
// Force reactivity so the user can select the same emoji multiple times
|
||||
nextTick(() => insertContent.value = emoji)
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
// Watch for changes in macro content and update editor content.
|
||||
watch(
|
||||
() => conversationStore.conversation.macro,
|
||||
() => {
|
||||
contentToSet.value = conversationStore.conversation.macro.message_content
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<Smile class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,7 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
|
||||
defineProps({
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
hasText: Boolean,
|
||||
enableSend: Boolean,
|
||||
handleSend: Function,
|
||||
handleFileUpload: Function,
|
||||
handleInlineImageUpload: Function
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-64 space-y-2">
|
||||
<component :is="icon" :stroke-width="1.4" :size="70" />
|
||||
<component :is="icon" :stroke-width="1" :size="50" />
|
||||
<h1 class="text-md font-semibold text-gray-800">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col">
|
||||
<div class="flex justify-start items-center p-3 w-full space-x-4 border-b">
|
||||
<SidebarTrigger class="cursor-pointer w-5 h-5" />
|
||||
<span class="text-xl font-semibold">{{ title }}</span>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<header class="border-b">
|
||||
<div class="flex items-center space-x-4 p-2">
|
||||
<SidebarTrigger class="text-gray-500 hover:text-gray-700 transition-colors" />
|
||||
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex justify-between px-2 py-2 w-full">
|
||||
<!-- Filters -->
|
||||
<div class="bg-white px-4 py-3 flex justify-between items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
<Button variant="ghost">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" class="w-30">
|
||||
{{ conversationStore.getListStatus }}
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
<ChevronDown class="w-4 h-4 ml-2 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
v-for="status in conversationStore.statusesForSelect"
|
||||
v-for="status in conversationStore.statusOptions"
|
||||
:key="status.value"
|
||||
@click="handleStatusChange(status)"
|
||||
>
|
||||
@@ -24,14 +28,13 @@
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
<Button variant="ghost">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" class="w-30">
|
||||
{{ conversationStore.getListSortField }}
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
<ChevronDown class="w-4 h-4 ml-2 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<!-- TODO move hardcoded values to consts -->
|
||||
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('started_first')"
|
||||
@@ -53,63 +56,79 @@
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<EmptyList
|
||||
class="px-4"
|
||||
v-if="!hasConversations && !hasErrored && !isLoading"
|
||||
title="No conversations found"
|
||||
message="Try adjusting filters."
|
||||
:icon="MessageCircleQuestion"
|
||||
></EmptyList>
|
||||
|
||||
<!-- List -->
|
||||
<!-- Content -->
|
||||
<div class="flex-grow overflow-y-auto">
|
||||
<EmptyList
|
||||
class="px-4"
|
||||
v-if="conversationStore.conversations.errorMessage"
|
||||
title="Could not fetch conversations"
|
||||
:message="conversationStore.conversations.errorMessage"
|
||||
:icon="MessageCircleWarning"
|
||||
></EmptyList>
|
||||
v-if="!hasConversations && !hasErrored && !isLoading"
|
||||
key="empty"
|
||||
class="px-4 py-8"
|
||||
title="No conversations found"
|
||||
message="Try adjusting your filters"
|
||||
:icon="MessageCircleQuestion"
|
||||
/>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-else>
|
||||
<div class="space-y-5 px-2">
|
||||
<!-- Empty State -->
|
||||
<TransitionGroup
|
||||
enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
enter-to-class="opacity-100 transform translate-y-0"
|
||||
leave-active-class="transition-all duration-300 ease-in-out"
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform translate-y-4"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<EmptyList
|
||||
v-if="conversationStore.conversations.errorMessage"
|
||||
key="error"
|
||||
class="px-4 py-8"
|
||||
title="Could not fetch conversations"
|
||||
:message="conversationStore.conversations.errorMessage"
|
||||
:icon="MessageCircleWarning"
|
||||
/>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div v-else key="list" class="divide-y divide-gray-200">
|
||||
<ConversationListItem
|
||||
class="mt-2"
|
||||
:conversation="conversation"
|
||||
:currentConversation="conversationStore.current"
|
||||
v-for="conversation in conversationStore.conversationsList"
|
||||
:key="conversation.uuid"
|
||||
:conversation="conversation"
|
||||
:currentConversation="conversationStore.current"
|
||||
:contactFullName="conversationStore.getContactFullName(conversation.uuid)"
|
||||
class="transition-colors duration-200 hover:bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- skeleton -->
|
||||
<div v-if="isLoading">
|
||||
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
|
||||
</div>
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="isLoading" key="loading" class="space-y-4 p-4">
|
||||
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Load more -->
|
||||
<div class="flex justify-center items-center p-5 relative" v-if="!hasErrored">
|
||||
<div v-if="conversationStore.conversations.hasMore">
|
||||
<Button variant="link" @click="loadNextPage">
|
||||
<p v-if="!isLoading">Load more</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="!conversationStore.conversations.hasMore && hasConversations">
|
||||
All conversations loaded
|
||||
</div>
|
||||
<!-- Load More -->
|
||||
<div
|
||||
v-if="!hasErrored && (conversationStore.conversations.hasMore || hasConversations)"
|
||||
class="flex justify-center items-center p-5"
|
||||
>
|
||||
<Button
|
||||
v-if="conversationStore.conversations.hasMore"
|
||||
variant="outline"
|
||||
@click="loadNextPage"
|
||||
:disabled="isLoading"
|
||||
class="transition-all duration-200 ease-in-out transform hover:scale-105"
|
||||
>
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ isLoading ? 'Loading...' : 'Load more' }}
|
||||
</Button>
|
||||
<p v-else class="text-sm text-gray-500">All conversations loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed, onUnmounted } from 'vue'
|
||||
import { onMounted, computed, onUnmounted, ref } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next'
|
||||
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -124,24 +143,26 @@ import { useRoute } from 'vue-router'
|
||||
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
let reFetchInterval = null
|
||||
const route = useRoute()
|
||||
let reFetchInterval = ref(null)
|
||||
|
||||
// Re-fetch conversations list every 30 seconds for any missed updates.
|
||||
// FIXME: Figure out a better way to handle this.
|
||||
const title = computed(() => {
|
||||
const typeValue = route.meta?.type?.(route)
|
||||
return (
|
||||
(typeValue || route.meta?.title || '').charAt(0).toUpperCase() +
|
||||
(typeValue || route.meta?.title || '').slice(1)
|
||||
)
|
||||
})
|
||||
|
||||
// FIXME: Figure how to get missed updates.
|
||||
onMounted(() => {
|
||||
reFetchInterval = setInterval(() => {
|
||||
reFetchInterval.value = setInterval(() => {
|
||||
conversationStore.reFetchConversationsList(false)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => {
|
||||
const typeValue = route.meta?.type?.(route)
|
||||
return (typeValue || route.meta?.title || '').charAt(0).toUpperCase() + (typeValue || route.meta?.title || '').slice(1)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(reFetchInterval)
|
||||
clearInterval(reFetchInterval.value)
|
||||
conversationStore.clearListReRenderInterval()
|
||||
})
|
||||
|
||||
@@ -157,15 +178,7 @@ const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations()
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return conversationStore.conversationsList.length !== 0
|
||||
})
|
||||
|
||||
const hasErrored = computed(() => {
|
||||
return conversationStore.conversations.errorMessage ? true : false
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return conversationStore.conversations.loading
|
||||
})
|
||||
const hasConversations = computed(() => conversationStore.conversationsList.length !== 0)
|
||||
const hasErrored = computed(() => !!conversationStore.conversations.errorMessage)
|
||||
const isLoading = computed(() => conversationStore.conversations.loading)
|
||||
</script>
|
||||
|
||||
@@ -18,26 +18,11 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ListFilter, ChevronDown } from 'lucide-vue-next'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Filter from '@/components/common/Filter.vue'
|
||||
import Filter from '@/components/common/FilterBuilder.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -52,14 +37,6 @@ onMounted(() => {
|
||||
localFilters.value = [...conversationStore.conversations.filters]
|
||||
})
|
||||
|
||||
const handleStatusChange = (status) => {
|
||||
console.log('status', status)
|
||||
}
|
||||
|
||||
const handleSortChange = (order) => {
|
||||
console.log('order', order)
|
||||
}
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
const [statusesResp, prioritiesResp] = await Promise.all([
|
||||
api.getStatuses(),
|
||||
|
||||
@@ -1,53 +1,49 @@
|
||||
<template>
|
||||
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
|
||||
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
|
||||
@click="navigateToConversation(conversation.uuid)">
|
||||
|
||||
<div class="pl-3">
|
||||
<Avatar class="size-[45px]">
|
||||
<div
|
||||
class="relative p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
|
||||
:class="{ 'bg-blue-50': conversation.uuid === currentConversation?.uuid }"
|
||||
@click="navigateToConversation(conversation.uuid)"
|
||||
>
|
||||
<div class="flex items-start space-x-4">
|
||||
<Avatar class="w-12 h-12 rounded-full ring-2 ring-white">
|
||||
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 w-full pb-2">
|
||||
<div class="flex justify-between pt-2 pr-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 flex gap-x-1">
|
||||
<Mail size="13" />
|
||||
{{ conversation.inbox_name }}
|
||||
</p>
|
||||
<p class="text-base font-normal">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ contactFullName }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500" v-if="conversation.last_message_at">
|
||||
{{ formatTime(conversation.last_message_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-2 pr-3">
|
||||
<div class="flex justify-between">
|
||||
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1">
|
||||
<CheckCheck :size="14" /> {{ trimmedLastMessage }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]"
|
||||
v-if="conversation.unread_message_count > 0">
|
||||
<span class="text-white text-xs font-extrabold">
|
||||
{{ conversation.unread_message_count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-xs text-gray-500 flex items-center space-x-1">
|
||||
<Mail class="w-3 h-3" />
|
||||
<span>{{ conversation.inbox_name }}</span>
|
||||
</p>
|
||||
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
<CheckCheck class="inline w-4 h-4 mr-1 text-green-500" />
|
||||
{{ trimmedLastMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center mt-2 space-x-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 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
|
||||
v-if="conversation.unread_message_count > 0"
|
||||
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 bg-blue-500 text-white text-xs font-bold rounded-full"
|
||||
>
|
||||
{{ conversation.unread_message_count }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,6 +84,7 @@ const navigateToConversation = (uuid) => {
|
||||
|
||||
const trimmedLastMessage = computed(() => {
|
||||
const message = props.conversation.last_message || ''
|
||||
return message.length > 45 ? message.slice(0, 45) + "..." : message
|
||||
return message.length > 100 ? message.slice(0, 100) + "..." : message
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-5 p-6 border-b">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
</div>
|
||||
<div class="flex items-center gap-5 p-6 border-b min-w-[200px]">
|
||||
<Skeleton class="h-12 w-12 rounded-full aspect-square" />
|
||||
<div class="space-y-2 flex-grow">
|
||||
<Skeleton class="h-4 w-full max-w-[250px]" />
|
||||
<Skeleton class="h-4 w-full max-w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<template>
|
||||
<div v-if="conversationStore.current">
|
||||
<ConversationSideBarContact :conversation="conversationStore.current" class="p-3" />
|
||||
<Accordion type="multiple" collapsible class="border-t mt-4" :default-value="[]">
|
||||
<Accordion type="multiple" collapsible class="border-t" :default-value="[]">
|
||||
<AccordionItem value="Actions">
|
||||
<AccordionTrigger class="bg-muted p-3"> Actions </AccordionTrigger>
|
||||
<AccordionContent class="space-y-5 p-3">
|
||||
|
||||
<!-- Agent -->
|
||||
<ComboBox
|
||||
v-model="assignedUserID"
|
||||
:items="usersStore.forSelect"
|
||||
:items="usersStore.options"
|
||||
placeholder="Search agent"
|
||||
defaultLabel="Assign agent"
|
||||
@select="selectAgent"
|
||||
@@ -43,21 +42,21 @@
|
||||
<!-- Team -->
|
||||
<ComboBox
|
||||
v-model="assignedTeamID"
|
||||
:items="teamsStore.forSelect"
|
||||
:items="teamsStore.options"
|
||||
placeholder="Search team"
|
||||
defaultLabel="Assign team"
|
||||
@select="selectTeam"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
{{item.emoji}}
|
||||
{{ item.emoji }}
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
{{selected.emoji}}
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>Select team</span>
|
||||
@@ -67,7 +66,7 @@
|
||||
<!-- Priority -->
|
||||
<ComboBox
|
||||
v-model="conversationStore.current.priority"
|
||||
:items="conversationStore.prioritiesForSelect"
|
||||
:items="conversationStore.priorityOptions"
|
||||
:defaultLabel="conversationStore.current.priority ?? 'Select priority'"
|
||||
placeholder="Select priority"
|
||||
@select="selectPriority"
|
||||
@@ -79,7 +78,6 @@
|
||||
:items="tags"
|
||||
placeholder="Select tags"
|
||||
/>
|
||||
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="Information">
|
||||
@@ -106,7 +104,6 @@ import {
|
||||
} from '@/components/ui/accordion'
|
||||
import ConversationInfo from './ConversationInfo.vue'
|
||||
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
|
||||
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
@@ -118,45 +115,32 @@ const conversationStore = useConversationStore()
|
||||
const usersStore = useUsersStore()
|
||||
const teamsStore = useTeamStore()
|
||||
const tags = ref([])
|
||||
const tagIDMap = {}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchTags()])
|
||||
await fetchTags()
|
||||
})
|
||||
|
||||
// FIXME: Fix race.
|
||||
watch(
|
||||
() => conversationStore.current && conversationStore.current.tags,
|
||||
() => conversationStore.current?.tags,
|
||||
() => {
|
||||
handleUpsertTags()
|
||||
conversationStore.upsertTags({
|
||||
tags: JSON.stringify(conversationStore.current.tags)
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id))
|
||||
const assignedTeamID = computed(() => String(conversationStore.current.assigned_team_id))
|
||||
|
||||
const handleUpsertTags = () => {
|
||||
let tagIDs = conversationStore.current.tags.map((tag) => {
|
||||
if (tag in tagIDMap) {
|
||||
return tagIDMap[tag]
|
||||
}
|
||||
})
|
||||
conversationStore.upsertTags({
|
||||
tag_ids: JSON.stringify(tagIDs)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const resp = await api.getTags()
|
||||
resp.data.data.forEach((item) => {
|
||||
tagIDMap[item.name] = item.id
|
||||
tags.value.push(item.name)
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch tags',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" />
|
||||
<AvatarImage :src="conversation?.contact?.avatar_url" v-if="conversation?.contact?.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
@@ -13,7 +13,7 @@
|
||||
<Mail class="size-3 mt-1"></Mail>
|
||||
{{ conversation.contact.email }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.phone_number">
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.phone_number">
|
||||
<Phone class="size-3 mt-1"></Phone>
|
||||
{{ conversation.contact.phone_number }}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true" :show-x-axis="true"
|
||||
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" />
|
||||
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" :rounded-corners="4"/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
90
frontend/src/components/macro/MacroActionsPreview.vue
Normal file
90
frontend/src/components/macro/MacroActionsPreview.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap px-2 py-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="action in actions"
|
||||
:key="action.type"
|
||||
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
|
||||
>
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<component
|
||||
:is="getIcon(action.type)"
|
||||
size="16"
|
||||
class="text-primary group-hover:text-primary"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
|
||||
>
|
||||
{{ getDisplayValue(action) }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="text-sm">{{ getTooltip(action) }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="onRemove(action)"
|
||||
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
title="Remove action"
|
||||
>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { X, Users, User, MessageSquare, Tags, Flag, Send } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps({
|
||||
actions: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
onRemove: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const getIcon = (type) =>
|
||||
({
|
||||
assign_team: Users,
|
||||
assign_user: User,
|
||||
set_status: MessageSquare,
|
||||
set_priority: Flag,
|
||||
send_reply: Send,
|
||||
set_tags: Tags
|
||||
})[type]
|
||||
|
||||
const getDisplayValue = (action) => {
|
||||
if (action.display_value?.length) {
|
||||
return action.display_value.join(', ')
|
||||
}
|
||||
return action.value.join(', ')
|
||||
}
|
||||
|
||||
const getTooltip = (action) => {
|
||||
switch (action.type) {
|
||||
case 'assign_team':
|
||||
return `Assign to team: ${getDisplayValue(action)}`
|
||||
case 'assign_user':
|
||||
return `Assign to user: ${getDisplayValue(action)}`
|
||||
case 'set_status':
|
||||
return `Set status to: ${getDisplayValue(action)}`
|
||||
case 'set_priority':
|
||||
return `Set priority to: ${getDisplayValue(action)}`
|
||||
case 'send_reply':
|
||||
return `Send reply: ${getDisplayValue(action)}`
|
||||
case 'set_tags':
|
||||
return `Set tags: ${getDisplayValue(action)}`
|
||||
default:
|
||||
return `Action: ${action.type}, Value: ${getDisplayValue(action)}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -174,7 +174,7 @@ const adminNavItems = [
|
||||
{
|
||||
title: 'Conversations',
|
||||
href: '/admin/conversations',
|
||||
description: 'Manage tags, canned responses and statuses.',
|
||||
description: 'Manage tags, macros and statuses.',
|
||||
children: [
|
||||
{
|
||||
title: 'Tags',
|
||||
@@ -183,9 +183,9 @@ const adminNavItems = [
|
||||
permissions: ['tags:manage'],
|
||||
},
|
||||
{
|
||||
title: 'Canned responses',
|
||||
href: '/admin/conversations/canned-responses',
|
||||
description: 'Manage canned responses.',
|
||||
title: 'Macros',
|
||||
href: '/admin/conversations/macros',
|
||||
description: 'Manage macros.',
|
||||
permissions: ['tags:manage'],
|
||||
},
|
||||
{
|
||||
@@ -300,12 +300,12 @@ const adminNavItems = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'OpenID Connect SSO',
|
||||
title: 'SSO',
|
||||
href: '/admin/oidc',
|
||||
description: 'Manage OpenID SSO configurations',
|
||||
children: [
|
||||
{
|
||||
title: 'OpenID Connect SSO',
|
||||
title: 'SSO',
|
||||
href: '/admin/oidc',
|
||||
description: 'Manage OpenID SSO configurations',
|
||||
permissions: ['tags:manage'],
|
||||
@@ -338,7 +338,7 @@ const hasConversationOpen = computed(() => {
|
||||
<template>
|
||||
<div class="flex flex-row justify-between h-full">
|
||||
<div class="flex-1">
|
||||
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 17rem;">
|
||||
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 16rem;">
|
||||
<!-- Flex Container that holds all the sidebar components -->
|
||||
<Sidebar collapsible="icon" class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0">
|
||||
|
||||
@@ -519,7 +519,7 @@ const hasConversationOpen = computed(() => {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild size="md">
|
||||
<SidebarMenuButton asChild>
|
||||
<div>
|
||||
<span class="font-semibold text-2xl">Inbox</span>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
<template>
|
||||
<div v-if="dueAt" class="flex items-center justify-center">
|
||||
<span
|
||||
v-if="actualAt && isAfterDueTime"
|
||||
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4 mr-1" />
|
||||
<span class="flex items-center">{{ label }} Overdue</span>
|
||||
</span>
|
||||
<TransitionGroup name="fade" class="animate-fade-in-down">
|
||||
<span
|
||||
v-if="actualAt && isAfterDueTime"
|
||||
key="overdue"
|
||||
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
|
||||
>
|
||||
<AlertCircle class="w-3 h-3 flex-shrink-0" />
|
||||
<span class="flex-1 text-center">{{ label }} Overdue</span>
|
||||
</span>
|
||||
|
||||
<span v-else-if="actualAt && !isAfterDueTime" class="flex items-center text-xs text-green-700">
|
||||
<template v-if="showSLAHit">
|
||||
<CheckCircle class="w-4 h-4 mr-1" />
|
||||
<span class="flex items-center">{{ label }} SLA Hit</span>
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="actualAt && !isAfterDueTime && showSLAHit"
|
||||
key="sla-hit"
|
||||
class="inline-flex items-center bg-green-50 px-1 py-1 rounded-full text-xs font-medium text-green-700 border border-green-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-green-100 animate-fade-in-down min-w-[90px]"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 flex-shrink-0" />
|
||||
<span class="flex-1 text-center">{{ label }} SLA Hit</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else-if="sla?.status === 'remaining'"
|
||||
class="flex items-center bg-yellow-100 p-1 rounded-lg text-xs text-yellow-700 border border-yellow-300"
|
||||
>
|
||||
<Clock class="w-4 h-4 mr-1" />
|
||||
<span class="flex items-center">{{ label }} {{ sla.value }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="sla?.status === 'remaining'"
|
||||
key="remaining"
|
||||
class="inline-flex items-center bg-yellow-50 px-1 py-1 rounded-full text-xs font-medium text-yellow-700 border border-yellow-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-yellow-100 animate-fade-in-down min-w-[90px]"
|
||||
>
|
||||
<Clock class="w-3 h-3 flex-shrink-0" />
|
||||
<span class="flex-1 text-center">{{ label }} {{ sla.value }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else-if="sla?.status === 'overdue'"
|
||||
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4 mr-1" />
|
||||
<span class="flex items-center">{{ label }} Overdue by {{ sla.value }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="sla?.status === 'overdue'"
|
||||
key="sla-overdue"
|
||||
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
|
||||
>
|
||||
<AlertCircle class="w-3 h-3 flex-shrink-0" />
|
||||
<span class="flex-1 text-center">{{ label }} overdue</span>
|
||||
</span>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -18,31 +18,31 @@ export function useConversationFilters () {
|
||||
label: 'Status',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: cStore.statusesForSelect
|
||||
options: cStore.statusOptions
|
||||
},
|
||||
priority_id: {
|
||||
label: 'Priority',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: cStore.prioritiesForSelect
|
||||
options: cStore.priorityOptions
|
||||
},
|
||||
assigned_team_id: {
|
||||
label: 'Assigned team',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: tStore.forSelect
|
||||
options: tStore.options
|
||||
},
|
||||
assigned_user_id: {
|
||||
label: 'Assigned user',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: uStore.forSelect
|
||||
options: uStore.options
|
||||
},
|
||||
inbox_id: {
|
||||
label: 'Inbox',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: iStore.forSelect
|
||||
options: iStore.options
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -61,25 +61,25 @@ export function useConversationFilters () {
|
||||
label: 'Status',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: cStore.statusesForSelect
|
||||
options: cStore.statusOptions
|
||||
},
|
||||
priority: {
|
||||
label: 'Priority',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: cStore.prioritiesForSelect
|
||||
options: cStore.priorityOptions
|
||||
},
|
||||
assigned_team: {
|
||||
label: 'Assigned team',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: tStore.forSelect
|
||||
options: tStore.options
|
||||
},
|
||||
assigned_user: {
|
||||
label: 'Assigned agent',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: uStore.forSelect
|
||||
options: uStore.options
|
||||
},
|
||||
hours_since_created: {
|
||||
label: 'Hours since created',
|
||||
@@ -95,7 +95,7 @@ export function useConversationFilters () {
|
||||
label: 'Inbox',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: iStore.forSelect
|
||||
options: iStore.options
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -103,22 +103,22 @@ export function useConversationFilters () {
|
||||
assign_team: {
|
||||
label: 'Assign to team',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
options: tStore.forSelect
|
||||
options: tStore.options
|
||||
},
|
||||
assign_user: {
|
||||
label: 'Assign to user',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
options: uStore.forSelect
|
||||
options: uStore.options
|
||||
},
|
||||
set_status: {
|
||||
label: 'Set status',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
options: cStore.statusesForSelect
|
||||
options: cStore.statusOptionsNoSnooze
|
||||
},
|
||||
set_priority: {
|
||||
label: 'Set priority',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
options: cStore.prioritiesForSelect
|
||||
options: cStore.priorityOptions
|
||||
},
|
||||
send_private_note: {
|
||||
label: 'Send private note',
|
||||
@@ -131,7 +131,11 @@ export function useConversationFilters () {
|
||||
set_sla: {
|
||||
label: 'Set SLA',
|
||||
type: FIELD_TYPE.SELECT,
|
||||
options: slaStore.forSelect
|
||||
options: slaStore.options
|
||||
},
|
||||
set_tags: {
|
||||
label: 'Set tags',
|
||||
type: FIELD_TYPE.TAG
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const EMITTER_EVENTS = {
|
||||
REFRESH_LIST: 'refresh-list',
|
||||
SHOW_TOAST: 'show-toast',
|
||||
SHOW_SOONER: 'show-sooner',
|
||||
NEW_MESSAGE: 'new-message',
|
||||
SET_NESTED_COMMAND: 'set-nested-command',
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
TAG: 'tag',
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
RICHTEXT: 'richtext'
|
||||
|
||||
@@ -339,17 +339,30 @@ const routes = [
|
||||
{
|
||||
path: 'tags',
|
||||
component: () => import('@/components/admin/conversation/tags/Tags.vue'),
|
||||
meta: { title: 'Conversation Tags' }
|
||||
meta: { title: 'Tags' }
|
||||
},
|
||||
{
|
||||
path: 'statuses',
|
||||
component: () => import('@/components/admin/conversation/status/Status.vue'),
|
||||
meta: { title: 'Conversation Statuses' }
|
||||
meta: { title: 'Statuses' }
|
||||
},
|
||||
{
|
||||
path: 'canned-responses',
|
||||
component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'),
|
||||
meta: { title: 'Canned Responses' }
|
||||
path: 'Macros',
|
||||
component: () => import('@/components/admin/conversation/macros/Macros.vue'),
|
||||
meta: { title: 'Macros' },
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
component: () => import('@/components/admin/conversation/macros/CreateMacro.vue'),
|
||||
meta: { title: 'Create Macro' }
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
props: true,
|
||||
component: () => import('@/components/admin/conversation/macros/EditMacro.vue'),
|
||||
meta: { title: 'Edit Macro' }
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,12 +13,19 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const priorities = ref([])
|
||||
const statuses = ref([])
|
||||
|
||||
const prioritiesForSelect = computed(() => {
|
||||
// Options for select fields
|
||||
const priorityOptions = computed(() => {
|
||||
return priorities.value.map(p => ({ label: p.name, value: p.id }))
|
||||
})
|
||||
const statusesForSelect = computed(() => {
|
||||
const statusOptions = computed(() => {
|
||||
return statuses.value.map(s => ({ label: s.name, value: s.id }))
|
||||
})
|
||||
const statusOptionsNoSnooze = computed(() =>
|
||||
statuses.value.filter(s => s.name !== 'Snoozed').map(s => ({
|
||||
label: s.name,
|
||||
value: s.id
|
||||
}))
|
||||
)
|
||||
|
||||
const sortFieldMap = {
|
||||
oldest: {
|
||||
@@ -78,6 +85,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const conversation = reactive({
|
||||
data: null,
|
||||
participants: {},
|
||||
mediaFiles: [],
|
||||
macro: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
})
|
||||
@@ -101,6 +110,22 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
clearInterval(reRenderInterval)
|
||||
}
|
||||
|
||||
function setMacro (macros) {
|
||||
conversation.macro = macros
|
||||
}
|
||||
|
||||
function removeMacroAction (action) {
|
||||
conversation.macro.actions = conversation.macro.actions.filter(a => a.type !== action.type)
|
||||
}
|
||||
|
||||
function resetMacro () {
|
||||
conversation.macro = {}
|
||||
}
|
||||
|
||||
function resetMediaFiles () {
|
||||
conversation.mediaFiles = []
|
||||
}
|
||||
|
||||
function setListStatus (status, fetch = true) {
|
||||
if (conversations.status === status) return
|
||||
conversations.status = status
|
||||
@@ -193,6 +218,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
async function fetchConversation (uuid) {
|
||||
conversation.loading = true
|
||||
resetCurrentConversation()
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
conversation.data = resp.data.data
|
||||
@@ -419,7 +445,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function upsertTags (v) {
|
||||
try {
|
||||
await api.upsertTags(conversation.data.uuid, v)
|
||||
@@ -517,6 +542,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
Object.assign(conversation, {
|
||||
data: null,
|
||||
participants: {},
|
||||
macro: {},
|
||||
mediaFiles: [],
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
})
|
||||
@@ -574,11 +601,16 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
fetchPriorities,
|
||||
setListSortField,
|
||||
setListStatus,
|
||||
removeMacroAction,
|
||||
setMacro,
|
||||
resetMacro,
|
||||
resetMediaFiles,
|
||||
getListSortField,
|
||||
getListStatus,
|
||||
statuses,
|
||||
priorities,
|
||||
prioritiesForSelect,
|
||||
statusesForSelect
|
||||
priorityOptions,
|
||||
statusOptionsNoSnooze,
|
||||
statusOptions
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import api from '@/api'
|
||||
export const useInboxStore = defineStore('inbox', () => {
|
||||
const inboxes = ref([])
|
||||
const emitter = useEmitter()
|
||||
const forSelect = computed(() => inboxes.value.map(inb => ({
|
||||
const options = computed(() => inboxes.value.map(inb => ({
|
||||
label: inb.name,
|
||||
value: String(inb.id)
|
||||
})))
|
||||
@@ -27,7 +27,7 @@ export const useInboxStore = defineStore('inbox', () => {
|
||||
}
|
||||
return {
|
||||
inboxes,
|
||||
forSelect,
|
||||
options,
|
||||
fetchInboxes,
|
||||
}
|
||||
})
|
||||
41
frontend/src/stores/macro.js
Normal file
41
frontend/src/stores/macro.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import { useUserStore } from './user'
|
||||
import api from '@/api'
|
||||
|
||||
export const useMacroStore = defineStore('macroStore', () => {
|
||||
const macroList = ref([])
|
||||
const emitter = useEmitter()
|
||||
const userStore = useUserStore()
|
||||
const macroOptions = computed(() => {
|
||||
const userTeams = userStore.teams.map(team => String(team.id))
|
||||
return macroList.value.filter(macro =>
|
||||
macro.visibility === 'all' || userTeams.includes(macro.team_id) || String(macro.user_id) === String(userStore.userID)
|
||||
).map(macro => ({
|
||||
...macro,
|
||||
label: macro.name,
|
||||
value: String(macro.id),
|
||||
}))
|
||||
})
|
||||
const loadMacros = async () => {
|
||||
if (macroList.value.length) return
|
||||
try {
|
||||
const response = await api.getAllMacros()
|
||||
macroList.value = response?.data?.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
macroList,
|
||||
macroOptions,
|
||||
loadMacros,
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import api from '@/api'
|
||||
export const useSlaStore = defineStore('sla', () => {
|
||||
const slas = ref([])
|
||||
const emitter = useEmitter()
|
||||
const forSelect = computed(() => slas.value.map(sla => ({
|
||||
const options = computed(() => slas.value.map(sla => ({
|
||||
label: sla.name,
|
||||
value: String(sla.id)
|
||||
})))
|
||||
@@ -27,7 +27,7 @@ export const useSlaStore = defineStore('sla', () => {
|
||||
}
|
||||
return {
|
||||
slas,
|
||||
forSelect,
|
||||
options,
|
||||
fetchSlas
|
||||
}
|
||||
})
|
||||
|
||||
37
frontend/src/stores/tag.js
Normal file
37
frontend/src/stores/tag.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import api from '@/api'
|
||||
|
||||
export const useTagStore = defineStore('tags', () => {
|
||||
const tags = ref([])
|
||||
const emitter = useEmitter()
|
||||
const tagNames = computed(() => tags.value.map(tag => tag.name))
|
||||
const tagOptions = computed(() => tags.value.map(tag => ({
|
||||
label: tag.name,
|
||||
value: String(tag.id),
|
||||
})))
|
||||
|
||||
const fetchTags = async () => {
|
||||
if (tags.value.length) return
|
||||
try {
|
||||
const response = await api.getTags()
|
||||
tags.value = response?.data?.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tags,
|
||||
tagOptions,
|
||||
tagNames,
|
||||
fetchTags,
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import api from '@/api'
|
||||
export const useTeamStore = defineStore('team', () => {
|
||||
const teams = ref([])
|
||||
const emitter = useEmitter()
|
||||
const forSelect = computed(() => teams.value.map(team => ({
|
||||
const options = computed(() => teams.value.map(team => ({
|
||||
label: team.name,
|
||||
value: String(team.id),
|
||||
emoji: team.emoji,
|
||||
@@ -28,7 +28,7 @@ export const useTeamStore = defineStore('team', () => {
|
||||
}
|
||||
return {
|
||||
teams,
|
||||
forSelect,
|
||||
options,
|
||||
fetchTeams,
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import api from '@/api'
|
||||
export const useUsersStore = defineStore('users', () => {
|
||||
const users = ref([])
|
||||
const emitter = useEmitter()
|
||||
const forSelect = computed(() => users.value.map(user => ({
|
||||
const options = computed(() => users.value.map(user => ({
|
||||
label: user.first_name + ' ' + user.last_name,
|
||||
value: String(user.id),
|
||||
avatar_url: user.avatar_url,
|
||||
@@ -28,7 +28,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
}
|
||||
return {
|
||||
users,
|
||||
forSelect,
|
||||
options,
|
||||
fetchUsers,
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,16 @@ import Admin from '@/components/admin/AdminPage.vue'
|
||||
|
||||
<template>
|
||||
<Admin class="page-content">
|
||||
<router-view></router-view>
|
||||
<main class="p-6 lg:p-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<div class="space-y-6">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<template>
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||
<ResizablePanel :min-size="23" :default-size="23" :max-size="40">
|
||||
<div class="flex">
|
||||
<div class="border-r w-[380px]">
|
||||
<ConversationList />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel>
|
||||
<div class="border-r">
|
||||
<Conversation v-if="conversationStore.current"></Conversation>
|
||||
<ConversationPlaceholder v-else></ConversationPlaceholder>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<div class="border-r flex-1">
|
||||
<Conversation v-if="conversationStore.current"></Conversation>
|
||||
<ConversationPlaceholder v-else></ConversationPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, onUnmounted, onMounted } from 'vue'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import ConversationList from '@/components/conversation/list/ConversationList.vue'
|
||||
import Conversation from '@/components/conversation/Conversation.vue'
|
||||
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
|
||||
|
||||
@@ -77,7 +77,7 @@ const stopRealtimeUpdates = () => {
|
||||
|
||||
const getDashboardData = () => {
|
||||
isLoading.value = true
|
||||
Promise.all([getCardStats(), getDashboardCharts()])
|
||||
Promise.allSettled([getCardStats(), getDashboardCharts()])
|
||||
.finally(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ module.exports = {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
inter: ['Inter', 'Helvetica Neue', 'sans-serif'],
|
||||
jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
@@ -84,12 +84,23 @@ module.exports = {
|
||||
from: { height: 'var(--radix-collapsible-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
'fade-in-down': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(-3px)'
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)'
|
||||
},
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
'collapsible-down': 'collapsible-down 0.2s ease-in-out',
|
||||
'collapsible-up': 'collapsible-up 0.2s ease-in-out',
|
||||
'fade-in-down': 'fade-in-down 0.3s ease-out'
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,11 +61,11 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
|
||||
|
||||
has, err := e.enforcer.HasPolicy(userID, permObj, permAct)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check policy: %v", err)
|
||||
return fmt.Errorf("failed to check casbin policy: %v", err)
|
||||
}
|
||||
if !has {
|
||||
if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
|
||||
return fmt.Errorf("failed to add policy: %v", err)
|
||||
return fmt.Errorf("failed to add casbin policy: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ const (
|
||||
// Tags
|
||||
PermTagsManage = "tags:manage"
|
||||
|
||||
// Canned Responses
|
||||
PermCannedResponsesManage = "canned_responses:manage"
|
||||
// Macros
|
||||
PermMacrosManage = "macros:manage"
|
||||
|
||||
// Users
|
||||
PermUsersManage = "users:manage"
|
||||
@@ -80,7 +80,7 @@ var validPermissions = map[string]struct{}{
|
||||
PermViewManage: {},
|
||||
PermStatusManage: {},
|
||||
PermTagsManage: {},
|
||||
PermCannedResponsesManage: {},
|
||||
PermMacrosManage: {},
|
||||
PermUsersManage: {},
|
||||
PermTeamsManage: {},
|
||||
PermAutomationsManage: {},
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
@@ -25,7 +24,6 @@ import (
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
|
||||
// MaxQueueSize defines the maximum size of the task queues.
|
||||
MaxQueueSize = 5000
|
||||
)
|
||||
@@ -51,9 +49,7 @@ type Engine struct {
|
||||
rulesMu sync.RWMutex
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
conversationStore ConversationStore
|
||||
slaStore SLAStore
|
||||
systemUser umodels.User
|
||||
conversationStore conversationStore
|
||||
taskQueue chan ConversationTask
|
||||
closed bool
|
||||
closedMu sync.RWMutex
|
||||
@@ -65,20 +61,10 @@ type Opts struct {
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
type ConversationStore interface {
|
||||
GetConversation(id int, uuid string) (cmodels.Conversation, error)
|
||||
GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error)
|
||||
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
|
||||
UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error
|
||||
UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error
|
||||
UpdateConversationPriority(uuid string, priorityID int, priority string, actor umodels.User) error
|
||||
SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error
|
||||
SendReply(media []mmodels.Media, senderID int, conversationUUID, content, meta string) error
|
||||
RecordSLASet(conversationUUID string, actor umodels.User) error
|
||||
}
|
||||
|
||||
type SLAStore interface {
|
||||
ApplySLA(conversationID, slaID int) error
|
||||
type conversationStore interface {
|
||||
ApplyAction(action models.RuleAction, conversation cmodels.Conversation, user umodels.User) error
|
||||
GetConversation(teamID int, uuid string) (cmodels.Conversation, error)
|
||||
GetConversationsCreatedAfter(time.Time) ([]cmodels.Conversation, error)
|
||||
}
|
||||
|
||||
type queries struct {
|
||||
@@ -94,13 +80,12 @@ type queries struct {
|
||||
}
|
||||
|
||||
// New initializes a new Engine.
|
||||
func New(systemUser umodels.User, opt Opts) (*Engine, error) {
|
||||
func New(opt Opts) (*Engine, error) {
|
||||
var (
|
||||
q queries
|
||||
e = &Engine{
|
||||
systemUser: systemUser,
|
||||
lo: opt.Lo,
|
||||
taskQueue: make(chan ConversationTask, MaxQueueSize),
|
||||
lo: opt.Lo,
|
||||
taskQueue: make(chan ConversationTask, MaxQueueSize),
|
||||
}
|
||||
)
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
|
||||
@@ -112,9 +97,8 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
|
||||
}
|
||||
|
||||
// SetConversationStore sets conversations store.
|
||||
func (e *Engine) SetConversationStore(store ConversationStore, slaStore SLAStore) {
|
||||
func (e *Engine) SetConversationStore(store conversationStore) {
|
||||
e.conversationStore = store
|
||||
e.slaStore = slaStore
|
||||
}
|
||||
|
||||
// ReloadRules reloads automation rules from DB.
|
||||
@@ -277,43 +261,6 @@ func (e *Engine) UpdateRuleExecutionMode(ruleType, mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleNewConversation handles new conversation events.
|
||||
func (e *Engine) handleNewConversation(conversationUUID string) {
|
||||
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
|
||||
// handleUpdateConversation handles update conversation events with specific eventType.
|
||||
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
|
||||
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
|
||||
// handleTimeTrigger handles time trigger events.
|
||||
func (e *Engine) handleTimeTrigger() {
|
||||
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
|
||||
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversations for time trigger", "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
|
||||
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
|
||||
for _, conversation := range conversations {
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateNewConversationRules enqueues a new conversation for rule evaluation.
|
||||
func (e *Engine) EvaluateNewConversationRules(conversationUUID string) {
|
||||
e.closedMu.RLock()
|
||||
@@ -355,6 +302,43 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT
|
||||
}
|
||||
}
|
||||
|
||||
// handleNewConversation handles new conversation events.
|
||||
func (e *Engine) handleNewConversation(conversationUUID string) {
|
||||
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
|
||||
// handleUpdateConversation handles update conversation events with specific eventType.
|
||||
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
|
||||
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
|
||||
// handleTimeTrigger handles time trigger events.
|
||||
func (e *Engine) handleTimeTrigger() {
|
||||
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
|
||||
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching conversations for time trigger", "error", err)
|
||||
return
|
||||
}
|
||||
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
|
||||
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
|
||||
for _, conversation := range conversations {
|
||||
e.evalConversationRules(rules, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
// queryRules fetches automation rules from the database.
|
||||
func (e *Engine) queryRules() []models.Rule {
|
||||
var (
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
)
|
||||
|
||||
// evalConversationRules evaluates a list of rules against a given conversation.
|
||||
@@ -39,7 +39,7 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
|
||||
if evaluateFinalResult(groupEvalResults, rule.GroupOperator) {
|
||||
e.lo.Debug("rule evaluation successful executing actions", "conversation_uuid", conversation.UUID)
|
||||
for _, action := range rule.Actions {
|
||||
e.applyAction(action, conversation)
|
||||
e.conversationStore.ApplyAction(action, conversation, umodels.User{})
|
||||
}
|
||||
if rule.ExecutionMode == models.ExecutionModeFirstMatch {
|
||||
e.lo.Debug("first match rule execution mode, breaking out of rule evaluation", "conversation_uuid", conversation.UUID)
|
||||
@@ -138,7 +138,6 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
return false
|
||||
}
|
||||
|
||||
// Case sensitivity handling
|
||||
if !rule.CaseSensitiveMatch {
|
||||
valueToCompare = strings.ToLower(valueToCompare)
|
||||
rule.Value = strings.ToLower(rule.Value)
|
||||
@@ -210,55 +209,3 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
e.lo.Debug("conversation automation rule status", "has_met", conditionMet, "conversation_uuid", conversation.UUID)
|
||||
return conditionMet
|
||||
}
|
||||
|
||||
// applyAction applies a specific action to the given conversation.
|
||||
func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
|
||||
switch action.Type {
|
||||
case models.ActionAssignTeam:
|
||||
e.lo.Debug("executing assign team action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
teamID, _ := strconv.Atoi(action.Action)
|
||||
if err := e.conversationStore.UpdateConversationTeamAssignee(conversation.UUID, teamID, e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionAssignUser:
|
||||
e.lo.Debug("executing assign user action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
agentID, _ := strconv.Atoi(action.Action)
|
||||
if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, agentID, e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionSetPriority:
|
||||
e.lo.Debug("executing set priority action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
priorityID, _ := strconv.Atoi(action.Action)
|
||||
if err := e.conversationStore.UpdateConversationPriority(conversation.UUID, priorityID, "", e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionSetStatus:
|
||||
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
statusID, _ := strconv.Atoi(action.Action)
|
||||
if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, statusID, "", "", e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionSendPrivateNote:
|
||||
e.lo.Debug("executing send private note action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
if err := e.conversationStore.SendPrivateNote([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionReply:
|
||||
e.lo.Debug("executing reply action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
if err := e.conversationStore.SendReply([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action, "" /**meta**/); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionSetSLA:
|
||||
e.lo.Debug("executing SLA action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
slaID, _ := strconv.Atoi(action.Action)
|
||||
if err := e.slaStore.ApplySLA(conversation.ID, slaID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.conversationStore.RecordSLASet(conversation.UUID, e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unrecognized rule action: %s", action.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,7 @@ const (
|
||||
ActionSendPrivateNote = "send_private_note"
|
||||
ActionReply = "send_reply"
|
||||
ActionSetSLA = "set_sla"
|
||||
ActionSetTags = "set_tags"
|
||||
|
||||
OperatorAnd = "AND"
|
||||
OperatorOR = "OR"
|
||||
@@ -52,6 +54,17 @@ const (
|
||||
ExecutionModeFirstMatch = "first_match"
|
||||
)
|
||||
|
||||
// ActionPermissions maps actions to permissions
|
||||
var ActionPermissions = map[string]string{
|
||||
ActionAssignTeam: authzModels.PermConversationsUpdateTeamAssignee,
|
||||
ActionAssignUser: authzModels.PermConversationsUpdateUserAssignee,
|
||||
ActionSetStatus: authzModels.PermConversationsUpdateStatus,
|
||||
ActionSetPriority: authzModels.PermConversationsUpdatePriority,
|
||||
ActionSendPrivateNote: authzModels.PermMessagesWrite,
|
||||
ActionReply: authzModels.PermMessagesWrite,
|
||||
ActionSetTags: authzModels.PermConversationsUpdateTags,
|
||||
}
|
||||
|
||||
// RuleRecord represents a rule record in the database
|
||||
type RuleRecord struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
@@ -89,6 +102,7 @@ type RuleDetail struct {
|
||||
}
|
||||
|
||||
type RuleAction struct {
|
||||
Type string `json:"type" db:"type"`
|
||||
Action string `json:"value" db:"value"`
|
||||
Type string `json:"type" db:"type"`
|
||||
Value []string `json:"value" db:"value"`
|
||||
DisplayValue []string `json:"display_value" db:"-"`
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Package cannedresp provides functionality to manage canned responses in the system.
|
||||
package cannedresp
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/cannedresp/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
)
|
||||
|
||||
// Manager handles the operations related to canned responses.
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
// Opts holds the options for creating a new Manager.
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
type queries struct {
|
||||
GetAll *sqlx.Stmt `query:"get-all"`
|
||||
Create *sqlx.Stmt `query:"create"`
|
||||
Update *sqlx.Stmt `query:"update"`
|
||||
Delete *sqlx.Stmt `query:"delete"`
|
||||
}
|
||||
|
||||
// New initializes a new Manager.
|
||||
func New(opts Opts) (*Manager, error) {
|
||||
var q queries
|
||||
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all canned responses.
|
||||
func (t *Manager) GetAll() ([]models.CannedResponse, error) {
|
||||
var c = make([]models.CannedResponse, 0)
|
||||
if err := t.q.GetAll.Select(&c); err != nil {
|
||||
t.lo.Error("error fetching canned responses", "error", err)
|
||||
return c, envelope.NewError(envelope.GeneralError, "Error fetching canned responses", nil)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Create adds a new canned response.
|
||||
func (t *Manager) Create(title, content string) error {
|
||||
if _, err := t.q.Create.Exec(title, content); err != nil {
|
||||
t.lo.Error("error creating canned response", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating canned response", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing canned response.
|
||||
func (t *Manager) Update(id int, title, content string) error {
|
||||
result, err := t.q.Update.Exec(id, title, content)
|
||||
if err != nil {
|
||||
t.lo.Error("error updating canned response", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating canned response", nil)
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a canned response by ID.
|
||||
func (t *Manager) Delete(id int) error {
|
||||
result, err := t.q.Delete.Exec(id)
|
||||
if err != nil {
|
||||
t.lo.Error("error deleting canned response", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting canned response", nil)
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type CannedResponse struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Content string `db:"content" json:"content"`
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
-- name: get-all
|
||||
SELECT id, title, content, created_at, updated_at FROM canned_responses order by updated_at desc;
|
||||
|
||||
-- name: create
|
||||
INSERT INTO canned_responses (title, content)
|
||||
VALUES ($1, $2);
|
||||
|
||||
-- name: update
|
||||
UPDATE canned_responses
|
||||
SET title = $2, content = $3, updated_at = now() where id = $1;
|
||||
|
||||
-- name: delete
|
||||
DELETE FROM canned_responses WHERE id = $1;
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/automation"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models"
|
||||
smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
@@ -52,6 +55,7 @@ type Manager struct {
|
||||
mediaStore mediaStore
|
||||
statusStore statusStore
|
||||
priorityStore priorityStore
|
||||
slaStore slaStore
|
||||
notifier *notifier.Service
|
||||
lo *logf.Logger
|
||||
db *sqlx.DB
|
||||
@@ -67,6 +71,10 @@ type Manager struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type slaStore interface {
|
||||
ApplySLA(conversationID, slaID int) (slaModels.SLAPolicy, error)
|
||||
}
|
||||
|
||||
type statusStore interface {
|
||||
Get(int) (smodels.Status, error)
|
||||
}
|
||||
@@ -82,6 +90,7 @@ type teamStore interface {
|
||||
|
||||
type userStore interface {
|
||||
Get(int) (umodels.User, error)
|
||||
GetSystemUser() (umodels.User, error)
|
||||
CreateContact(user *umodels.User) error
|
||||
}
|
||||
|
||||
@@ -110,6 +119,7 @@ func New(
|
||||
wsHub *ws.Hub,
|
||||
i18n *i18n.I18n,
|
||||
notifier *notifier.Service,
|
||||
sla slaStore,
|
||||
status statusStore,
|
||||
priority priorityStore,
|
||||
inboxStore inboxStore,
|
||||
@@ -134,6 +144,7 @@ func New(
|
||||
userStore: userStore,
|
||||
teamStore: teamStore,
|
||||
mediaStore: mediaStore,
|
||||
slaStore: sla,
|
||||
statusStore: status,
|
||||
priorityStore: priority,
|
||||
automation: automation,
|
||||
@@ -626,8 +637,8 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// UpsertConversationTags upserts the tags associated with a conversation.
|
||||
func (t *Manager) UpsertConversationTags(uuid string, tagIDs []int) error {
|
||||
if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagIDs)); err != nil {
|
||||
func (t *Manager) UpsertConversationTags(uuid string, tagNames []string) error {
|
||||
if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
|
||||
t.lo.Error("error upserting conversation tags", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error upserting tags", nil)
|
||||
}
|
||||
@@ -747,3 +758,76 @@ func (m *Manager) UnassignOpen(userID int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
|
||||
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
|
||||
func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error {
|
||||
if len(action.Value) == 0 {
|
||||
m.lo.Warn("no value provided for action", "action", action.Type, "conversation_uuid", conversation.UUID)
|
||||
return fmt.Errorf("no value provided for action %s", action.Type)
|
||||
}
|
||||
|
||||
// If user is not provided, use system user.
|
||||
if user.ID == 0 {
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not apply %s action. could not fetch system user: %w", action.Type, err)
|
||||
}
|
||||
user = systemUser
|
||||
}
|
||||
|
||||
switch action.Type {
|
||||
case amodels.ActionAssignTeam:
|
||||
m.lo.Debug("executing assign team action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
teamID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationTeamAssignee(conversation.UUID, teamID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionAssignUser:
|
||||
m.lo.Debug("executing assign user action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
agentID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationUserAssignee(conversation.UUID, agentID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionSetPriority:
|
||||
m.lo.Debug("executing set priority action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
priorityID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationPriority(conversation.UUID, priorityID, "", user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionSetStatus:
|
||||
m.lo.Debug("executing set status action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
statusID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.UpdateConversationStatus(conversation.UUID, statusID, "", "", user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionSendPrivateNote:
|
||||
m.lo.Debug("executing send private note action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
if err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0]); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionReply:
|
||||
m.lo.Debug("executing reply action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
if err := m.SendReply([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0], ""); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
case amodels.ActionSetSLA:
|
||||
m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
slaID, _ := strconv.Atoi(action.Value[0])
|
||||
slaPolicy, err := m.slaStore.ApplySLA(conversation.ID, slaID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
if err := m.RecordSLASet(conversation.UUID, slaPolicy.Name, user); err != nil {
|
||||
m.lo.Error("error recording SLA set activity", "error", err)
|
||||
}
|
||||
case amodels.ActionSetTags:
|
||||
m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID)
|
||||
if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unrecognized action type %s", action.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -376,8 +376,8 @@ func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umod
|
||||
}
|
||||
|
||||
// RecordSLASet records an activity for an SLA set.
|
||||
func (m *Manager) RecordSLASet(conversationUUID string, actor umodels.User) error {
|
||||
return m.InsertConversationActivity(ActivitySLASet, conversationUUID, "", actor)
|
||||
func (m *Manager) RecordSLASet(conversationUUID string, slaName string, actor umodels.User) error {
|
||||
return m.InsertConversationActivity(ActivitySLASet, conversationUUID, slaName, actor)
|
||||
}
|
||||
|
||||
// InsertConversationActivity inserts an activity message.
|
||||
@@ -425,13 +425,13 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
|
||||
case ActivitySelfAssign:
|
||||
content = fmt.Sprintf("%s self-assigned this conversation", actorName)
|
||||
case ActivityPriorityChange:
|
||||
content = fmt.Sprintf("%s changed priority to %s", actorName, newValue)
|
||||
content = fmt.Sprintf("%s set priority to %s", actorName, newValue)
|
||||
case ActivityStatusChange:
|
||||
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
|
||||
case ActivityTagChange:
|
||||
content = fmt.Sprintf("%s added tags %s", actorName, newValue)
|
||||
case ActivitySLASet:
|
||||
content = fmt.Sprintf("%s set an SLA to this conversation", actorName)
|
||||
content = fmt.Sprintf("%s set %s SLA", actorName, newValue)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid activity type %s", activityType)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ SELECT
|
||||
ct.first_name as "contact.first_name",
|
||||
ct.last_name as "contact.last_name",
|
||||
ct.email as "contact.email",
|
||||
ct.avatar_url as "contact.avatar_url"
|
||||
ct.avatar_url as "contact.avatar_url",
|
||||
ct.phone_number as "contact.phone_number"
|
||||
FROM conversations c
|
||||
JOIN users ct ON c.contact_id = ct.id
|
||||
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
|
||||
@@ -320,13 +321,16 @@ WITH conversation_id AS (
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO conversation_tags (conversation_id, tag_id)
|
||||
SELECT conversation_id.id, unnest($2::int[])
|
||||
FROM conversation_id
|
||||
SELECT conversation_id.id, t.id
|
||||
FROM conversation_id, tags t
|
||||
WHERE t.name = ANY($2::text[])
|
||||
ON CONFLICT (conversation_id, tag_id) DO UPDATE SET tag_id = EXCLUDED.tag_id
|
||||
)
|
||||
DELETE FROM conversation_tags
|
||||
WHERE conversation_id = (SELECT id FROM conversation_id)
|
||||
AND tag_id NOT IN (SELECT unnest($2::int[]));
|
||||
AND tag_id NOT IN (
|
||||
SELECT id FROM tags WHERE name = ANY($2::text[])
|
||||
);
|
||||
|
||||
-- name: get-to-address
|
||||
SELECT cc.identifier
|
||||
|
||||
117
internal/macro/macro.go
Normal file
117
internal/macro/macro.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package macro provides functionality for managing templated text responses and actions.
|
||||
package macro
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/macro/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
)
|
||||
|
||||
// Manager is the macro manager.
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
// Predefined queries.
|
||||
type queries struct {
|
||||
Get *sqlx.Stmt `query:"get"`
|
||||
GetAll *sqlx.Stmt `query:"get-all"`
|
||||
Create *sqlx.Stmt `query:"create"`
|
||||
Update *sqlx.Stmt `query:"update"`
|
||||
Delete *sqlx.Stmt `query:"delete"`
|
||||
IncUsageCount *sqlx.Stmt `query:"increment-usage-count"`
|
||||
}
|
||||
|
||||
// Opts contains the dependencies for the macro manager.
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
// New initializes a macro manager.
|
||||
func New(opts Opts) (*Manager, error) {
|
||||
var q queries
|
||||
err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{q: q, lo: opts.Lo}, nil
|
||||
}
|
||||
|
||||
// Get returns a macro by ID.
|
||||
func (m *Manager) Get(id int) (models.Macro, error) {
|
||||
macro := models.Macro{}
|
||||
err := m.q.Get.Get(¯o, id)
|
||||
if err != nil {
|
||||
m.lo.Error("error getting macro", "error", err)
|
||||
return macro, envelope.NewError(envelope.GeneralError, "Error getting macro", nil)
|
||||
}
|
||||
return macro, nil
|
||||
}
|
||||
|
||||
// Create adds a new macro.
|
||||
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
|
||||
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions)
|
||||
if err != nil {
|
||||
m.lo.Error("error creating macro", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating macro", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing macro.
|
||||
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
|
||||
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions)
|
||||
if err != nil {
|
||||
m.lo.Error("error updating macro", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating macro", nil)
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll returns all macros.
|
||||
func (m *Manager) GetAll() ([]models.Macro, error) {
|
||||
macros := make([]models.Macro, 0)
|
||||
err := m.q.GetAll.Select(¯os)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching macros", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching macros", nil)
|
||||
}
|
||||
return macros, nil
|
||||
}
|
||||
|
||||
// Delete deletes a macro by ID.
|
||||
func (m *Manager) Delete(id int) error {
|
||||
result, err := m.q.Delete.Exec(id)
|
||||
if err != nil {
|
||||
m.lo.Error("error deleting macro", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting macro", nil)
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementUsageCount increments the usage count of a macro.
|
||||
func (m *Manager) IncrementUsageCount(id int) error {
|
||||
if _, err := m.q.IncUsageCount.Exec(id); err != nil {
|
||||
m.lo.Error("error incrementing usage count", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error incrementing macro usage count", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
internal/macro/models/models.go
Normal file
19
internal/macro/models/models.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Macro struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
MessageContent string `db:"message_content" json:"message_content"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
UserID *int `db:"user_id" json:"user_id,string"`
|
||||
TeamID *int `db:"team_id" json:"team_id,string"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
Actions json.RawMessage `db:"actions" json:"actions"`
|
||||
}
|
||||
67
internal/macro/queries.sql
Normal file
67
internal/macro/queries.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- name: get
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
message_content,
|
||||
created_at,
|
||||
updated_at,
|
||||
visibility,
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: get-all
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
message_content,
|
||||
created_at,
|
||||
updated_at,
|
||||
visibility,
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
ORDER BY
|
||||
updated_at DESC;
|
||||
|
||||
-- name: create
|
||||
INSERT INTO
|
||||
macros (name, message_content, user_id, team_id, visibility, actions)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: update
|
||||
UPDATE
|
||||
macros
|
||||
SET
|
||||
name = $2,
|
||||
message_content = $3,
|
||||
user_id = $4,
|
||||
team_id = $5,
|
||||
visibility = $6,
|
||||
actions = $7,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: delete
|
||||
DELETE FROM
|
||||
macros
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: increment-usage-count
|
||||
UPDATE
|
||||
macros
|
||||
SET
|
||||
usage_count = usage_count + 1
|
||||
WHERE
|
||||
id = $1;
|
||||
@@ -141,10 +141,10 @@ func (m *Manager) Update(id int, name, description, firstResponseDuration, resol
|
||||
}
|
||||
|
||||
// ApplySLA associates an SLA policy with a conversation.
|
||||
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
|
||||
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) (models.SLAPolicy, error) {
|
||||
sla, err := m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return err
|
||||
return sla, err
|
||||
}
|
||||
for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
|
||||
if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
|
||||
@@ -155,10 +155,10 @@ func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
|
||||
}
|
||||
if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
|
||||
m.lo.Error("error applying SLA to conversation", "error", err)
|
||||
return err
|
||||
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA to conversation", nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).
|
||||
|
||||
@@ -15,7 +15,7 @@ type Team struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Emoji null.String `db:"emoji" json:"emoji"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ConversationAssignmentType string `db:"conversation_assignment_type"`
|
||||
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
|
||||
Timezone string `db:"timezone" json:"timezone,omitempty"`
|
||||
BusinessHoursID int `db:"business_hours_id" json:"business_hours_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/lib/pq"
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -15,6 +16,7 @@ type User struct {
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Disabled bool `db:"disabled" json:"disabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
|
||||
32
schema.sql
32
schema.sql
@@ -11,7 +11,7 @@ DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM
|
||||
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact');
|
||||
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
|
||||
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');
|
||||
|
||||
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "visibility" AS ENUM ('all', 'team', 'user');
|
||||
|
||||
DROP TABLE IF EXISTS conversation_slas CASCADE;
|
||||
CREATE TABLE conversation_slas (
|
||||
@@ -163,15 +163,20 @@ CREATE TABLE automation_rules (
|
||||
CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS canned_responses CASCADE;
|
||||
CREATE TABLE canned_responses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
title TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 140),
|
||||
CONSTRAINT constraint_canned_responses_on_content CHECK (length("content") <= 5000)
|
||||
DROP TABLE IF EXISTS macros CASCADE;
|
||||
CREATE TABLE macros (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
title TEXT NOT NULL,
|
||||
actions JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
visibility macro_visibility NOT NULL,
|
||||
message_content TEXT NOT NULL,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
team_id INT REFERENCES teams(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
usage_count INT DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT title_length CHECK (length(title) <= 255),
|
||||
CONSTRAINT message_content_length CHECK (length(message_content) <= 1000)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS conversation_participants CASCADE;
|
||||
@@ -252,10 +257,11 @@ CREATE INDEX index_settings_on_key ON settings USING btree ("key");
|
||||
DROP TABLE IF EXISTS tags CASCADE;
|
||||
CREATE TABLE tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
"name" TEXT NOT NULL,
|
||||
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name")
|
||||
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name"),
|
||||
CONSTRAINT constraint_tags_on_name CHECK (length("name") <= 140)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS team_members CASCADE;
|
||||
@@ -464,5 +470,5 @@ VALUES
|
||||
(
|
||||
'Admin',
|
||||
'Role for users who have complete access to everything.',
|
||||
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,canned_responses:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
||||
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
||||
);
|
||||
Reference in New Issue
Block a user