From 8d05b8730028eb214af39ecb6f3b87e22a2763dc Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 21 Feb 2026 21:21:22 +0000 Subject: [PATCH 1/5] RM-160 Add support for labels on tickets & transcripts Signed-off-by: Ben --- app/http/endpoints/api/ticket/gettickets.go | 77 ++- .../endpoints/api/ticket/labelassignments.go | 129 ++++ app/http/endpoints/api/ticket/labels.go | 202 +++++++ app/http/endpoints/api/ticket/queryoptions.go | 22 + app/http/endpoints/api/transcripts/list.go | 53 +- .../endpoints/api/transcripts/queryoptions.go | 22 + app/http/server.go | 11 + frontend/src/views/Tickets.svelte | 547 ++++++++++++++++- frontend/src/views/Transcripts.svelte | 555 +++++++++++++++++- go.mod | 6 +- 10 files changed, 1602 insertions(+), 22 deletions(-) create mode 100644 app/http/endpoints/api/ticket/labelassignments.go create mode 100644 app/http/endpoints/api/ticket/labels.go diff --git a/app/http/endpoints/api/ticket/gettickets.go b/app/http/endpoints/api/ticket/gettickets.go index 7b566d4..4549b5c 100644 --- a/app/http/endpoints/api/ticket/gettickets.go +++ b/app/http/endpoints/api/ticket/gettickets.go @@ -14,10 +14,11 @@ import ( type ( listTicketsResponse struct { - Tickets []ticketData `json:"tickets"` - PanelTitles map[int]string `json:"panel_titles"` - ResolvedUsers map[uint64]user.User `json:"resolved_users"` - SelfId uint64 `json:"self_id,string"` + Tickets []ticketData `json:"tickets"` + PanelTitles map[int]string `json:"panel_titles"` + ResolvedUsers map[uint64]user.User `json:"resolved_users"` + Labels map[int][]ticketLabelData `json:"labels"` + SelfId uint64 `json:"self_id,string"` } ticketData struct { @@ -88,6 +89,18 @@ func GetTickets(c *gin.Context) { return } + // Fetch label data + ticketIds := make([]int, len(tickets)) + for i, ticket := range tickets { + ticketIds[i] = ticket.Id + } + + labelsMap, err := fetchLabelsForTickets(c, guildId, ticketIds) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch label data")) + return + } + data := make([]ticketData, len(tickets)) for i, ticket := range tickets { data[i] = ticketData{ @@ -105,6 +118,7 @@ func GetTickets(c *gin.Context) { Tickets: data, PanelTitles: panelTitles, ResolvedUsers: users, + Labels: labelsMap, SelfId: userId, }) } @@ -115,6 +129,7 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke Tickets: []ticketData{}, PanelTitles: make(map[int]string), ResolvedUsers: make(map[uint64]user.User), + Labels: make(map[int][]ticketLabelData), SelfId: userId, }) return @@ -168,6 +183,18 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke return } + // Fetch label data + ticketIds := make([]int, len(plainTickets)) + for i, ticket := range plainTickets { + ticketIds[i] = ticket.Id + } + + labelsMap, err := fetchLabelsForTickets(c, guildId, ticketIds) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch label data")) + return + } + // Build ticketData from tickets with metadata data := make([]ticketData, len(tickets)) for i, ticket := range tickets { @@ -186,6 +213,48 @@ func buildResponseFromPlainTickets(c *gin.Context, plainTickets []database.Ticke Tickets: data, PanelTitles: panelTitles, ResolvedUsers: users, + Labels: labelsMap, SelfId: userId, }) } + +func fetchLabelsForTickets(c *gin.Context, guildId uint64, ticketIds []int) (map[int][]ticketLabelData, error) { + if len(ticketIds) == 0 { + return make(map[int][]ticketLabelData), nil + } + + labelAssignments, err := dbclient.Client.TicketLabelAssignments.GetByTickets(c, guildId, ticketIds) + if err != nil { + return nil, err + } + + allLabels, err := dbclient.Client.TicketLabels.GetByGuild(c, guildId) + if err != nil { + return nil, err + } + + labelLookup := make(map[int]ticketLabelData) + for _, l := range allLabels { + labelLookup[l.LabelId] = ticketLabelData{ + LabelId: l.LabelId, + Name: l.Name, + Colour: l.Colour, + } + } + + result := make(map[int][]ticketLabelData) + for ticketId, assignedIds := range labelAssignments { + var resolved []ticketLabelData + for _, lid := range assignedIds { + if ld, exists := labelLookup[lid]; exists { + resolved = append(resolved, ld) + } + } + if resolved == nil { + resolved = []ticketLabelData{} + } + result[ticketId] = resolved + } + + return result, nil +} diff --git a/app/http/endpoints/api/ticket/labelassignments.go b/app/http/endpoints/api/ticket/labelassignments.go new file mode 100644 index 0000000..313582c --- /dev/null +++ b/app/http/endpoints/api/ticket/labelassignments.go @@ -0,0 +1,129 @@ +package api + +import ( + "fmt" + "strconv" + + "github.com/TicketsBot-cloud/dashboard/app/http/audit" + dbclient "github.com/TicketsBot-cloud/dashboard/database" + "github.com/TicketsBot-cloud/dashboard/utils" + "github.com/TicketsBot-cloud/database" + "github.com/gin-gonic/gin" +) + +type setLabelsBody struct { + LabelIds []int `json:"label_ids"` +} + +func GetTicketLabels(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + ticketId, err := strconv.Atoi(ctx.Param("ticketId")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid ticket ID.")) + return + } + + labelIds, err := dbclient.Client.TicketLabelAssignments.GetByTicket(ctx, guildId, ticketId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch labels. Please try again.")) + return + } + + if labelIds == nil { + labelIds = []int{} + } + + ctx.JSON(200, gin.H{"label_ids": labelIds}) +} + +func SetTicketLabels(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + ticketId, err := strconv.Atoi(ctx.Param("ticketId")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid ticket ID.")) + return + } + + var body setLabelsBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid request body.")) + return + } + + if len(body.LabelIds) > maxLabelsPerGuild { + ctx.JSON(400, utils.ErrorStr("Too many labels.")) + return + } + + // Validate that all label IDs belong to this guild + if len(body.LabelIds) > 0 { + labels, err := dbclient.Client.TicketLabels.GetByGuild(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to update labels. Please try again.")) + return + } + + validIds := make(map[int]bool) + for _, l := range labels { + validIds[l.LabelId] = true + } + + for _, id := range body.LabelIds { + if !validIds[id] { + ctx.JSON(400, utils.ErrorStr(fmt.Sprintf("Label ID %d does not exist.", id))) + return + } + } + } + + if err := dbclient.Client.TicketLabelAssignments.Replace(ctx, guildId, ticketId, body.LabelIds); err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to update labels. Please try again.")) + return + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: database.AuditActionTicketLabelAssign, + ResourceType: database.AuditResourceTicketLabelAssignment, + ResourceId: audit.StringPtr(strconv.Itoa(ticketId)), + NewData: body.LabelIds, + }) + + ctx.JSON(200, gin.H{"label_ids": body.LabelIds}) +} + +func RemoveTicketLabel(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + ticketId, err := strconv.Atoi(ctx.Param("ticketId")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid ticket ID.")) + return + } + + labelId, err := strconv.Atoi(ctx.Param("labelid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid label ID.")) + return + } + + if err := dbclient.Client.TicketLabelAssignments.Delete(ctx, guildId, ticketId, labelId); err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to remove label. Please try again.")) + return + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: database.AuditActionTicketLabelUnassign, + ResourceType: database.AuditResourceTicketLabelAssignment, + ResourceId: audit.StringPtr(fmt.Sprintf("%d:%d", ticketId, labelId)), + }) + + ctx.JSON(204, nil) +} diff --git a/app/http/endpoints/api/ticket/labels.go b/app/http/endpoints/api/ticket/labels.go new file mode 100644 index 0000000..3061b32 --- /dev/null +++ b/app/http/endpoints/api/ticket/labels.go @@ -0,0 +1,202 @@ +package api + +import ( + "fmt" + "strconv" + + "github.com/TicketsBot-cloud/dashboard/app/http/audit" + dbclient "github.com/TicketsBot-cloud/dashboard/database" + "github.com/TicketsBot-cloud/dashboard/utils" + "github.com/TicketsBot-cloud/database" + "github.com/gin-gonic/gin" +) + +const maxLabelsPerGuild = 50 + +type createLabelBody struct { + Name string `json:"name"` + Colour int32 `json:"colour"` +} + +type updateLabelBody struct { + Name string `json:"name"` + Colour int32 `json:"colour"` +} + +type ticketLabelData struct { + LabelId int `json:"label_id"` + Name string `json:"name"` + Colour int32 `json:"colour"` +} + +func ListTicketLabels(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + labels, err := dbclient.Client.TicketLabels.GetByGuild(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch labels. Please try again.")) + return + } + + if labels == nil { + labels = []database.TicketLabel{} + } + + ctx.JSON(200, labels) +} + +func CreateTicketLabel(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + var body createLabelBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid request body.")) + return + } + + if len(body.Name) < 1 || len(body.Name) > 32 { + ctx.JSON(400, utils.ErrorStr("Label name must be between 1 and 32 characters.")) + return + } + + if body.Colour < 0 || body.Colour > 0xFFFFFF { + ctx.JSON(400, utils.ErrorStr("Invalid colour value.")) + return + } + + count, err := dbclient.Client.TicketLabels.GetCount(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to create label. Please try again.")) + return + } + + if count >= maxLabelsPerGuild { + ctx.JSON(400, utils.ErrorStr(fmt.Sprintf("You can only have up to %d labels per server.", maxLabelsPerGuild))) + return + } + + labelId, err := dbclient.Client.TicketLabels.Create(ctx, guildId, body.Name, body.Colour) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to create label. The name may already be in use.")) + return + } + + newLabel := database.TicketLabel{ + GuildId: guildId, + LabelId: labelId, + Name: body.Name, + Colour: body.Colour, + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: database.AuditActionTicketLabelCreate, + ResourceType: database.AuditResourceTicketLabel, + ResourceId: audit.StringPtr(strconv.Itoa(labelId)), + NewData: newLabel, + }) + + ctx.JSON(200, newLabel) +} + +func UpdateTicketLabel(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + labelId, err := strconv.Atoi(ctx.Param("labelid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid label ID.")) + return + } + + var body updateLabelBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid request body.")) + return + } + + if len(body.Name) < 1 || len(body.Name) > 32 { + ctx.JSON(400, utils.ErrorStr("Label name must be between 1 and 32 characters.")) + return + } + + if body.Colour < 0 || body.Colour > 0xFFFFFF { + ctx.JSON(400, utils.ErrorStr("Invalid colour value.")) + return + } + + existing, ok, err := dbclient.Client.TicketLabels.Get(ctx, guildId, labelId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to update label. Please try again.")) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Label not found.")) + return + } + + if err := dbclient.Client.TicketLabels.Update(ctx, guildId, labelId, body.Name, body.Colour); err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to update label. The name may already be in use.")) + return + } + + newLabel := database.TicketLabel{ + GuildId: guildId, + LabelId: labelId, + Name: body.Name, + Colour: body.Colour, + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: database.AuditActionTicketLabelUpdate, + ResourceType: database.AuditResourceTicketLabel, + ResourceId: audit.StringPtr(strconv.Itoa(labelId)), + OldData: existing, + NewData: newLabel, + }) + + ctx.JSON(200, newLabel) +} + +func DeleteTicketLabel(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + labelId, err := strconv.Atoi(ctx.Param("labelid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid label ID.")) + return + } + + existing, ok, err := dbclient.Client.TicketLabels.Get(ctx, guildId, labelId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to delete label. Please try again.")) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Label not found.")) + return + } + + if err := dbclient.Client.TicketLabels.Delete(ctx, guildId, labelId); err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to delete label. Please try again.")) + return + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: database.AuditActionTicketLabelDelete, + ResourceType: database.AuditResourceTicketLabel, + ResourceId: audit.StringPtr(strconv.Itoa(labelId)), + OldData: existing, + }) + + ctx.JSON(204, nil) +} diff --git a/app/http/endpoints/api/ticket/queryoptions.go b/app/http/endpoints/api/ticket/queryoptions.go index 0fa11de..7a913c3 100644 --- a/app/http/endpoints/api/ticket/queryoptions.go +++ b/app/http/endpoints/api/ticket/queryoptions.go @@ -19,6 +19,7 @@ type wrappedQueryOptions struct { UserId uint64 `json:"user_id"` PanelId int `json:"panel_id"` ClaimedById uint64 `json:"claimed_by_id"` + LabelIds []int `json:"label_ids"` } // UnmarshalJSON dynamically handles both string and number types, treating empty strings as 0 @@ -81,6 +82,26 @@ func (o *wrappedQueryOptions) UnmarshalJSON(data []byte) error { case float64: fieldValue.SetUint(uint64(val)) } + case reflect.Slice: + if arr, ok := rawValue.([]interface{}); ok { + elemType := fieldValue.Type().Elem() + slice := reflect.MakeSlice(fieldValue.Type(), 0, len(arr)) + for _, item := range arr { + if num, ok := item.(float64); ok { + elem := reflect.New(elemType).Elem() + switch elemType.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + elem.SetInt(int64(num)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + elem.SetUint(uint64(num)) + case reflect.Float32, reflect.Float64: + elem.SetFloat(num) + } + slice = reflect.Append(slice, elem) + } + } + fieldValue.Set(slice) + } } } @@ -113,6 +134,7 @@ func (o *wrappedQueryOptions) toQueryOptions(guildId uint64) (database.TicketQue Open: utils.BoolPtr(true), // Only open tickets PanelId: o.PanelId, ClaimedById: o.ClaimedById, + LabelIds: o.LabelIds, Order: database.OrderTypeDescending, } return opts, nil diff --git a/app/http/endpoints/api/transcripts/list.go b/app/http/endpoints/api/transcripts/list.go index 4e4cc94..9107663 100644 --- a/app/http/endpoints/api/transcripts/list.go +++ b/app/http/endpoints/api/transcripts/list.go @@ -14,13 +14,20 @@ import ( const pageLimit = 15 +type transcriptLabelData struct { + LabelId int `json:"label_id"` + Name string `json:"name"` + Colour int32 `json:"colour"` +} + type transcriptMetadata struct { - TicketId int `json:"ticket_id"` - Username string `json:"username"` - CloseReason *string `json:"close_reason"` - ClosedBy *uint64 `json:"closed_by"` - Rating *uint8 `json:"rating"` - HasTranscript bool `json:"has_transcript"` + TicketId int `json:"ticket_id"` + Username string `json:"username"` + CloseReason *string `json:"close_reason"` + ClosedBy *uint64 `json:"closed_by"` + Rating *uint8 `json:"rating"` + HasTranscript bool `json:"has_transcript"` + Labels []transcriptLabelData `json:"labels"` } type paginatedTranscripts struct { @@ -100,6 +107,29 @@ func ListTranscripts(ctx *gin.Context) { return } + // Get label assignments + labelAssignments, err := dbclient.Client.TicketLabelAssignments.GetByTickets(ctx, guildId, ticketIds) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch records. Please try again.")) + return + } + + // Get all guild labels for name resolution + allLabels, err := dbclient.Client.TicketLabels.GetByGuild(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch records. Please try again.")) + return + } + + labelMap := make(map[int]transcriptLabelData) + for _, l := range allLabels { + labelMap[l.LabelId] = transcriptLabelData{ + LabelId: l.LabelId, + Name: l.Name, + Colour: l.Colour, + } + } + transcripts := make([]transcriptMetadata, len(tickets)) for i, ticket := range tickets { transcript := transcriptMetadata{ @@ -117,6 +147,17 @@ func ListTranscripts(ctx *gin.Context) { transcript.ClosedBy = v.ClosedBy } + if assignedIds, ok := labelAssignments[ticket.Id]; ok { + for _, lid := range assignedIds { + if ld, exists := labelMap[lid]; exists { + transcript.Labels = append(transcript.Labels, ld) + } + } + } + if transcript.Labels == nil { + transcript.Labels = []transcriptLabelData{} + } + transcripts[i] = transcript } diff --git a/app/http/endpoints/api/transcripts/queryoptions.go b/app/http/endpoints/api/transcripts/queryoptions.go index 9e4caf9..984b1ca 100644 --- a/app/http/endpoints/api/transcripts/queryoptions.go +++ b/app/http/endpoints/api/transcripts/queryoptions.go @@ -22,6 +22,7 @@ type wrappedQueryOptions struct { Rating int `json:"rating"` ClosedById uint64 `json:"closed_by_id"` ClaimedById uint64 `json:"claimed_by_id"` + LabelIds []int `json:"label_ids"` } // UnmarshalJSON dynamically handles both string and number types, treating empty strings as 0 @@ -84,6 +85,26 @@ func (o *wrappedQueryOptions) UnmarshalJSON(data []byte) error { case float64: fieldValue.SetUint(uint64(val)) } + case reflect.Slice: + if arr, ok := rawValue.([]interface{}); ok { + elemType := fieldValue.Type().Elem() + slice := reflect.MakeSlice(fieldValue.Type(), 0, len(arr)) + for _, item := range arr { + if num, ok := item.(float64); ok { + elem := reflect.New(elemType).Elem() + switch elemType.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + elem.SetInt(int64(num)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + elem.SetUint(uint64(num)) + case reflect.Float32, reflect.Float64: + elem.SetFloat(num) + } + slice = reflect.Append(slice, elem) + } + } + fieldValue.Set(slice) + } } } @@ -127,6 +148,7 @@ func (o *wrappedQueryOptions) toQueryOptions(guildId uint64) (database.TicketQue Rating: o.Rating, ClosedById: o.ClosedById, ClaimedById: o.ClaimedById, + LabelIds: o.LabelIds, Order: database.OrderTypeDescending, Limit: pageLimit, Offset: offset, diff --git a/app/http/server.go b/app/http/server.go index fc0ebc9..7e5cadf 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -174,6 +174,17 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server guildApiNoAuth.GET("/transcripts/:ticketId", rl(middleware.RateLimitTypeGuild, 10, 10*time.Second), api_transcripts.GetTranscriptHandler) guildApiNoAuth.GET("/transcripts/:ticketId/render", rl(middleware.RateLimitTypeGuild, 10, 10*time.Second), api_transcripts.GetTranscriptRenderHandler) + // Ticket label CRUD (admin-only for mutations, support-level for reads) + guildAuthApiSupport.GET("/ticket-labels", api_ticket.ListTicketLabels) + guildAuthApiAdmin.POST("/ticket-labels", rl(middleware.RateLimitTypeGuild, 10, time.Minute), api_ticket.CreateTicketLabel) + guildAuthApiAdmin.PATCH("/ticket-labels/:labelid", api_ticket.UpdateTicketLabel) + guildAuthApiAdmin.DELETE("/ticket-labels/:labelid", api_ticket.DeleteTicketLabel) + + // Ticket label assignments - support level + guildAuthApiSupport.GET("/tickets/:ticketId/labels", api_ticket.GetTicketLabels) + guildAuthApiSupport.PUT("/tickets/:ticketId/labels", api_ticket.SetTicketLabels) + guildAuthApiSupport.DELETE("/tickets/:ticketId/labels/:labelid", api_ticket.RemoveTicketLabel) + guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets) guildAuthApiSupport.POST("/tickets", api_ticket.GetTickets) guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket) diff --git a/frontend/src/views/Tickets.svelte b/frontend/src/views/Tickets.svelte index 56a1c41..1ee8e34 100644 --- a/frontend/src/views/Tickets.svelte +++ b/frontend/src/views/Tickets.svelte @@ -15,6 +15,7 @@ import Checkbox from "../components/form/Checkbox.svelte"; import Input from "../components/form/Input.svelte"; import PanelDropdown from "../components/PanelDropdown.svelte"; + import { permissionLevelCache } from "../js/stores"; export let currentRoute; let guildId = currentRoute.namedParams.id; @@ -26,6 +27,7 @@ "Claimed By", "Last Message Time", "Awaiting Response", + "Labels", ]; let sortMethod = "unclaimed"; let onlyShowMyTickets = false; @@ -34,14 +36,45 @@ let panels = []; let selectedPanel; + // Labels + let labels = []; + let selectedLabelIds = []; + let showLabelManageModal = false; + let newLabelName = ""; + let newLabelColour = "#4A4A4A"; + let labelAssignDropdownTicketId = null; + + // Permission level + let isAdmin = false; + $: { + if ($permissionLevelCache[guildId]) { + isAdmin = $permissionLevelCache[guildId].permission_level === 2; + } + } + let data = { tickets: [], panel_titles: {}, resolved_users: {}, + labels: {}, }; let filtered = []; + function textColourForBg(hex) { + const r = (hex >> 16) & 0xFF, g = (hex >> 8) & 0xFF, b = hex & 0xFF; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#1a1a2e" : "#ffffff"; + } + + function intToHex(val) { + return "#" + val.toString(16).padStart(6, "0"); + } + + function hexToInt(hex) { + return parseInt(hex.replace("#", ""), 16); + } + let handleInputTicketId = () => { filterSettings.username = undefined; filterSettings.userId = undefined; @@ -151,6 +184,16 @@ panels = res.data; } + async function loadLabels() { + const res = await axios.get(`${API_URL}/api/${guildId}/ticket-labels`); + if (res.status !== 200) { + notifyError(res.data); + return; + } + + labels = res.data; + } + async function loadTickets() { const filterParams = { id: filterSettings.ticketId, @@ -160,6 +203,10 @@ panel_id: selectedPanel, }; + if (selectedLabelIds.length > 0) { + filterParams.label_ids = selectedLabelIds; + } + const res = await axios.post( `${API_URL}/api/${guildId}/tickets`, filterParams @@ -175,6 +222,10 @@ data.tickets = []; } + if (!data.labels) { + data.labels = {}; + } + data.tickets = data.tickets.map((ticket) => { if (ticket.claimed_by === "null") { ticket.claimed_by = null; @@ -186,7 +237,93 @@ filterTickets(); } - const columnStorageKey = "ticket_list:selected_columns"; + // Label management + async function createLabel() { + if (!newLabelName.trim()) return; + + const res = await axios.post(`${API_URL}/api/${guildId}/ticket-labels`, { + name: newLabelName.trim(), + colour: hexToInt(newLabelColour), + }); + + if (res.status !== 200) { + notifyError(res.data); + return; + } + + labels = [...labels, res.data]; + newLabelName = ""; + newLabelColour = "#4A4A4A"; + } + + async function deleteLabel(labelId) { + const res = await axios.delete(`${API_URL}/api/${guildId}/ticket-labels/${labelId}`); + if (res.status !== 204) { + notifyError(res.data); + return; + } + + labels = labels.filter(l => l.label_id !== labelId); + selectedLabelIds = selectedLabelIds.filter(id => id !== labelId); + } + + // Label assignment + function toggleLabelDropdown(ticketId) { + if (labelAssignDropdownTicketId === ticketId) { + labelAssignDropdownTicketId = null; + } else { + labelAssignDropdownTicketId = ticketId; + } + } + + async function toggleLabelAssignment(ticketId, labelId) { + const currentLabels = data.labels[ticketId] || []; + const currentIds = currentLabels.map(l => l.label_id); + let newIds; + + if (currentIds.includes(labelId)) { + newIds = currentIds.filter(id => id !== labelId); + } else { + newIds = [...currentIds, labelId]; + } + + const res = await axios.put(`${API_URL}/api/${guildId}/tickets/${ticketId}/labels`, { + label_ids: newIds, + }); + + if (res.status !== 200) { + notifyError(res.data); + return; + } + + // Update local state + const updatedLabels = newIds.map(id => { + const label = labels.find(l => l.label_id === id); + return label ? { label_id: label.label_id, name: label.name, colour: label.colour } : null; + }).filter(Boolean); + + data.labels = { ...data.labels, [ticketId]: updatedLabels }; + } + + function handleLabelFilterChange(labelId) { + if (selectedLabelIds.includes(labelId)) { + selectedLabelIds = selectedLabelIds.filter(id => id !== labelId); + } else { + selectedLabelIds = [...selectedLabelIds, labelId]; + } + } + + // Close label dropdown when clicking outside + function handleDocumentClick(event) { + if (labelAssignDropdownTicketId !== null) { + const dropdown = event.target.closest('.label-assign-wrapper'); + if (!dropdown) { + labelAssignDropdownTicketId = null; + } + } + } + + const columnStorageKey = "ticket_list:selected_columns:v2"; const sortOrderKey = "ticket_list:sort_order"; const onlyMyTicketsKey = "ticket_list:only_my_tickets"; @@ -229,10 +366,12 @@ loadFilterSettings(); setDefaultHeaders(); - await Promise.all([loadPanels(), loadTickets()]); + await Promise.all([loadPanels(), loadLabels(), loadTickets()]); }); + +
@@ -312,6 +451,26 @@ /> + + {#if labels.length > 0} +
+
+ +
+ {#each labels as label} + + {/each} +
+
+
+ {/if}
@@ -320,7 +479,14 @@ - Open Tickets + + Open Tickets + {#if isAdmin} + + {/if} + Awaiting Response + Labels @@ -378,6 +550,7 @@ : null} {@const panel_title = data.panel_titles[ticket.panel_id?.toString()]} + {@const ticketLabels = data.labels[ticket.id] || []} + +
+ {#if ticketLabels.length > 0} + {#each ticketLabels as label} + {label.name} + {/each} + {/if} + {#if labels.length > 0} +
+ + {#if labelAssignDropdownTicketId === ticket.id} +
+ {#each labels as label} + + {/each} +
+ {/if} +
+ {/if} +
+ +
+ +{#if showLabelManageModal} + +{/if} + diff --git a/frontend/src/components/manage/LabelEditor.svelte b/frontend/src/components/manage/LabelEditor.svelte new file mode 100644 index 0000000..eefc67c --- /dev/null +++ b/frontend/src/components/manage/LabelEditor.svelte @@ -0,0 +1,113 @@ + + + + + dispatch("cancel", {})} +> + {data ? "Edit Label" : "Create Label"} +
+
+ + +
+ +
+ Preview + + + {name || "Label"} + +
+
+ {data ? "Save" : "Create"} +
+ + diff --git a/frontend/src/components/manage/LabelSelect.svelte b/frontend/src/components/manage/LabelSelect.svelte new file mode 100644 index 0000000..2c60db7 --- /dev/null +++ b/frontend/src/components/manage/LabelSelect.svelte @@ -0,0 +1,251 @@ + + + + +
+
{ + showDropdown = !showDropdown; + }} + > + {#if selectedLabels.length > 0} +
+ {#each selectedLabels as label (label.label_id)} + remove(label.label_id)} + /> + {/each} +
+ {:else} + Select labels... + {/if} + + + +
+ + {#if showDropdown} + + {/if} +
+ + diff --git a/frontend/src/views/Tickets.svelte b/frontend/src/views/Tickets.svelte index 1ee8e34..c03a57c 100644 --- a/frontend/src/views/Tickets.svelte +++ b/frontend/src/views/Tickets.svelte @@ -2,6 +2,7 @@ import Card from "../components/Card.svelte"; import { getRelativeTime, + intToColour, notifyError, withLoadingScreen, } from "../js/util"; @@ -16,6 +17,8 @@ import Input from "../components/form/Input.svelte"; import PanelDropdown from "../components/PanelDropdown.svelte"; import { permissionLevelCache } from "../js/stores"; + import LabelBadge from "../components/manage/LabelBadge.svelte"; + import LabelEditor from "../components/manage/LabelEditor.svelte"; export let currentRoute; let guildId = currentRoute.namedParams.id; @@ -40,8 +43,7 @@ let labels = []; let selectedLabelIds = []; let showLabelManageModal = false; - let newLabelName = ""; - let newLabelColour = "#4A4A4A"; + let showLabelEditor = false; let labelAssignDropdownTicketId = null; // Permission level @@ -62,19 +64,13 @@ let filtered = []; function textColourForBg(hex) { - const r = (hex >> 16) & 0xFF, g = (hex >> 8) & 0xFF, b = hex & 0xFF; + const r = (hex >> 16) & 0xff, + g = (hex >> 8) & 0xff, + b = hex & 0xff; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.5 ? "#1a1a2e" : "#ffffff"; } - function intToHex(val) { - return "#" + val.toString(16).padStart(6, "0"); - } - - function hexToInt(hex) { - return parseInt(hex.replace("#", ""), 16); - } - let handleInputTicketId = () => { filterSettings.username = undefined; filterSettings.userId = undefined; @@ -209,7 +205,7 @@ const res = await axios.post( `${API_URL}/api/${guildId}/tickets`, - filterParams + filterParams, ); if (res.status !== 200) { notifyError(res.data); @@ -238,13 +234,13 @@ } // Label management - async function createLabel() { - if (!newLabelName.trim()) return; + async function createLabel(event) { + const { name, colour } = event.detail; - const res = await axios.post(`${API_URL}/api/${guildId}/ticket-labels`, { - name: newLabelName.trim(), - colour: hexToInt(newLabelColour), - }); + const res = await axios.post( + `${API_URL}/api/${guildId}/ticket-labels`, + { name, colour }, + ); if (res.status !== 200) { notifyError(res.data); @@ -252,19 +248,20 @@ } labels = [...labels, res.data]; - newLabelName = ""; - newLabelColour = "#4A4A4A"; + showLabelEditor = false; } async function deleteLabel(labelId) { - const res = await axios.delete(`${API_URL}/api/${guildId}/ticket-labels/${labelId}`); + const res = await axios.delete( + `${API_URL}/api/${guildId}/ticket-labels/${labelId}`, + ); if (res.status !== 204) { notifyError(res.data); return; } - labels = labels.filter(l => l.label_id !== labelId); - selectedLabelIds = selectedLabelIds.filter(id => id !== labelId); + labels = labels.filter((l) => l.label_id !== labelId); + selectedLabelIds = selectedLabelIds.filter((id) => id !== labelId); } // Label assignment @@ -278,18 +275,21 @@ async function toggleLabelAssignment(ticketId, labelId) { const currentLabels = data.labels[ticketId] || []; - const currentIds = currentLabels.map(l => l.label_id); + const currentIds = currentLabels.map((l) => l.label_id); let newIds; if (currentIds.includes(labelId)) { - newIds = currentIds.filter(id => id !== labelId); + newIds = currentIds.filter((id) => id !== labelId); } else { newIds = [...currentIds, labelId]; } - const res = await axios.put(`${API_URL}/api/${guildId}/tickets/${ticketId}/labels`, { - label_ids: newIds, - }); + const res = await axios.put( + `${API_URL}/api/${guildId}/tickets/${ticketId}/labels`, + { + label_ids: newIds, + }, + ); if (res.status !== 200) { notifyError(res.data); @@ -297,17 +297,25 @@ } // Update local state - const updatedLabels = newIds.map(id => { - const label = labels.find(l => l.label_id === id); - return label ? { label_id: label.label_id, name: label.name, colour: label.colour } : null; - }).filter(Boolean); + const updatedLabels = newIds + .map((id) => { + const label = labels.find((l) => l.label_id === id); + return label + ? { + label_id: label.label_id, + name: label.name, + colour: label.colour, + } + : null; + }) + .filter(Boolean); data.labels = { ...data.labels, [ticketId]: updatedLabels }; } function handleLabelFilterChange(labelId) { if (selectedLabelIds.includes(labelId)) { - selectedLabelIds = selectedLabelIds.filter(id => id !== labelId); + selectedLabelIds = selectedLabelIds.filter((id) => id !== labelId); } else { selectedLabelIds = [...selectedLabelIds, labelId]; } @@ -316,7 +324,7 @@ // Close label dropdown when clicking outside function handleDocumentClick(event) { if (labelAssignDropdownTicketId !== null) { - const dropdown = event.target.closest('.label-assign-wrapper'); + const dropdown = event.target.closest(".label-assign-wrapper"); if (!dropdown) { labelAssignDropdownTicketId = null; } @@ -460,9 +468,18 @@ {#each labels as label} @@ -482,7 +499,11 @@ Open Tickets {#if isAdmin} - {/if} @@ -534,10 +555,8 @@ "Awaiting Response", )}>Awaiting Response - LabelsLabels @@ -631,34 +650,53 @@
{#if ticketLabels.length > 0} {#each ticketLabels as label} - {label.name} + {/each} {/if} {#if labels.length > 0}
{#if labelAssignDropdownTicketId === ticket.id} -
+
{#each labels as label} -
@@ -688,11 +726,14 @@ {#if showLabelManageModal} - {/if} +{#if showLabelEditor} + (showLabelEditor = false)} + /> +{/if} +