Overhaul ticket list page
This commit is contained in:
parent
733c397631
commit
90ba4cfd21
@ -1,24 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/TicketsBot/GoPanel/database"
|
||||
"github.com/TicketsBot/GoPanel/rpc/cache"
|
||||
"github.com/TicketsBot/GoPanel/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rxdn/gdl/objects/user"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ticketResponse struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
ticketData struct {
|
||||
TicketId int `json:"id"`
|
||||
PanelTitle string `json:"panel_title"`
|
||||
User *user.User `json:"user,omitempty"`
|
||||
}
|
||||
PanelId *int `json:"panel_id"`
|
||||
UserId uint64 `json:"user_id,string"`
|
||||
ClaimedBy *uint64 `json:"claimed_by,string"`
|
||||
OpenedAt time.Time `json:"opened_at"`
|
||||
LastResponseTime *time.Time `json:"last_response_time"`
|
||||
LastResponseIsStaff *bool `json:"last_response_is_staff"`
|
||||
}
|
||||
)
|
||||
|
||||
func GetTickets(ctx *gin.Context) {
|
||||
userId := ctx.Keys["userid"].(uint64)
|
||||
guildId := ctx.Keys["guildid"].(uint64)
|
||||
|
||||
tickets, err := database.Client.Tickets.GetGuildOpenTickets(ctx, guildId)
|
||||
tickets, err := database.Client.Tickets.GetGuildOpenTicketsWithMetadata(ctx, guildId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorJson(err))
|
||||
return
|
||||
@ -36,37 +50,38 @@ func GetTickets(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
// Get user objects
|
||||
userIds := make([]uint64, len(tickets))
|
||||
for i, ticket := range tickets {
|
||||
userIds[i] = ticket.UserId
|
||||
userIds := make([]uint64, 0, int(float32(len(tickets))*1.5))
|
||||
for _, ticket := range tickets {
|
||||
userIds = append(userIds, ticket.Ticket.UserId)
|
||||
|
||||
if ticket.ClaimedBy != nil {
|
||||
userIds = append(userIds, *ticket.ClaimedBy)
|
||||
}
|
||||
}
|
||||
|
||||
users, err := cache.Instance.GetUsers(context.Background(), userIds)
|
||||
users, err := cache.Instance.GetUsers(ctx, userIds)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorJson(err))
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]ticketResponse, len(tickets))
|
||||
data := make([]ticketData, len(tickets))
|
||||
for i, ticket := range tickets {
|
||||
var user *user.User
|
||||
if tmp, ok := users[ticket.UserId]; ok {
|
||||
user = &tmp
|
||||
}
|
||||
|
||||
panelTitle := "Unknown"
|
||||
if ticket.PanelId != nil {
|
||||
if tmp, ok := panelTitles[*ticket.PanelId]; ok {
|
||||
panelTitle = tmp
|
||||
}
|
||||
}
|
||||
|
||||
data[i] = ticketResponse{
|
||||
data[i] = ticketData{
|
||||
TicketId: ticket.Id,
|
||||
PanelTitle: panelTitle,
|
||||
User: user,
|
||||
PanelId: ticket.PanelId,
|
||||
UserId: ticket.Ticket.UserId,
|
||||
ClaimedBy: ticket.ClaimedBy,
|
||||
OpenedAt: ticket.OpenTime,
|
||||
LastResponseTime: ticket.LastMessageTime,
|
||||
LastResponseIsStaff: ticket.UserIsStaff,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(200, data)
|
||||
ctx.JSON(200, listTicketsResponse{
|
||||
Tickets: data,
|
||||
PanelTitles: panelTitles,
|
||||
ResolvedUsers: users,
|
||||
SelfId: userId,
|
||||
})
|
||||
}
|
||||
|
211
app/http/endpoints/api/ticket/sendtag.go
Normal file
211
app/http/endpoints/api/ticket/sendtag.go
Normal file
@ -0,0 +1,211 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/TicketsBot/GoPanel/botcontext"
|
||||
"github.com/TicketsBot/GoPanel/database"
|
||||
"github.com/TicketsBot/GoPanel/rpc"
|
||||
"github.com/TicketsBot/GoPanel/utils"
|
||||
"github.com/TicketsBot/GoPanel/utils/types"
|
||||
"github.com/TicketsBot/common/premium"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rxdn/gdl/objects/channel/embed"
|
||||
"github.com/rxdn/gdl/rest"
|
||||
"github.com/rxdn/gdl/rest/request"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type sendTagBody struct {
|
||||
TagId string `json:"tag_id"`
|
||||
}
|
||||
|
||||
func SendTag(ctx *gin.Context) {
|
||||
guildId := ctx.Keys["guildid"].(uint64)
|
||||
userId := ctx.Keys["userid"].(uint64)
|
||||
|
||||
botContext, err := botcontext.ContextForGuild(guildId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get ticket ID
|
||||
ticketId, err := strconv.Atoi(ctx.Param("ticketId"))
|
||||
if err != nil {
|
||||
ctx.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"error": "Invalid ticket ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var body sendTagBody
|
||||
if err := ctx.BindJSON(&body); err != nil {
|
||||
ctx.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"error": "Tag is missing",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify guild is premium
|
||||
premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botContext.Token, botContext.RateLimiter)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorJson(err))
|
||||
return
|
||||
}
|
||||
|
||||
if premiumTier == premium.None {
|
||||
ctx.JSON(402, gin.H{
|
||||
"success": false,
|
||||
"error": "Guild is not premium",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
ticket, err := database.Client.Tickets.Get(ctx, ticketId, guildId)
|
||||
|
||||
// Verify the ticket exists
|
||||
if ticket.UserId == 0 {
|
||||
ctx.JSON(404, gin.H{
|
||||
"success": false,
|
||||
"error": "Ticket not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the user has permission to send to this guild
|
||||
if ticket.GuildId != guildId {
|
||||
ctx.JSON(403, gin.H{
|
||||
"success": false,
|
||||
"error": "Guild ID doesn't match",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get tag
|
||||
tag, ok, err := database.Client.Tag.Get(ctx, guildId, body.TagId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorJson(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
ctx.JSON(404, gin.H{
|
||||
"success": false,
|
||||
"error": "Tag not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Preferably send via a webhook
|
||||
webhook, err := database.Client.Webhooks.Get(ctx, guildId, ticketId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := database.Client.Settings.Get(ctx, guildId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorStr("Failed to fetch settings"))
|
||||
return
|
||||
}
|
||||
|
||||
var embeds []*embed.Embed
|
||||
if tag.Embed != nil {
|
||||
embeds = []*embed.Embed{
|
||||
types.NewCustomEmbed(tag.Embed.CustomEmbed, tag.Embed.Fields).IntoDiscordEmbed(),
|
||||
}
|
||||
}
|
||||
|
||||
if webhook.Id != 0 {
|
||||
var webhookData rest.WebhookBody
|
||||
if settings.AnonymiseDashboardResponses {
|
||||
guild, err := botContext.GetGuild(context.Background(), guildId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorStr("Failed to fetch guild"))
|
||||
return
|
||||
}
|
||||
|
||||
webhookData = rest.WebhookBody{
|
||||
Content: utils.ValueOrZero(tag.Content),
|
||||
Embeds: embeds,
|
||||
Username: guild.Name,
|
||||
AvatarUrl: guild.IconUrl(),
|
||||
}
|
||||
} else {
|
||||
user, err := botContext.GetUser(context.Background(), userId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorStr("Failed to fetch user"))
|
||||
return
|
||||
}
|
||||
|
||||
webhookData = rest.WebhookBody{
|
||||
Content: utils.ValueOrZero(tag.Content),
|
||||
Embeds: embeds,
|
||||
Username: user.EffectiveName(),
|
||||
AvatarUrl: user.AvatarUrl(256),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Ratelimit
|
||||
_, err = rest.ExecuteWebhook(ctx, webhook.Token, nil, webhook.Id, true, webhookData)
|
||||
|
||||
if err != nil {
|
||||
// We can delete the webhook in this case
|
||||
var unwrapped request.RestError
|
||||
if errors.As(err, &unwrapped); unwrapped.StatusCode == 403 || unwrapped.StatusCode == 404 {
|
||||
go database.Client.Webhooks.Delete(ctx, guildId, ticketId)
|
||||
}
|
||||
} else {
|
||||
ctx.JSON(200, gin.H{
|
||||
"success": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
message := utils.ValueOrZero(tag.Content)
|
||||
if !settings.AnonymiseDashboardResponses {
|
||||
user, err := botContext.GetUser(context.Background(), userId)
|
||||
if err != nil {
|
||||
ctx.JSON(500, utils.ErrorStr("Failed to fetch user"))
|
||||
return
|
||||
}
|
||||
|
||||
message = fmt.Sprintf("**%s**: %s", user.EffectiveName(), message)
|
||||
}
|
||||
|
||||
if len(message) > 2000 {
|
||||
message = message[0:1999]
|
||||
}
|
||||
|
||||
if ticket.ChannelId == nil {
|
||||
ctx.JSON(404, gin.H{
|
||||
"success": false,
|
||||
"error": "Ticket channel ID is nil",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = rest.CreateMessage(ctx, botContext.Token, botContext.RateLimiter, *ticket.ChannelId, rest.CreateMessageData{Content: message, Embeds: embeds}); err != nil {
|
||||
ctx.JSON(500, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(200, gin.H{
|
||||
"success": true,
|
||||
})
|
||||
}
|
@ -158,6 +158,7 @@ func StartServer(sm *livechat.SocketManager) {
|
||||
guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets)
|
||||
guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket)
|
||||
guildAuthApiSupport.POST("/tickets/:ticketId", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendMessage)
|
||||
guildAuthApiSupport.POST("/tickets/:ticketId/tag", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendTag)
|
||||
guildAuthApiSupport.DELETE("/tickets/:ticketId", api_ticket.CloseTicket)
|
||||
|
||||
// Websockets do not support headers: so we must implement authentication over the WS connection
|
||||
|
@ -4,6 +4,7 @@
|
||||
--primary: #995DF3;
|
||||
--primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%);
|
||||
--blue: #3472f7;
|
||||
--background: #272727;
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
@ -1,4 +1,4 @@
|
||||
<button on:click isTrigger="1" class:fullWidth class:danger class:iconOnly {disabled} {type}>
|
||||
<button on:click isTrigger="1" class:fullWidth class:danger class:iconOnly class:shadow={!noShadow} {disabled} {type} bind:clientWidth>
|
||||
{#if icon !== undefined}
|
||||
<i class="{icon}"></i>
|
||||
{/if}
|
||||
@ -16,6 +16,8 @@
|
||||
export let type = "submit";
|
||||
export let danger = false;
|
||||
export let iconOnly = false;
|
||||
export let noShadow = false;
|
||||
export let clientWidth;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@ -37,6 +39,9 @@
|
||||
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out;
|
||||
}
|
||||
|
||||
button.shadow {
|
||||
box-shadow: 0 4px 4px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,6 @@
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 75px;
|
||||
|
||||
color: white;
|
||||
margin: 10px 20px;
|
||||
|
98
frontend/src/components/ColumnSelector.svelte
Normal file
98
frontend/src/components/ColumnSelector.svelte
Normal file
@ -0,0 +1,98 @@
|
||||
<section>
|
||||
<Button on:click={toggleDropdown} bind:clientWidth={buttonWidth}>{label}</Button>
|
||||
<div class="dropdown" bind:this={dropdown} style="min-width: {buttonWidth}px">
|
||||
{#each options as option}
|
||||
<div class="option">
|
||||
<input type="checkbox" checked={selected.includes(option)} on:change={() => handleChange(option)} />
|
||||
<span>{option}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
margin-top: 6px;
|
||||
padding: 5px 10px;
|
||||
background-color: var(--background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--primary);
|
||||
box-shadow: 0 14px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Button from "./Button.svelte";
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let label = "Select Columns";
|
||||
export let options = [];
|
||||
export let selected = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleChange(option) {
|
||||
if (!selected) {
|
||||
selected = [];
|
||||
}
|
||||
|
||||
if (selected.includes(option)) {
|
||||
selected = selected.filter((item) => item !== option);
|
||||
} else {
|
||||
selected = [...selected, option];
|
||||
}
|
||||
|
||||
dispatch('change', selected)
|
||||
}
|
||||
|
||||
let dropdown;
|
||||
let buttonWidth;
|
||||
|
||||
function toggleDropdown() {
|
||||
if (dropdown.style.display === 'block') {
|
||||
dropdown.style.display = 'none';
|
||||
} else {
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
let current = e.target;
|
||||
let dropdownFound = false;
|
||||
|
||||
while (current) {
|
||||
if (current.attributes) {
|
||||
if (current.hasAttribute('istrigger')) {
|
||||
dropdownFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current === dropdown) {
|
||||
dropdownFound = true;
|
||||
break;
|
||||
} else {
|
||||
current = current.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dropdownFound) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
@ -1,3 +1,29 @@
|
||||
{#if tagSelectorModal}
|
||||
<div class="modal" transition:fade>
|
||||
<div class="modal-wrapper">
|
||||
<Card footer footerRight fill={false}>
|
||||
<span slot="title">Send Tag</span>
|
||||
|
||||
<div slot="body" class="modal-inner">
|
||||
<Dropdown col2 label="Select a tag..." bind:value={selectedTag}>
|
||||
{#each Object.keys(tags) as tag}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div slot="footer" style="gap: 12px">
|
||||
<Button danger icon="fas fa-times" on:click={() => tagSelectorModal = false}>Close</Button>
|
||||
<Button icon="fas fa-paper-plane" on:click={sendTag}>Send</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="discord-container">
|
||||
<div class="channel-header">
|
||||
<span id="channel-name">#ticket-{ticketId}</span>
|
||||
@ -13,25 +39,59 @@
|
||||
<form on:submit|preventDefault={sendMessage}>
|
||||
<input type="text" class="message-input" bind:value={sendContent} disabled={!isPremium}
|
||||
placeholder="{isPremium ? `Message #ticket-${ticketId}` : 'Premium users can receive messages in real-time and respond to tickets through the dashboard'}">
|
||||
{#if isPremium}
|
||||
<i class="fas fa-paper-plane send-button" on:click={sendMessage}/>
|
||||
<div class="tag-selector">
|
||||
<Button type="button" noShadow on:click={openTagSelector}>Select Tag</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {fade} from "svelte/transition";
|
||||
import Button from "./Button.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Dropdown from "./form/Dropdown.svelte";
|
||||
|
||||
export let ticketId;
|
||||
export let isPremium = false;
|
||||
export let messages = [];
|
||||
export let container;
|
||||
|
||||
export let tags = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let sendContent = '';
|
||||
let selectedTag;
|
||||
|
||||
function sendMessage() {
|
||||
dispatch('send', sendContent);
|
||||
dispatch('send', {
|
||||
type: 'message',
|
||||
content: sendContent
|
||||
});
|
||||
sendContent = '';
|
||||
}
|
||||
|
||||
let tagSelectorModal = false;
|
||||
|
||||
function openTagSelector() {
|
||||
tagSelectorModal = true;
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
function sendTag() {
|
||||
tagSelectorModal = false;
|
||||
|
||||
dispatch('send', {
|
||||
type: 'tag',
|
||||
tag_id: selectedTag
|
||||
});
|
||||
|
||||
selectedTag = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@ -90,6 +150,7 @@
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
@ -99,10 +160,69 @@
|
||||
border-color: #2e3136 !important;
|
||||
background-color: #2e3136 !important;
|
||||
color: white !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-input:focus, .message-input:focus-visible {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-selector {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/** modal **/
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
display: flex;
|
||||
width: 60%;
|
||||
margin: 10% auto auto auto;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 2%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1280px) {
|
||||
.modal-wrapper {
|
||||
width: 96%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 500;
|
||||
background-color: #000;
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
|
@ -9,7 +9,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<select id="input" class="form-input" on:change bind:value={value} {disabled}>
|
||||
<select id="input" class="form-input" on:change bind:value={value} {disabled} style="margin: 0">
|
||||
<slot />
|
||||
</select>
|
||||
</div>
|
||||
|
@ -94,3 +94,25 @@ export function checkForParamAndRewrite(param) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const units = {
|
||||
year : 24 * 60 * 60 * 1000 * 365,
|
||||
month : 24 * 60 * 60 * 1000 * 365/12,
|
||||
day : 24 * 60 * 60 * 1000,
|
||||
hour : 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
second: 1000
|
||||
};
|
||||
|
||||
// From https://stackoverflow.com/a/53800501
|
||||
export function getRelativeTime(timestamp) {
|
||||
const elapsed = timestamp - new Date();
|
||||
|
||||
// "Math.abs" accounts for both "past" & "future" scenarios
|
||||
for (const u in units) {
|
||||
if (Math.abs(elapsed) > units[u] || u === 'second') {
|
||||
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||
return rtf.format(Math.round(elapsed / units[u]), u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,19 @@
|
||||
<div class="parent">
|
||||
<div class="content">
|
||||
<Card footer={false}>
|
||||
<span slot="title">Support Teams</span>
|
||||
<span slot="title">Ticket #{ticketId}</span>
|
||||
<div slot="body" class="body-wrapper">
|
||||
<div class="section">
|
||||
<h2 class="section-title">Close Ticket</h2>
|
||||
|
||||
<form on:submit|preventDefault={closeTicket}>
|
||||
<div class="row" style="max-height: 63px; align-items: flex-end"> <!-- hacky -->
|
||||
<div class="col-3" style="margin-bottom: 0 !important;">
|
||||
<div class="col-2" style="margin-bottom: 0 !important;">
|
||||
<Input label="Close Reason" placeholder="No reason specified" col1={true} bind:value={closeReason}/>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<div style="margin-left: 30px">
|
||||
<Button danger={true} icon="fas fa-lock">Close Ticket</Button>
|
||||
<div class="col-3">
|
||||
<div style="margin-left: 30px; margin-bottom: 0.5em">
|
||||
<Button danger={true} noShadow icon="fas fa-lock">Close Ticket</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">View Ticket</h2>
|
||||
<DiscordMessages {ticketId} {isPremium} {messages} bind:container on:send={sendMessage} />
|
||||
<DiscordMessages {ticketId} {isPremium} {tags} {messages} bind:container on:send={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@ -46,6 +46,7 @@
|
||||
let closeReason = '';
|
||||
let messages = [];
|
||||
let isPremium = false;
|
||||
let tags = [];
|
||||
let container;
|
||||
|
||||
let WS_URL = env.WS_URL || 'ws://localhost:3000';
|
||||
@ -69,6 +70,7 @@
|
||||
}
|
||||
|
||||
async function sendMessage(e) {
|
||||
if (e.detail.type === 'message') {
|
||||
let data = {
|
||||
message: e.detail,
|
||||
};
|
||||
@ -81,6 +83,20 @@
|
||||
notifyError(res.data.error);
|
||||
}
|
||||
}
|
||||
} else if (e.detail.type === 'tag') {
|
||||
let data = {
|
||||
tag_id: e.detail.tag_id,
|
||||
};
|
||||
|
||||
const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}/tag`, data);
|
||||
if (res.status !== 200) {
|
||||
if (res.status === 429) {
|
||||
notifyRatelimit();
|
||||
} else {
|
||||
notifyError(res.data.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebsocket() {
|
||||
@ -124,6 +140,16 @@
|
||||
isPremium = res.data.premium;
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
const res = await axios.get(`${API_URL}/api/${guildId}/tags`);
|
||||
if (res.status !== 200) {
|
||||
notifyError(res.data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
tags = res.data;
|
||||
}
|
||||
|
||||
withLoadingScreen(async () => {
|
||||
setDefaultHeaders();
|
||||
await Promise.all([
|
||||
@ -135,6 +161,7 @@
|
||||
|
||||
if (isPremium) {
|
||||
connectWebsocket();
|
||||
await loadTags();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -152,7 +179,6 @@
|
||||
justify-content: space-between;
|
||||
width: 96%;
|
||||
height: 100%;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.body-wrapper {
|
||||
|
@ -1,27 +1,94 @@
|
||||
<div class="content">
|
||||
<main>
|
||||
<Card footer={false}>
|
||||
<span slot="title">
|
||||
<i class="fas fa-filter"></i>
|
||||
Filters
|
||||
</span>
|
||||
<div slot="body" class="filter-wrapper">
|
||||
<div>
|
||||
<label class="form-label">Show Columns</label>
|
||||
<ColumnSelector
|
||||
options={["ID", "Panel", "User", "Opened Time", "Claimed By", "Last Message Time", "Awaiting Response"]}
|
||||
bind:selected={selectedColumns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dropdown col2 label="Sort Tickets By..." bind:value={sortMethod}>
|
||||
<option value="id_desc">Ticket ID (Descending)</option>
|
||||
<option value="unclaimed">Unclaimed & Awaiting Response First</option>
|
||||
</Dropdown>
|
||||
|
||||
<Checkbox label="Only Show Unclaimed Tickets & Tickets Claimed By Me" bind:value={onlyShowMyTickets} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card footer={false}>
|
||||
<span slot="title">Open Tickets</span>
|
||||
<div slot="body" class="body-wrapper">
|
||||
<table class="nice">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Panel</th>
|
||||
<th>User</th>
|
||||
<th>View</th>
|
||||
<th class:visible={selectedColumns.includes('ID')}>ID</th>
|
||||
<th class:visible={selectedColumns.includes('Panel')}>Panel</th>
|
||||
<th class:visible={selectedColumns.includes('User')}>User</th>
|
||||
<th class:visible={selectedColumns.includes('Opened Time')}>Opened</th>
|
||||
<th class:visible={selectedColumns.includes('Claimed By')}>Claimed By</th>
|
||||
<th class:visible={selectedColumns.includes('Last Message Time')}>Last Message</th>
|
||||
<th class:visible={selectedColumns.includes('Awaiting Response')}>Awaiting Response</th>
|
||||
<th class="visible">View</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tickets as ticket}
|
||||
{#each filtered as ticket}
|
||||
{@const user = data.resolved_users[ticket.user_id]}
|
||||
{@const claimer = ticket.claimed_by ? data.resolved_users[ticket.claimed_by] : null}
|
||||
{@const panel_title = data.panel_titles[ticket.panel_id?.toString()]}
|
||||
|
||||
<tr>
|
||||
<td>{ticket.id}</td>
|
||||
<td>{ticket.panel_title}</td>
|
||||
{#if ticket.user !== undefined}
|
||||
<td>{ticket.user.username}</td>
|
||||
<td class:visible={selectedColumns.includes('ID')}>{ticket.id}</td>
|
||||
<td class:visible={selectedColumns.includes('Panel')}>
|
||||
{panel_title || 'Unknown Panel'}
|
||||
</td>
|
||||
|
||||
<td class:visible={selectedColumns.includes('User')}>
|
||||
{#if user}
|
||||
{user.global_name || user.username}
|
||||
{:else}
|
||||
<td>Unknown</td>
|
||||
Unknown
|
||||
{/if}
|
||||
<td>
|
||||
</td>
|
||||
|
||||
<td class:visible={selectedColumns.includes('Opened Time')}>
|
||||
{getRelativeTime(new Date(ticket.opened_at))}
|
||||
</td>
|
||||
|
||||
<td class:visible={selectedColumns.includes('Claimed By')}>
|
||||
{#if ticket.claimed_by === null}
|
||||
<b>Unclaimed</b>
|
||||
{:else if claimer}
|
||||
{claimer.global_name || claimer.username}
|
||||
{:else}
|
||||
Unknown
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class:visible={selectedColumns.includes('Last Message Time')}>
|
||||
{#if ticket.last_response_time}
|
||||
{getRelativeTime(new Date(ticket.last_response_time))}
|
||||
{:else}
|
||||
Never
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class:visible={selectedColumns.includes('Awaiting Response')}>
|
||||
{#if ticket.last_response_is_staff}
|
||||
No
|
||||
{:else}
|
||||
<b>Yes</b>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="visible">
|
||||
<Navigate to="/manage/{guildId}/tickets/view/{ticket.id}" styles="link">
|
||||
<Button type="button">View</Button>
|
||||
</Navigate>
|
||||
@ -32,21 +99,85 @@
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import Card from "../components/Card.svelte";
|
||||
import {notifyError, withLoadingScreen} from '../js/util'
|
||||
import {getRelativeTime, notifyError, withLoadingScreen} from '../js/util'
|
||||
import axios from "axios";
|
||||
import {API_URL} from "../js/constants";
|
||||
import {setDefaultHeaders} from '../includes/Auth.svelte'
|
||||
import Button from "../components/Button.svelte";
|
||||
import {Navigate} from 'svelte-router-spa';
|
||||
import ColumnSelector from "../components/ColumnSelector.svelte";
|
||||
import Dropdown from "../components/form/Dropdown.svelte";
|
||||
import Checkbox from "../components/form/Checkbox.svelte";
|
||||
|
||||
export let currentRoute;
|
||||
let guildId = currentRoute.namedParams.id;
|
||||
|
||||
let tickets = [];
|
||||
let selectedColumns = ['ID', 'Panel', 'User', 'Claimed By', 'Last Message Time', 'Awaiting Response'];
|
||||
let sortMethod = "unclaimed";
|
||||
let onlyShowMyTickets = false;
|
||||
|
||||
let data = {
|
||||
tickets: [],
|
||||
panel_titles: {},
|
||||
resolved_users: {}
|
||||
};
|
||||
|
||||
let filtered = [];
|
||||
|
||||
function filterTickets() {
|
||||
filtered = data.tickets.filter(ticket => {
|
||||
if (onlyShowMyTickets) {
|
||||
return ticket.claimed_by === null || ticket.claimed_by === data.self_id;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply sort
|
||||
if (sortMethod === 'id_desc') {
|
||||
filtered.sort((a, b) => b.id - a.id);
|
||||
} else if (sortMethod === 'unclaimed') {
|
||||
filtered.sort((a, b) => {
|
||||
// Place unclaimed tickets at the top. The priority of fields used for sorting is:
|
||||
// 1. Unclaimed tickets, or tickets claimed by the current user
|
||||
// 2. Awaiting Response
|
||||
// 3. Last Response Time
|
||||
|
||||
// Unclaimed tickets at the top
|
||||
if (a.claimed_by === null && b.claimed_by !== null) {
|
||||
return -1;
|
||||
}
|
||||
if (a.claimed_by !== null && b.claimed_by === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.claimed_by === data.self_id && b.claimed_by !== data.self_id) {
|
||||
return -1;
|
||||
}
|
||||
if (a.claimed_by !== data.self_id && b.claimed_by === data.self_id) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Among claimed tickets, those awaiting response at the top
|
||||
if (!a.last_response_is_staff && b.last_response_is_staff) {
|
||||
return -1;
|
||||
}
|
||||
if (a.last_response_is_staff && !b.last_response_is_staff) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Among tickets not awaiting response, sort by last response time
|
||||
const aLastResponseTime = new Date(a.last_response_time || 0);
|
||||
const bLastResponseTime = new Date(b.last_response_time || 0);
|
||||
|
||||
return bLastResponseTime - aLastResponseTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
const res = await axios.get(`${API_URL}/api/${guildId}/tickets`);
|
||||
@ -55,18 +186,56 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tickets = res.data;
|
||||
data = res.data;
|
||||
filterTickets();
|
||||
}
|
||||
|
||||
const columnStorageKey = 'ticket_list:selected_columns';
|
||||
const sortOrderKey = 'ticket_list:sort_order';
|
||||
const onlyMyTicketsKey = 'ticket_list:only_my_tickets';
|
||||
|
||||
$: selectedColumns, updateFilters();
|
||||
$: sortMethod, updateFilters();
|
||||
$: onlyShowMyTickets, updateFilters();
|
||||
|
||||
function updateFilters() {
|
||||
window.localStorage.setItem(columnStorageKey, JSON.stringify(selectedColumns));
|
||||
window.localStorage.setItem(sortOrderKey, sortMethod);
|
||||
window.localStorage.setItem(onlyMyTicketsKey, JSON.stringify(onlyShowMyTickets));
|
||||
|
||||
filterTickets();
|
||||
}
|
||||
|
||||
function loadFilterSettings() {
|
||||
const columns = window.localStorage.getItem(columnStorageKey);
|
||||
if (columns) {
|
||||
selectedColumns = JSON.parse(columns);
|
||||
}
|
||||
|
||||
const sortOrder = window.localStorage.getItem(sortOrderKey);
|
||||
if (sortOrder) {
|
||||
sortMethod = sortOrder;
|
||||
}
|
||||
|
||||
const onlyMyTickets = window.localStorage.getItem(onlyMyTicketsKey);
|
||||
if (onlyMyTickets) {
|
||||
onlyShowMyTickets = JSON.parse(onlyMyTickets);
|
||||
}
|
||||
}
|
||||
|
||||
withLoadingScreen(async () => {
|
||||
loadFilterSettings();
|
||||
|
||||
setDefaultHeaders();
|
||||
await loadTickets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -77,4 +246,27 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
th, td {
|
||||
display: none;
|
||||
}
|
||||
|
||||
th.visible, td.visible {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
.filter-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
2
go.mod
2
go.mod
@ -8,7 +8,7 @@ require (
|
||||
github.com/BurntSushi/toml v1.2.1
|
||||
github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2
|
||||
github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941
|
||||
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a
|
||||
github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978
|
||||
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c
|
||||
github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72
|
||||
github.com/apex/log v1.1.2
|
||||
|
2
go.sum
2
go.sum
@ -72,6 +72,8 @@ github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506 h1:mU2wx9pyb72
|
||||
github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
|
||||
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w=
|
||||
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
|
||||
github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978 h1:31mE0z9PKGc2X8mImRq79x3/kosHoriWG5MLdf/lkJU=
|
||||
github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
|
||||
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s=
|
||||
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c=
|
||||
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=
|
||||
|
Loading…
x
Reference in New Issue
Block a user