Overhaul ticket list page
This commit is contained in:
parent
733c397631
commit
90ba4cfd21
@ -1,24 +1,38 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"github.com/TicketsBot/GoPanel/database"
|
"github.com/TicketsBot/GoPanel/database"
|
||||||
"github.com/TicketsBot/GoPanel/rpc/cache"
|
"github.com/TicketsBot/GoPanel/rpc/cache"
|
||||||
"github.com/TicketsBot/GoPanel/utils"
|
"github.com/TicketsBot/GoPanel/utils"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rxdn/gdl/objects/user"
|
"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"`
|
TicketId int `json:"id"`
|
||||||
PanelTitle string `json:"panel_title"`
|
PanelId *int `json:"panel_id"`
|
||||||
User *user.User `json:"user,omitempty"`
|
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) {
|
func GetTickets(ctx *gin.Context) {
|
||||||
|
userId := ctx.Keys["userid"].(uint64)
|
||||||
guildId := ctx.Keys["guildid"].(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 {
|
if err != nil {
|
||||||
ctx.JSON(500, utils.ErrorJson(err))
|
ctx.JSON(500, utils.ErrorJson(err))
|
||||||
return
|
return
|
||||||
@ -36,37 +50,38 @@ func GetTickets(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user objects
|
// Get user objects
|
||||||
userIds := make([]uint64, len(tickets))
|
userIds := make([]uint64, 0, int(float32(len(tickets))*1.5))
|
||||||
for i, ticket := range tickets {
|
for _, ticket := range tickets {
|
||||||
userIds[i] = ticket.UserId
|
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 {
|
if err != nil {
|
||||||
ctx.JSON(500, utils.ErrorJson(err))
|
ctx.JSON(500, utils.ErrorJson(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := make([]ticketResponse, len(tickets))
|
data := make([]ticketData, len(tickets))
|
||||||
for i, ticket := range tickets {
|
for i, ticket := range tickets {
|
||||||
var user *user.User
|
data[i] = ticketData{
|
||||||
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{
|
|
||||||
TicketId: ticket.Id,
|
TicketId: ticket.Id,
|
||||||
PanelTitle: panelTitle,
|
PanelId: ticket.PanelId,
|
||||||
User: user,
|
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", api_ticket.GetTickets)
|
||||||
guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket)
|
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", 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)
|
guildAuthApiSupport.DELETE("/tickets/:ticketId", api_ticket.CloseTicket)
|
||||||
|
|
||||||
// Websockets do not support headers: so we must implement authentication over the WS connection
|
// Websockets do not support headers: so we must implement authentication over the WS connection
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
--primary: #995DF3;
|
--primary: #995DF3;
|
||||||
--primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%);
|
--primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%);
|
||||||
--blue: #3472f7;
|
--blue: #3472f7;
|
||||||
|
--background: #272727;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
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}
|
{#if icon !== undefined}
|
||||||
<i class="{icon}"></i>
|
<i class="{icon}"></i>
|
||||||
{/if}
|
{/if}
|
||||||
@ -16,6 +16,8 @@
|
|||||||
export let type = "submit";
|
export let type = "submit";
|
||||||
export let danger = false;
|
export let danger = false;
|
||||||
export let iconOnly = false;
|
export let iconOnly = false;
|
||||||
|
export let noShadow = false;
|
||||||
|
export let clientWidth;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -37,6 +39,9 @@
|
|||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out;
|
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%);
|
box-shadow: 0 4px 4px rgb(0 0 0 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,7 +72,6 @@
|
|||||||
.card-body {
|
.card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 75px;
|
|
||||||
|
|
||||||
color: white;
|
color: white;
|
||||||
margin: 10px 20px;
|
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="discord-container">
|
||||||
<div class="channel-header">
|
<div class="channel-header">
|
||||||
<span id="channel-name">#ticket-{ticketId}</span>
|
<span id="channel-name">#ticket-{ticketId}</span>
|
||||||
@ -13,25 +39,59 @@
|
|||||||
<form on:submit|preventDefault={sendMessage}>
|
<form on:submit|preventDefault={sendMessage}>
|
||||||
<input type="text" class="message-input" bind:value={sendContent} disabled={!isPremium}
|
<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'}">
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {createEventDispatcher} from "svelte";
|
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 ticketId;
|
||||||
export let isPremium = false;
|
export let isPremium = false;
|
||||||
export let messages = [];
|
export let messages = [];
|
||||||
export let container;
|
export let container;
|
||||||
|
|
||||||
|
export let tags = [];
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
let sendContent = '';
|
let sendContent = '';
|
||||||
|
let selectedTag;
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
dispatch('send', sendContent);
|
dispatch('send', {
|
||||||
|
type: 'message',
|
||||||
|
content: sendContent
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -90,6 +150,7 @@
|
|||||||
|
|
||||||
.message-input {
|
.message-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
@ -99,10 +160,69 @@
|
|||||||
border-color: #2e3136 !important;
|
border-color: #2e3136 !important;
|
||||||
background-color: #2e3136 !important;
|
background-color: #2e3136 !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input:focus, .message-input:focus-visible {
|
.message-input:focus, .message-input:focus-visible {
|
||||||
outline-width: 0;
|
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>
|
</style>
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 />
|
<slot />
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -94,3 +94,25 @@ export function checkForParamAndRewrite(param) {
|
|||||||
|
|
||||||
return false;
|
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="parent">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<Card footer={false}>
|
<Card footer={false}>
|
||||||
<span slot="title">Support Teams</span>
|
<span slot="title">Ticket #{ticketId}</span>
|
||||||
<div slot="body" class="body-wrapper">
|
<div slot="body" class="body-wrapper">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">Close Ticket</h2>
|
<h2 class="section-title">Close Ticket</h2>
|
||||||
|
|
||||||
<form on:submit|preventDefault={closeTicket}>
|
<form on:submit|preventDefault={closeTicket}>
|
||||||
<div class="row" style="max-height: 63px; align-items: flex-end"> <!-- hacky -->
|
<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}/>
|
<Input label="Close Reason" placeholder="No reason specified" col1={true} bind:value={closeReason}/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-1">
|
<div class="col-3">
|
||||||
<div style="margin-left: 30px">
|
<div style="margin-left: 30px; margin-bottom: 0.5em">
|
||||||
<Button danger={true} icon="fas fa-lock">Close Ticket</Button>
|
<Button danger={true} noShadow icon="fas fa-lock">Close Ticket</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">View Ticket</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@ -46,6 +46,7 @@
|
|||||||
let closeReason = '';
|
let closeReason = '';
|
||||||
let messages = [];
|
let messages = [];
|
||||||
let isPremium = false;
|
let isPremium = false;
|
||||||
|
let tags = [];
|
||||||
let container;
|
let container;
|
||||||
|
|
||||||
let WS_URL = env.WS_URL || 'ws://localhost:3000';
|
let WS_URL = env.WS_URL || 'ws://localhost:3000';
|
||||||
@ -69,6 +70,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(e) {
|
async function sendMessage(e) {
|
||||||
|
if (e.detail.type === 'message') {
|
||||||
let data = {
|
let data = {
|
||||||
message: e.detail,
|
message: e.detail,
|
||||||
};
|
};
|
||||||
@ -81,6 +83,20 @@
|
|||||||
notifyError(res.data.error);
|
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() {
|
function connectWebsocket() {
|
||||||
@ -124,6 +140,16 @@
|
|||||||
isPremium = res.data.premium;
|
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 () => {
|
withLoadingScreen(async () => {
|
||||||
setDefaultHeaders();
|
setDefaultHeaders();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@ -135,6 +161,7 @@
|
|||||||
|
|
||||||
if (isPremium) {
|
if (isPremium) {
|
||||||
connectWebsocket();
|
connectWebsocket();
|
||||||
|
await loadTags();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -152,7 +179,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 96%;
|
width: 96%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-top: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-wrapper {
|
.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}>
|
<Card footer={false}>
|
||||||
<span slot="title">Open Tickets</span>
|
<span slot="title">Open Tickets</span>
|
||||||
<div slot="body" class="body-wrapper">
|
<div slot="body" class="body-wrapper">
|
||||||
<table class="nice">
|
<table class="nice">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th class:visible={selectedColumns.includes('ID')}>ID</th>
|
||||||
<th>Panel</th>
|
<th class:visible={selectedColumns.includes('Panel')}>Panel</th>
|
||||||
<th>User</th>
|
<th class:visible={selectedColumns.includes('User')}>User</th>
|
||||||
<th>View</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td>{ticket.id}</td>
|
<td class:visible={selectedColumns.includes('ID')}>{ticket.id}</td>
|
||||||
<td>{ticket.panel_title}</td>
|
<td class:visible={selectedColumns.includes('Panel')}>
|
||||||
{#if ticket.user !== undefined}
|
{panel_title || 'Unknown Panel'}
|
||||||
<td>{ticket.user.username}</td>
|
</td>
|
||||||
|
|
||||||
|
<td class:visible={selectedColumns.includes('User')}>
|
||||||
|
{#if user}
|
||||||
|
{user.global_name || user.username}
|
||||||
{:else}
|
{:else}
|
||||||
<td>Unknown</td>
|
Unknown
|
||||||
{/if}
|
{/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">
|
<Navigate to="/manage/{guildId}/tickets/view/{ticket.id}" styles="link">
|
||||||
<Button type="button">View</Button>
|
<Button type="button">View</Button>
|
||||||
</Navigate>
|
</Navigate>
|
||||||
@ -32,21 +99,85 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Card from "../components/Card.svelte";
|
import Card from "../components/Card.svelte";
|
||||||
import {notifyError, withLoadingScreen} from '../js/util'
|
import {getRelativeTime, notifyError, withLoadingScreen} from '../js/util'
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {API_URL} from "../js/constants";
|
import {API_URL} from "../js/constants";
|
||||||
import {setDefaultHeaders} from '../includes/Auth.svelte'
|
import {setDefaultHeaders} from '../includes/Auth.svelte'
|
||||||
import Button from "../components/Button.svelte";
|
import Button from "../components/Button.svelte";
|
||||||
import {Navigate} from 'svelte-router-spa';
|
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;
|
export let currentRoute;
|
||||||
let guildId = currentRoute.namedParams.id;
|
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() {
|
async function loadTickets() {
|
||||||
const res = await axios.get(`${API_URL}/api/${guildId}/tickets`);
|
const res = await axios.get(`${API_URL}/api/${guildId}/tickets`);
|
||||||
@ -55,18 +186,56 @@
|
|||||||
return;
|
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 () => {
|
withLoadingScreen(async () => {
|
||||||
|
loadFilterSettings();
|
||||||
|
|
||||||
setDefaultHeaders();
|
setDefaultHeaders();
|
||||||
await loadTickets();
|
await loadTickets();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.content {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@ -77,4 +246,27 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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>
|
</style>
|
||||||
|
2
go.mod
2
go.mod
@ -8,7 +8,7 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.2.1
|
github.com/BurntSushi/toml v1.2.1
|
||||||
github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2
|
github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2
|
||||||
github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941
|
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/logarchiver v0.0.0-20220326162808-cdf0310f5e1c
|
||||||
github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72
|
github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72
|
||||||
github.com/apex/log v1.1.2
|
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-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 h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w=
|
||||||
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
|
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 h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s=
|
||||||
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c=
|
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=
|
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=
|
||||||
|
Loading…
x
Reference in New Issue
Block a user