Merge pull request #258 from Moonlight-Panel/ImproveTicketSystemUI

Improved ticket system ui and some backend code
This commit is contained in:
Marcel Baumgartner 2023-08-09 00:36:33 +02:00 committed by GitHub
commit ef92dd47ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1351 additions and 885 deletions

View file

@ -45,9 +45,18 @@ public class DatabaseCheckupService
{
Logger.Info($"{migrations.Length} migrations pending. Updating now");
var backupHelper = new BackupHelper();
await backupHelper.CreateBackup(
PathBuilder.File("storage", "backups", $"{new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()}.zip"));
try
{
var backupHelper = new BackupHelper();
await backupHelper.CreateBackup(
PathBuilder.File("storage", "backups", $"{new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()}.zip"));
}
catch (Exception e)
{
Logger.Fatal("Unable to create backup");
Logger.Fatal(e);
Logger.Fatal("Moonlight will continue to start and apply the migrations without a backup");
}
Logger.Info("Applying migrations");

View file

@ -1,4 +1,5 @@
using System.Text;
using Microsoft.AspNetCore.Components;
using Moonlight.App.Services;
namespace Moonlight.App.Helpers;
@ -163,4 +164,22 @@ public static class Formatter
double gigabytes = (double)bytes / gbMultiplier;
return gigabytes;
}
public static RenderFragment FormatLineBreaks(string content)
{
return builder =>
{
int i = 0;
var arr = content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var line in arr)
{
builder.AddContent(i, line);
if (i++ != arr.Length - 1)
{
builder.AddMarkupContent(i, "<br/>");
}
}
};
}
}

View file

@ -31,9 +31,8 @@ public class DiscordNotificationService
Client = new(config.WebHook);
AppUrl = configService.Get().Moonlight.AppUrl;
Event.On<User>("supportChat.new", this, OnNewSupportChat);
Event.On<SupportChatMessage>("supportChat.message", this, OnSupportChatMessage);
Event.On<User>("supportChat.close", this, OnSupportChatClose);
Event.On<Ticket>("tickets.new", this, OnNewTicket);
Event.On<Ticket>("tickets.status", this, OnTicketStatusUpdated);
Event.On<User>("user.rating", this, OnUserRated);
Event.On<User>("billing.completed", this, OnBillingCompleted);
Event.On<BlocklistIp>("ddos.add", this, OnIpBlockListed);
@ -44,6 +43,24 @@ public class DiscordNotificationService
}
}
private async Task OnTicketStatusUpdated(Ticket ticket)
{
await SendNotification("", builder =>
{
builder.Title = "Ticket status has been updated";
builder.AddField("Issue topic", ticket.IssueTopic);
builder.AddField("Status", ticket.Status);
if (ticket.AssignedTo != null)
{
builder.AddField("Assigned to", $"{ticket.AssignedTo.FirstName} {ticket.AssignedTo.LastName}");
}
builder.Color = Color.Green;
builder.Url = $"{AppUrl}/admin/support/view/{ticket.Id}";
});
}
private async Task OnIpBlockListed(BlocklistIp blocklistIp)
{
await SendNotification("", builder =>
@ -85,46 +102,14 @@ public class DiscordNotificationService
});
}
private async Task OnSupportChatClose(User user)
private async Task OnNewTicket(Ticket ticket)
{
await SendNotification("", builder =>
{
builder.Title = "A new support chat has been marked as closed";
builder.Color = Color.Red;
builder.AddField("Email", user.Email);
builder.AddField("Firstname", user.FirstName);
builder.AddField("Lastname", user.LastName);
builder.Url = $"{AppUrl}/admin/support/view/{user.Id}";
});
}
private async Task OnSupportChatMessage(SupportChatMessage message)
{
if(message.Sender == null)
return;
await SendNotification("", builder =>
{
builder.Title = "New message in support chat";
builder.Color = Color.Blue;
builder.AddField("Message", message.Content);
builder.Author = new EmbedAuthorBuilder()
.WithName($"{message.Sender.FirstName} {message.Sender.LastName}")
.WithIconUrl(ResourceService.Avatar(message.Sender));
builder.Url = $"{AppUrl}/admin/support/view/{message.Recipient.Id}";
});
}
private async Task OnNewSupportChat(User user)
{
await SendNotification("", builder =>
{
builder.Title = "A new support chat has been marked as active";
builder.Title = "A new ticket has been created";
builder.AddField("Issue topic", ticket.IssueTopic);
builder.Color = Color.Green;
builder.AddField("Email", user.Email);
builder.AddField("Firstname", user.FirstName);
builder.AddField("Lastname", user.LastName);
builder.Url = $"{AppUrl}/admin/support/view/{user.Id}";
builder.Url = $"{AppUrl}/admin/support/view/{ticket.Id}";
});
}

View file

@ -24,29 +24,6 @@ public class TicketAdminService
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetAssigned()
{
return await TicketServerService.GetUserAssignedTickets(IdentityService.User);
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetUnAssigned()
{
return await TicketServerService.GetUnAssignedTickets();
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries,
TicketSubject subject, int subjectId)
{
return await TicketServerService.Create(
IdentityService.User,
issueTopic,
issueDescription,
issueTries,
subject,
subjectId
);
}
public async Task<TicketMessage> Send(string content, IBrowserFile? file = null)
{
string? attachment = null;
@ -83,13 +60,8 @@ public class TicketAdminService
return await TicketServerService.GetMessages(Ticket!);
}
public async Task Claim()
public async Task SetClaim(User? user)
{
await TicketServerService.Claim(Ticket!, IdentityService.User);
}
public async Task UnClaim()
{
await TicketServerService.Claim(Ticket!);
await TicketServerService.SetClaim(Ticket!, user);
}
}

View file

@ -23,11 +23,6 @@ public class TicketClientService
IdentityService = identityService;
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> Get()
{
return await TicketServerService.GetUserTickets(IdentityService.User);
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries, TicketSubject subject, int subjectId)
{

View file

@ -49,6 +49,12 @@ public class TicketServerService
// Do automatic stuff here
await SendSystemMessage(ticket, ConfigService.Get().Moonlight.Tickets.WelcomeMessage);
if (ticket.Subject != TicketSubject.Other)
{
await SendMessage(ticket, creatorUser, $"Subject :\n\n{ticket.Subject}: {ticket.SubjectId}");
}
//TODO: Check for opening times
return ticket;
@ -137,85 +143,7 @@ public class TicketServerService
return message;
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.CreatedBy.Id == u.Id)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserAssignedTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.Status != TicketStatus.Closed)
.Where(x => x.AssignedTo.Id == u.Id)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUnAssignedTickets()
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo == null)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<TicketMessage[]> GetMessages(Ticket ticket)
{
using var scope = ServiceScopeFactory.CreateScope();
@ -230,7 +158,7 @@ public class TicketServerService
return Task.FromResult(tickets.Messages.ToArray());
}
public async Task Claim(Ticket t, User? u = null)
public async Task SetClaim(Ticket t, User? u = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
@ -245,5 +173,8 @@ public class TicketServerService
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
var claimName = user == null ? "None" : user.FirstName + " " + user.LastName;
await SendSystemMessage(ticket, $"Ticked claim has been set to {claimName}");
}
}

View file

@ -7,6 +7,7 @@
@inject ResourceService ResourceService
@inject SmartTranslateService SmartTranslateService
<div class="scroll-y me-n5 pe-5" style="max-height: 50vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages.OrderByDescending(x => x.Id)) // Reverse messages to use auto scrolling
{
if (message.IsSupportMessage)
@ -18,10 +19,16 @@
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
@if (message.Sender != null)
{
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
}
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
@if (message.Sender != null)
{
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
}
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@ -62,8 +69,11 @@
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
<div class="symbol symbol-35px symbol-circle ">
@if (message.Sender != null)
{
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
}
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
@ -120,7 +130,10 @@
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
@if (message.Sender != null)
{
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
}
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
@ -169,8 +182,11 @@
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
<div class="symbol symbol-35px symbol-circle ">
@if (message.Sender != null)
{
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
}
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@ -208,6 +224,7 @@
}
}
}
</div>
@code
{

View file

@ -1,379 +1,278 @@
@page "/admin/support"
@page "/admin/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities
@using Moonlight.App.Events
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Microsoft.EntityFrameworkCore
@using BlazorTable
@using Moonlight.App.Helpers
@using Moonlight.App.Services
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketAdminService AdminService
@inject Repository<Ticket> TicketRepository
@inject SmartTranslateService SmartTranslateService
@inject EventSystem EventSystem
@inject IdentityService IdentityService
@attribute [PermissionRequired(nameof(Permissions.AdminSupport))]
@implements IDisposable
<div class="d-flex flex-column flex-lg-row">
<div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="card card-flush">
<div class="card-body pt-5">
<div class="scroll-y me-n5 pe-5 h-200px h-lg-auto" data-kt-scroll="true" data-kt-scroll-activate="{default: false, lg: true}" data-kt-scroll-max-height="auto" data-kt-scroll-dependencies="#kt_header, #kt_app_header, #kt_toolbar, #kt_app_toolbar, #kt_footer, #kt_app_footer, #kt_chat_contacts_header" data-kt-scroll-wrappers="#kt_content, #kt_app_content, #kt_chat_contacts_body" data-kt-scroll-offset="5px" style="max-height: 601px;">
<div class="separator separator-content border-primary mb-10 mt-5">
<span class="w-250px fw-bold fs-5">
<TL>Unassigned tickets</TL>
</span>
<div class="row mb-5">
<LazyLoader Load="LoadStatistics">
<div class="col-12 col-lg-6 col-xl">
<a class="mt-4 card" href="/admin/servers">
<div class="card-body">
<div class="row align-items-center gx-0">
<div class="col">
<h6 class="text-uppercase text-muted mb-2">
<TL>Total Tickets</TL>
</h6>
<span class="h2 mb-0">
@(TotalTicketCount)
</span>
</div>
<div class="col-auto">
<span class="h2 text-muted mb-0">
<i class="text-primary bx bx-purchase-tag bx-lg"></i>
</span>
</div>
</div>
</div>
@foreach (var ticket in UnAssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</a>
</div>
<div class="col-12 col-lg-6 col-xl">
<a class="mt-4 card" href="/admin/webspaces">
<div class="card-body">
<div class="row align-items-center gx-0">
<div class="col">
<h6 class="text-uppercase text-muted mb-2">
<TL>Unassigned tickets</TL>
</h6>
<span class="h2 mb-0">
@(UnAssignedTicketCount)
</span>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
<div class="col-auto">
<span class="h2 text-muted mb-0">
<i class="text-primary bx bxs-bell-ring bx-lg"></i>
</span>
</div>
</div>
if (ticket.Key != UnAssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
@if (AssignedTickets.Any())
{
<div class="separator separator-content border-primary mb-5 mt-8">
<span class="w-250px fw-bold fs-5">
<TL>Assigned tickets</TL>
</span>
</div>
}
@foreach (var ticket in AssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
</a>
</div>
<div class="col-12 col-lg-6 col-xl">
<a class="mt-4 card" href="/admin/domains">
<div class="card-body">
<div class="row align-items-center gx-0">
<div class="col">
<h6 class="text-uppercase text-muted mb-2">
<TL>Pending tickets</TL>
</h6>
<span class="h2 mb-0">
@(PendingTicketCount)
</span>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
<div class="col-auto">
<span class="h2 text-muted mb-">
<i class="text-primary bx bx-hourglass bx-lg"></i>
</span>
</div>
</div>
</div>
</a>
</div>
<div class="col-12 col-lg-6 col-xl">
<a class="mt-4 card" href="/admin/users">
<div class="card-body">
<div class="row align-items-center gx-0">
<div class="col">
<h6 class="text-uppercase text-muted mb-2">
<TL>Closed tickets</TL>
</h6>
<span class="h2 mb-0">
@(ClosedTicketCount)
</span>
</div>
<div class="col-auto">
<span class="h2 text-muted mb-">
<i class="text-primary bx bx-lock bx-lg"></i>
</span>
</div>
</div>
</div>
</a>
</div>
</LazyLoader>
</div>
if (ticket.Key != AssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
<div class="card">
<div class="card-header">
<span class="card-title">
<TL>Ticket overview</TL>
</span>
<div class="card-toolbar">
<div class="btn-group">
<WButton Text="@(SmartTranslateService.Translate("Overview"))" CssClasses="btn-secondary" OnClick="() => UpdateFilter(0)" />
<WButton Text="@(SmartTranslateService.Translate("Unassigned tickets"))" CssClasses="btn-secondary" OnClick="() => UpdateFilter(1)" />
<WButton Text="@(SmartTranslateService.Translate("My tickets"))" CssClasses="btn-secondary" OnClick="() => UpdateFilter(2)" />
<WButton Text="@(SmartTranslateService.Translate("All tickets"))" CssClasses="btn-secondary" OnClick="() => UpdateFilter(3)" />
</div>
</div>
</div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (AdminService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(AdminService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (AdminService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(AdminService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (AdminService.Ticket.Priority)
<div class="card-body">
<LazyLoader @ref="TicketLazyLoader" Load="LoadTickets">
<div class="table-responsive">
<Table TableItem="Ticket" Items="AllTickets" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Filterable="true" Sortable="true"/>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Assigned to"))" Field="@(x => x.AssignedTo)" Filterable="true" Sortable="true">
<Template>
<span>@(context.AssignedTo == null ? "None" : context.AssignedTo.FirstName + " " + context.AssignedTo.LastName)</span>
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Ticket title"))" Field="@(x => x.IssueTopic)" Filterable="true" Sortable="false"/>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("User"))" Field="@(x => x.CreatedBy)" Filterable="true" Sortable="true">
<Template>
<span>@(context.CreatedBy.FirstName) @(context.CreatedBy.LastName)</span>
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Created at"))" Field="@(x => x.CreatedAt)" Filterable="true" Sortable="true">
<Template>
<span>@(Formatter.FormatDate(context.CreatedAt))</span>
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Priority"))" Field="@(x => x.Priority)" Filterable="true" Sortable="true">
<Template>
@switch (context.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
<span class="badge bg-success">@(context.Priority)</span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
<span class="badge bg-primary">@(context.Priority)</span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
<span class="badge bg-warning">@(context.Priority)</span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
<span class="badge bg-danger">@(context.Priority)</span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(AdminService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="input-group">
<div class="me-3">
@if (AdminService.Ticket!.AssignedTo == null)
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Status)" Filterable="true" Sortable="true">
<Template>
@switch (context.Status)
{
<WButton Text="@(SmartTranslateService.Translate("Claim"))" OnClick="AdminService.Claim"/>
case TicketStatus.Closed:
<span class="badge bg-danger">@(context.Status)</span>
break;
case TicketStatus.Open:
<span class="badge bg-success">@(context.Status)</span>
break;
case TicketStatus.Pending:
<span class="badge bg-warning">@(context.Status)</span>
break;
case TicketStatus.WaitingForUser:
<span class="badge bg-primary">@(context.Status)</span>
break;
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Unclaim"))" OnClick="AdminService.UnClaim"/>
}
</div>
<select @bind="Priority" class="form-select rounded-start">
@foreach (var priority in (TicketPriority[])Enum.GetValues(typeof(TicketPriority)))
{
if (Priority == priority)
{
<option value="@(priority)" selected="">@(priority)</option>
}
else
{
<option value="@(priority)">@(priority)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update priority"))"
CssClasses="btn-primary"
OnClick="UpdatePriority">
</WButton>
<select @bind="Status" class="form-select">
@foreach (var status in (TicketStatus[])Enum.GetValues(typeof(TicketStatus)))
{
if (Status == status)
{
<option value="@(status)" selected="">@(status)</option>
}
else
{
<option value="@(status)">@(status)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update status"))"
CssClasses="btn-primary"
OnClick="UpdateStatus">
</WButton>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (AdminService.Ticket == null)
{
}
else
{
<TicketMessageView Messages="Messages" ViewAsSupport="true"/>
}
</Template>
</Column>
<Column TableItem="Ticket" Title="" Field="@(x => x.Id)" Filterable="false" Sortable="false">
<Template>
<a class="btn btn-sm btn-primary" href="/admin/support/view/@(context.Id)">
<TL>Open</TL>
</a>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</div>
@if (AdminService.Ticket != null)
{
<div class="card-footer pt-4" id="kt_chat_messenger_footer">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</LazyLoader>
</div>
</div>
</div>
@code
{
[Parameter]
public int Id { get; set; }
private int TotalTicketCount;
private int UnAssignedTicketCount;
private int PendingTicketCount;
private int ClosedTicketCount;
private Dictionary<Ticket, TicketMessage?> AssignedTickets;
private Dictionary<Ticket, TicketMessage?> UnAssignedTickets;
private List<TicketMessage> Messages = new();
private string MessageText;
private SmartFileSelect FileSelect;
private Ticket[] AllTickets;
private int Filter = 0;
private TicketPriority Priority;
private TicketStatus Status;
private LazyLoader TicketLazyLoader;
protected override async Task OnParametersSetAsync()
private Task LoadStatistics(LazyLoader _)
{
await Unsubscribe();
await ReloadTickets();
await Subscribe();
TotalTicketCount = TicketRepository
.Get()
.Count();
await InvokeAsync(StateHasChanged);
UnAssignedTicketCount = TicketRepository
.Get()
.Include(x => x.AssignedTo)
.Where(x => x.Status != TicketStatus.Closed)
.Count(x => x.AssignedTo == null);
PendingTicketCount = TicketRepository
.Get()
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo != null)
.Count(x => x.Status != TicketStatus.Closed);
ClosedTicketCount = TicketRepository
.Get()
.Count(x => x.Status == TicketStatus.Closed);
return Task.CompletedTask;
}
private async Task UpdatePriority()
private Task LoadTickets(LazyLoader _)
{
await AdminService.UpdatePriority(Priority);
}
private async Task UpdateStatus()
{
await AdminService.UpdateStatus(Status);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if (string.IsNullOrEmpty(MessageText))
return;
var msg = await AdminService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async _ =>
switch (Filter)
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (AdminService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{AdminService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{AdminService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
default:
AllTickets = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.AssignedTo)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
break;
case 1:
AllTickets = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo == null)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
break;
case 2:
AllTickets = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo != null)
.Where(x => x.AssignedTo!.Id == IdentityService.User.Id)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
break;
case 3:
AllTickets = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.AssignedTo)
.ToArray();
break;
}
return Task.CompletedTask;
}
private async Task Unsubscribe()
private async Task UpdateFilter(int filterId)
{
await EventSystem.Off("tickets.new", this);
if (AdminService.Ticket != null)
{
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
AdminService.Ticket = null;
AssignedTickets = await AdminService.GetAssigned();
UnAssignedTickets = await AdminService.GetUnAssigned();
if (Id != 0)
{
AdminService.Ticket = AssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (AdminService.Ticket == null)
{
AdminService.Ticket = UnAssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
}
if (AdminService.Ticket == null)
return;
Status = AdminService.Ticket.Status;
Priority = AdminService.Ticket.Priority;
if (reloadMessages)
{
var msgs = await AdminService.GetMessages();
Messages = msgs.ToList();
}
}
}
public async void Dispose()
{
await Unsubscribe();
Filter = filterId;
await TicketLazyLoader.Reload();
}
}

View file

@ -0,0 +1,379 @@
@page "/old_admin/support"
@page "/old_admin/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Events
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Moonlight.App.Services
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketAdminService AdminService
@inject SmartTranslateService SmartTranslateService
@inject EventSystem EventSystem
@inject IdentityService IdentityService
@attribute [PermissionRequired(nameof(Permissions.AdminSupport))]
@implements IDisposable
<div class="d-flex flex-column flex-lg-row">
<div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="card card-flush">
<div class="card-body pt-5">
<div class="scroll-y me-n5 pe-5 h-200px h-lg-auto" data-kt-scroll="true" data-kt-scroll-activate="{default: false, lg: true}" data-kt-scroll-max-height="auto" data-kt-scroll-dependencies="#kt_header, #kt_app_header, #kt_toolbar, #kt_app_toolbar, #kt_footer, #kt_app_footer, #kt_chat_contacts_header" data-kt-scroll-wrappers="#kt_content, #kt_app_content, #kt_chat_contacts_body" data-kt-scroll-offset="5px" style="max-height: 601px;">
<div class="separator separator-content border-primary mb-10 mt-5">
<span class="w-250px fw-bold fs-5">
<TL>Unassigned tickets</TL>
</span>
</div>
@foreach (var ticket in UnAssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != UnAssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
@if (AssignedTickets.Any())
{
<div class="separator separator-content border-primary mb-5 mt-8">
<span class="w-250px fw-bold fs-5">
<TL>Assigned tickets</TL>
</span>
</div>
}
@foreach (var ticket in AssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != AssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
</div>
</div>
</div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (AdminService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(AdminService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (AdminService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(AdminService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (AdminService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(AdminService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="input-group">
<div class="me-3">
@if (AdminService.Ticket!.AssignedTo == null)
{
<WButton Text="@(SmartTranslateService.Translate("Claim"))"/>
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Unclaim"))"/>
}
</div>
<select @bind="Priority" class="form-select rounded-start">
@foreach (var priority in (TicketPriority[])Enum.GetValues(typeof(TicketPriority)))
{
if (Priority == priority)
{
<option value="@(priority)" selected="">@(priority)</option>
}
else
{
<option value="@(priority)">@(priority)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update priority"))"
CssClasses="btn-primary"
OnClick="UpdatePriority">
</WButton>
<select @bind="Status" class="form-select">
@foreach (var status in (TicketStatus[])Enum.GetValues(typeof(TicketStatus)))
{
if (Status == status)
{
<option value="@(status)" selected="">@(status)</option>
}
else
{
<option value="@(status)">@(status)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update status"))"
CssClasses="btn-primary"
OnClick="UpdateStatus">
</WButton>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (AdminService.Ticket == null)
{
}
else
{
<TicketMessageView Messages="Messages" ViewAsSupport="true"/>
}
</div>
</div>
@if (AdminService.Ticket != null)
{
<div class="card-footer pt-4" id="kt_chat_messenger_footer">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</div>
</div>
</div>
@code
{
[Parameter]
public int Id { get; set; }
private Dictionary<Ticket, TicketMessage?> AssignedTickets;
private Dictionary<Ticket, TicketMessage?> UnAssignedTickets;
private List<TicketMessage> Messages = new();
private string MessageText;
private SmartFileSelect FileSelect;
private TicketPriority Priority;
private TicketStatus Status;
protected override async Task OnParametersSetAsync()
{
await Unsubscribe();
await ReloadTickets();
await Subscribe();
await InvokeAsync(StateHasChanged);
}
private async Task UpdatePriority()
{
await AdminService.UpdatePriority(Priority);
}
private async Task UpdateStatus()
{
await AdminService.UpdateStatus(Status);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if (string.IsNullOrEmpty(MessageText))
return;
var msg = await AdminService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (AdminService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{AdminService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{AdminService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (AdminService.Ticket != null)
{
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
AdminService.Ticket = null;
//AssignedTickets = await AdminService.GetAssigned();
//UnAssignedTickets = await AdminService.GetUnAssigned();
if (Id != 0)
{
AdminService.Ticket = AssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (AdminService.Ticket == null)
{
AdminService.Ticket = UnAssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
}
if (AdminService.Ticket == null)
return;
Status = AdminService.Ticket.Status;
Priority = AdminService.Ticket.Priority;
if (reloadMessages)
{
var msgs = await AdminService.GetMessages();
Messages = msgs.ToList();
}
}
}
public async void Dispose()
{
await Unsubscribe();
}
}

View file

@ -0,0 +1,293 @@
@page "/admin/support/view/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Events
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketAdminService TicketAdminService
@inject SmartTranslateService SmartTranslateService
@inject Repository<Ticket> TicketRepository
@inject EventSystem Event
@inject IdentityService IdentityService
@implements IDisposable
@attribute [PermissionRequired(nameof(Permissions.AdminSupportView))]
<LazyLoader @ref="LazyLoader" Load="Load">
@if (Ticket == null)
{
<NotFoundAlert/>
}
else
{
<div class="card">
<div class="row g-0">
<div class="col-xl-9 col-lg-8">
<div class="card-body border-end">
<div class="row mb-4 pb-2 g-3">
<span class="fs-2 fw-bold">@(Ticket.IssueTopic)</span>
</div>
<span class="fs-4">
<TL>Issue description</TL>:
</span>
<p class="fs-5 text-muted">
@(Formatter.FormatLineBreaks(Ticket.IssueDescription))
</p>
<span class="fs-4">
<TL>Issue resolve tries</TL>:
</span>
<p class="fs-5 text-muted">
@(Formatter.FormatLineBreaks(Ticket.IssueTries))
</p>
</div>
<div class="card-body border-end border-top bg-black">
<TicketMessageView Messages="Messages" ViewAsSupport="true"/>
</div>
<div class="card-footer pt-4">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageContent" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage"/>
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-xl-3 col-lg-4">
<div class="card-header">
<h6 class="card-title mb-0">
<TL>Ticket details</TL>
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-borderless align-middle mb-0">
<tbody>
<tr>
<th>
<TL>Ticket ID</TL>
</th>
<td>@(Ticket.Id)</td>
</tr>
<tr>
<th>
<TL>User</TL>
</th>
<td>
<a href="/admin/users/view/@(Ticket.CreatedBy.Id)">
@(Ticket.CreatedBy.FirstName) @(Ticket.CreatedBy.LastName)
</a>
</td>
</tr>
<tr>
<th>
<TL>Subject</TL>
</th>
<td>
<TL>@(Ticket.Subject)</TL>
</td>
</tr>
<tr>
<th>
<TL>Subject ID</TL>
</th>
<td>@(Ticket.SubjectId)</td>
</tr>
<tr>
<th>
<TL>Assigned to</TL>
</th>
@if (Ticket.AssignedTo == null)
{
<td>
<TL>None</TL>
</td>
}
else
{
<td>@(Ticket.AssignedTo.FirstName) @(Ticket.AssignedTo.LastName)</td>
}
</tr>
<tr>
<th>
<TL>Status</TL>
</th>
<td>
<select @bind="StatusModified" class="form-select">
@foreach (var status in (TicketStatus[])Enum.GetValues(typeof(TicketStatus)))
{
if (StatusModified == status)
{
<option value="@(status)" selected="">@(status)</option>
}
else
{
<option value="@(status)">@(status)</option>
}
}
</select>
</td>
</tr>
<tr>
<th>
<TL>Priority</TL>
</th>
<td>
<select @bind="PriorityModified" class="form-select">
@foreach (var priority in (TicketPriority[])Enum.GetValues(typeof(TicketPriority)))
{
if (PriorityModified == priority)
{
<option value="@(priority)" selected="">@(priority)</option>
}
else
{
<option value="@(priority)">@(priority)</option>
}
}
</select>
</td>
</tr>
<tr>
<th>
<TL>Created at</TL>
</th>
<td>@(Formatter.FormatDate(Ticket.CreatedAt))</td>
</tr>
<tr>
<th></th>
<td>
<WButton Text="@(SmartTranslateService.Translate("Save"))" OnClick="Save"/>
</td>
</tr>
<tr>
<th>
<WButton Text="@(SmartTranslateService.Translate("Claim"))" OnClick="() => SetClaim(IdentityService.User)"/>
</th>
<td>
<WButton Text="@(SmartTranslateService.Translate("Unclaim"))" OnClick="() => SetClaim(null)"/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
private Ticket? Ticket { get; set; }
private TicketPriority PriorityModified;
private TicketStatus StatusModified;
private List<TicketMessage> Messages = new();
private SmartFileSelect FileSelect;
private string MessageContent = "";
private LazyLoader LazyLoader;
private async Task Load(LazyLoader _)
{
Ticket = TicketRepository
.Get()
.Include(x => x.AssignedTo)
.Include(x => x.CreatedBy)
.FirstOrDefault(x => x.Id == Id);
if (Ticket != null)
{
TicketAdminService.Ticket = Ticket;
PriorityModified = Ticket.Priority;
StatusModified = Ticket.Status;
Messages = (await TicketAdminService.GetMessages()).ToList();
// Register events
await Event.On<TicketMessage>($"tickets.{Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await Event.On<Ticket>($"tickets.{Ticket.Id}.status", this, async _ =>
{
//TODO: Does not work because of data caching. So we dont reload because it will look the same anyways
//await LazyLoader.Reload();
});
}
}
private async Task Save()
{
if (PriorityModified != Ticket!.Priority)
await TicketAdminService.UpdatePriority(PriorityModified);
if (StatusModified != Ticket!.Status)
await TicketAdminService.UpdateStatus(StatusModified);
}
private async Task SetClaim(User? user)
{
await TicketAdminService.SetClaim(user);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageContent) && FileSelect.SelectedFile != null)
MessageContent = "File upload";
if (string.IsNullOrEmpty(MessageContent))
return;
var msg = await TicketAdminService.Send(
MessageContent,
FileSelect.SelectedFile
);
Messages.Add(msg);
MessageContent = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
public async void Dispose()
{
if (Ticket != null)
{
await Event.Off($"tickets.{Ticket.Id}.message", this);
await Event.Off($"tickets.{Ticket.Id}.status", this);
}
}
}

View file

@ -1,353 +1,217 @@
@page "/support"
@page "/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Events
@using Moonlight.App.Services.Files
@using Moonlight.App.Models.Misc
@using BlazorTable
@using Moonlight.App.Models.Forms
@using Moonlight.App.Services
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketClientService ClientService
@inject Repository<Server> ServerRepository
@inject Repository<WebSpace> WebSpaceRepository
@inject Repository<Domain> DomainRepository
@inject TicketClientService TicketClientService
@inject Repository<Ticket> TicketRepository
@inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService
@inject Repository<WebSpace> WebSpaceRepository
@inject Repository<Domain> DomainRepository
@inject Repository<Server> ServerRepository
@inject NavigationManager NavigationManager
@inject ResourceService ResourceService
@inject EventSystem EventSystem
<div class="d-flex flex-column flex-lg-row">
<div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="card card-flush">
<div class="card-body pt-5">
<div class="scroll-y me-n5 pe-5 h-200px h-lg-auto">
<div class="d-flex flex-stack d-flex justify-content-center mb-5">
<a href="/support" class="btn btn-primary">
<TL>Create new ticket</TL>
</a>
</div>
<div class="separator"></div>
@foreach (var ticket in Tickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != Tickets.Last().Key)
{
<div class="separator"></div>
}
}
</div>
</div>
</div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (ClientService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(ClientService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (ClientService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(ClientService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (ClientService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(ClientService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="me-n3">
<button class="btn btn-sm btn-icon btn-active-light-primary" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
<i class="ki-duotone ki-dots-square fs-2">
<span class="path1"></span><span class="path2"></span><span class="path3"></span><span class="path4"></span>
</i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-200px py-3" data-kt-menu="true">
<div class="menu-item px-3">
<div class="menu-content text-muted pb-2 px-3 fs-7 text-uppercase">
Contacts
</div>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_users_search">
Add Contact
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link flex-stack px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_invite_friends">
Invite Contacts
<span class="ms-2" data-bs-toggle="tooltip" aria-label="Specify a contact email to send an invitation" data-bs-original-title="Specify a contact email to send an invitation" data-kt-initialized="1">
<i class="ki-duotone ki-information fs-7">
<span class="path1"></span><span class="path2"></span><span class="path3"></span>
</i>
</span>
</a>
</div>
<div class="menu-item px-3" data-kt-menu-trigger="hover" data-kt-menu-placement="right-start">
<a href="#" class="menu-link px-3">
<span class="menu-title">Groups</span>
<span class="menu-arrow"></span>
</a>
<div class="menu-sub menu-sub-dropdown w-175px py-4">
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Create Group
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Invite Members
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
<div class="menu-item px-3 my-1">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<span class="card-title">
<TL>Create a new ticket</TL>
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (ClientService.Ticket == null)
{
<LazyLoader Load="LoadTicketCreate">
<SmartForm Model="Model" OnValidSubmit="OnValidSubmit">
<div class="mb-3">
<InputText @bind-Value="Model.IssueTopic"
placeholder="@(SmartTranslateService.Translate("Enter a title for your ticket"))"
class="form-control">
</InputText>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueDescription"
placeholder="@(SmartTranslateService.Translate("Describe the issue you are experiencing"))"
<div class="card-body">
<LazyLoader Load="LoadTicketCreate">
<SmartForm Model="Model" OnValidSubmit="OnValidSubmit">
<div class="mb-3">
<InputText @bind-Value="Model.IssueTopic"
placeholder="@(SmartTranslateService.Translate("Enter a title for your ticket"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueTries"
placeholder="@(SmartTranslateService.Translate("Describe what you have tried to solve this issue"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<select @bind="Model.Subject" class="form-select">
@foreach (var subject in (TicketSubject[])Enum.GetValues(typeof(TicketSubject)))
</InputText>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueDescription"
placeholder="@(SmartTranslateService.Translate("Describe the issue you are experiencing"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueTries"
placeholder="@(SmartTranslateService.Translate("Describe what you have tried to solve this issue"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<select @bind="Model.Subject" class="form-select">
@foreach (var subject in (TicketSubject[])Enum.GetValues(typeof(TicketSubject)))
{
if (Model.Subject == subject)
{
<option value="@(subject)" selected="">@(subject)</option>
}
else
{
<option value="@(subject)">@(subject)</option>
}
}
</select>
</div>
<div class="mb-3">
@if (Model.Subject == TicketSubject.Domain)
{
if (Model.Subject == subject)
{
<option value="@(subject)" selected="">@(subject)</option>
}
else
{
<option value="@(subject)">@(subject)</option>
}
<select @bind="Model.SubjectId" class="form-select">
@foreach (var domain in Domains)
{
if (Model.SubjectId == domain.Id)
{
<option value="@(domain.Id)" selected="">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
else
{
<option value="@(domain.Id)">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
}
</select>
}
</select>
</div>
<div class="mb-3">
@if (Model.Subject == TicketSubject.Domain)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var domain in Domains)
{
if (Model.SubjectId == domain.Id)
else if (Model.Subject == TicketSubject.Server)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var server in Servers)
{
<option value="@(domain.Id)" selected="">@(domain.Name).@(domain.SharedDomain.Name)</option>
if (Model.SubjectId == server.Id)
{
<option value="@(server.Id)" selected="">@(server.Name)</option>
}
else
{
<option value="@(server.Id)">@(server.Name)</option>
}
}
else
</select>
}
else if (Model.Subject == TicketSubject.Webspace)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var webSpace in WebSpaces)
{
<option value="@(domain.Id)">@(domain.Name).@(domain.SharedDomain.Name)</option>
if (Model.SubjectId == webSpace.Id)
{
<option value="@(webSpace.Id)" selected="">@(webSpace.Domain)</option>
}
else
{
<option value="@(webSpace.Id)">@(webSpace.Domain)</option>
}
}
}
</select>
}
else if (Model.Subject == TicketSubject.Server)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var server in Servers)
{
if (Model.SubjectId == server.Id)
{
<option value="@(server.Id)" selected="">@(server.Name)</option>
}
else
{
<option value="@(server.Id)">@(server.Name)</option>
}
}
</select>
}
else if (Model.Subject == TicketSubject.Webspace)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var webSpace in WebSpaces)
{
if (Model.SubjectId == webSpace.Id)
{
<option value="@(webSpace.Id)" selected="">@(webSpace.Domain)</option>
}
else
{
<option value="@(webSpace.Id)">@(webSpace.Domain)</option>
}
}
</select>
}
</div>
<div class="text-end">
<button class="btn btn-primary" type="submit">
<TL>Create ticket</TL>
</button>
</div>
</SmartForm>
</LazyLoader>
}
else
{
<TicketMessageView Messages="Messages"/>
}
</div>
</div>
@if (ClientService.Ticket != null)
{
<div class="card-footer pt-4">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</select>
}
</div>
<div class="text-end">
<button class="btn btn-primary" type="submit">
<TL>Create ticket</TL>
</button>
</div>
</SmartForm>
</LazyLoader>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header">
<span class="card-title">
<TL>Your tickets</TL>
</span>
</div>
<div class="card-body">
<LazyLoader Load="LoadTickets">
<div class="table-responsive">
<Table TableItem="Ticket" Items="Tickets" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Ticket title"))" Field="@(x => x.IssueTopic)" Filterable="true" Sortable="false">
<Template>
<a href="/support/view/@(context.Id)">@(context.IssueTopic)</a>
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Assigned to"))" Field="@(x => x.AssignedTo)" Filterable="true" Sortable="true">
<Template>
<span>@(context.AssignedTo == null ? "None" : context.AssignedTo.FirstName + " " + context.AssignedTo.LastName)</span>
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Priority"))" Field="@(x => x.Priority)" Filterable="true" Sortable="true">
<Template>
@switch (context.Priority)
{
case TicketPriority.Low:
<span class="badge bg-success">@(context.Priority)</span>
break;
case TicketPriority.Medium:
<span class="badge bg-primary">@(context.Priority)</span>
break;
case TicketPriority.High:
<span class="badge bg-warning">@(context.Priority)</span>
break;
case TicketPriority.Critical:
<span class="badge bg-danger">@(context.Priority)</span>
break;
}
</Template>
</Column>
<Column TableItem="Ticket" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Status)" Filterable="true" Sortable="true">
<Template>
@switch (context.Status)
{
case TicketStatus.Closed:
<span class="badge bg-danger">@(context.Status)</span>
break;
case TicketStatus.Open:
<span class="badge bg-success">@(context.Status)</span>
break;
case TicketStatus.Pending:
<span class="badge bg-warning">@(context.Status)</span>
break;
case TicketStatus.WaitingForUser:
<span class="badge bg-primary">@(context.Status)</span>
break;
}
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
</div>
</div>
}
</div>
</div>
</div>
@code
{
[Parameter]
public int Id { get; set; }
private Dictionary<Ticket, TicketMessage?> Tickets;
private List<TicketMessage> Messages = new();
private Ticket[] Tickets;
private CreateTicketDataModel Model = new();
private string MessageText;
private SmartFileSelect FileSelect;
private Server[] Servers;
private WebSpace[] WebSpaces;
private Domain[] Domains;
protected override async Task OnParametersSetAsync()
private Task LoadTickets(LazyLoader _)
{
await Unsubscribe();
await ReloadTickets();
await Subscribe();
Tickets = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.AssignedTo)
.Where(x => x.Status != TicketStatus.Closed)
.Where(x => x.CreatedBy.Id == IdentityService.User.Id)
.ToArray();
await InvokeAsync(StateHasChanged);
return Task.CompletedTask;
}
private Task LoadTicketCreate(LazyLoader _)
{
Servers = ServerRepository
@ -374,95 +238,16 @@
private async Task OnValidSubmit()
{
var ticket = await ClientService.Create(
var ticket = await TicketClientService.Create(
Model.IssueTopic,
Model.IssueDescription,
Model.IssueTries,
Model.Subject,
Model.SubjectId
);
);
Model = new();
NavigationManager.NavigateTo("/support/" + ticket.Id);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if(string.IsNullOrEmpty(MessageText))
return;
var msg = await ClientService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async ticket =>
{
if (ticket.CreatedBy != null && ticket.CreatedBy.Id != IdentityService.User.Id)
return;
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (ClientService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{ClientService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && !message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{ClientService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (ClientService.Ticket != null)
{
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
ClientService.Ticket = null;
Tickets = await ClientService.Get();
if (Id != 0)
{
ClientService.Ticket = Tickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (ClientService.Ticket == null)
return;
if (reloadMessages)
{
var msgs = await ClientService.GetMessages();
Messages = msgs.ToList();
}
}
NavigationManager.NavigateTo("/support/view/" + ticket.Id);
}
}

View file

@ -0,0 +1,131 @@
@page "/support/view/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Moonlight.Shared.Components.Tickets
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Events
@using Moonlight.App.Services.Sessions
@inject TicketClientService TicketClientService
@inject SmartTranslateService SmartTranslateService
@inject Repository<Ticket> TicketRepository
@inject IdentityService IdentityService
@inject EventSystem Event
@implements IDisposable
<LazyLoader Load="Load">
@if (Ticket == null)
{
<NotFoundAlert />
}
else
{
<div class="card">
<div class="card-header">
<span class="card-title">@(Ticket.IssueTopic)</span>
</div>
<div class="card-body border-end border-top bg-black">
<TicketMessageView Messages="Messages"/>
</div>
<div class="card-footer pt-4">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageContent" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage"/>
</td>
</tr>
</table>
</div>
</div>
</div>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
private Ticket? Ticket;
private List<TicketMessage> Messages = new();
private SmartFileSelect FileSelect;
private string MessageContent = "";
private async Task Load(LazyLoader _)
{
Ticket = TicketRepository
.Get()
.Include(x => x.CreatedBy)
.Where(x => x.CreatedBy.Id == IdentityService.User.Id)
.FirstOrDefault(x => x.Id == Id);
if (Ticket != null)
{
TicketClientService.Ticket = Ticket;
Messages = (await TicketClientService.GetMessages()).ToList();
// Register events
await Event.On<TicketMessage>($"tickets.{Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && !message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await Event.On<Ticket>($"tickets.{Ticket.Id}.status", this, async _ =>
{
//TODO: Does not work because of data caching. So we dont reload because it will look the same anyways
//await LazyLoader.Reload();
});
}
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageContent) && FileSelect.SelectedFile != null)
MessageContent = "File upload";
if (string.IsNullOrEmpty(MessageContent))
return;
var msg = await TicketClientService.Send(
MessageContent,
FileSelect.SelectedFile
);
Messages.Add(msg);
MessageContent = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
public async void Dispose()
{
if (Ticket != null)
{
await Event.Off($"tickets.{Ticket.Id}.message", this);
await Event.Off($"tickets.{Ticket.Id}.status", this);
}
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="922.43055" height="543.51482" viewBox="0 0 922.43055 543.51482" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M429.40405,719.95523c-.05566-.24511-5.43994-24.79785,5.55615-45.19043,10.9961-20.39166,34.46827-29.38476,34.70411-29.47363l1.07275-.40234.25342,1.11816c.05566.24512,5.43994,24.79785-5.55615,45.19043-10.99561,20.39166-34.46827,29.38477-34.70411,29.47363l-1.07324.40235Zm39.86182-72.33782c-4.70166,2.02246-23.25781,10.874-32.54492,28.09667-9.28809,17.22461-6.48584,37.59375-5.59229,42.63086,4.69971-2.01757,23.24854-10.85546,32.54492-28.09668C472.96118,673.02457,470.15991,652.65738,469.26587,647.61741Z" transform="translate(-138.78472 -178.24259)" fill="#f1f1f1"/><path d="M457.03116,684.04435c-19.76056,11.88861-27.371,35.50268-27.371,35.50268s24.42779,4.33881,44.18835-7.5498,27.371-35.50269,27.371-35.50269S476.79172,672.15574,457.03116,684.04435Z" transform="translate(-138.78472 -178.24259)" fill="#f1f1f1"/><path d="M403.38208,533.755h55.82731V505.49264H403.38208Z" transform="translate(-138.78472 -178.24259)" fill="#6c63ff"/><path d="M415.04913,508.29308a7.93668,7.93668,0,0,0-8.31091-8.89017L398.88446,483.055l-11.09864,2.34969,11.41528,22.93065a7.97965,7.97965,0,0,0,15.848-.04225Z" transform="translate(-138.78472 -178.24259)" fill="#ffb7b7"/><polygon points="105.767 519.858 115.004 524.585 137.634 491.205 124 484.228 105.767 519.858" fill="#ffb7b7"/><path d="M243.73878,693.879l18.19172,9.3096.00074.00038a13.02378,13.02378,0,0,1,5.65977,17.526l-.19281.37672-29.785-15.24261Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><polygon points="212.454 532.791 222.83 532.79 227.767 492.766 212.452 492.766 212.454 532.791" fill="#ffb7b7"/><path d="M348.59142,707.64523l20.43546-.00083h.00082a13.02377,13.02377,0,0,1,13.02307,13.02286v.4232l-33.45873.00124Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><path d="M302.70032,384.56392s-6.94873-5.32368-6.94873,7.68016l-1.09716,42.97236,12.25169,40.59517,7.13159-13.166-2.92578-28.52633Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><path d="M377.03136,502.10087s8.05774,39.24119-2.14873,71.4453L369.24221,697.3668l-20.68154-1.61155-7.252-91.85825-6.98338-45.66053L319.8214,599.86813l-47.80927,88.098-22.02449-17.18985s24.40667-66.59522,42.43744-80.57741l9.043-102.99572Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><circle cx="213.7317" cy="130.27454" r="23.5814" fill="#ffb8b8"/><path d="M350.84761,313.51884c2.82683.3678,4.95918-2.52447,5.94818-5.19806s1.74257-5.7862,4.2003-7.23041c3.35778-1.9731,7.65389.4,11.49368-.251,4.33631-.73516,7.15572-5.3308,7.37669-9.72343s-1.5271-8.61741-3.24227-12.66737l-.5988,5.03318a9.98113,9.98113,0,0,0-4.36168-8.72436l.77179,7.38543a7.83853,7.83853,0,0,0-9.01785-6.48609l.12154,4.40051c-5.00844-.59556-10.06064-1.19195-15.08391-.73823s-10.08162,2.043-13.88883,5.35126c-5.695,4.94857-7.77493,13.097-7.07665,20.6092s3.79932,14.56944,7.0313,21.38674c.81317,1.71525,1.9379,3.65079,3.82353,3.86929a3.85158,3.85158,0,0,0,3.7712-2.84219,10.30188,10.30188,0,0,0-.04573-5.06077c-.4765-2.5321-1.07717-5.12024-.62916-7.65754s2.27332-5.04462,4.831-5.35557,5.1749,2.61264,3.94519,4.8768Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><polygon points="239.706 334.352 160.991 329.274 166.915 304.729 238.859 320.81 239.706 334.352" fill="#cbcbcb"/><path d="M329.94662,340.67725l4.93725-6.85464s5.51849,1.8754,20.29759,9.23183l1.04414,6.42222,25.65358,157.78706-46.62958-2.01147-12.69811-.27017-4.15546-9.315-5.12418,9.11755-12.40014-.26383-12.61742-7.31445,12.43455-38.03511,4.02295-34.74361-6.21728-32.73214s-7.82347-30.05728,22.30906-46.26386Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><polygon points="255.787 321.656 257.48 327.581 269.329 319.864 264.858 314.745 255.787 321.656" fill="#cbcbcb"/><path d="M338.76693,359.748s18.36836-23.45,29.47029,2.68606l3.50019,27.38928s13.44915,34.04483,13.85365,47.81926c0,0,9.11826,17.97712,9.0345,27.41942l13.48858,30.53718-13.8346,8.58887L355.991,445.481Z" transform="translate(-138.78472 -178.24259)" fill="#2f2e41"/><path d="M520.78472,721.75741h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z" transform="translate(-138.78472 -178.24259)" fill="#cbcbcb"/><path d="M597.358,414.94225h460.06211V182.03786H597.358Z" transform="translate(-138.78472 -178.24259)" fill="#fff"/><path d="M1061.21528,418.73745H593.5628V178.24259h467.65248ZM601.153,411.14723h452.472V185.83281H601.153Z" transform="translate(-138.78472 -178.24259)" fill="#e5e5e5"/><rect x="739.86245" y="46.21081" width="86.67858" height="148.0759" fill="#e5e5e5"/><rect x="550.66766" y="46.20822" width="174.83378" height="40.75957" fill="#6c63ff"/><rect x="550.66766" y="98.31856" width="174.83378" height="43.85523" fill="#e5e5e5"/><rect x="550.66766" y="153.52456" width="174.83378" height="40.75957" fill="#e5e5e5"/></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="653.146" height="396.47" viewBox="0 0 653.146 396.47" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Group_22" data-name="Group 22" transform="translate(-288 -252.29)">
<g id="Group_20" data-name="Group 20" transform="matrix(0.839, -0.545, 0.545, 0.839, -851.767, -5983.48)">
<path id="Path_561-393" data-name="Path 561" d="M434.056,197.674H233.67a2.881,2.881,0,0,0-2.879,2.879V336.34a2.881,2.881,0,0,0,2.879,2.879H434.056a2.876,2.876,0,0,0,2.189-1.01.669.669,0,0,0,.063-.079,2.7,2.7,0,0,0,.413-.7,2.808,2.808,0,0,0,.218-1.093V200.553A2.882,2.882,0,0,0,434.056,197.674Zm2.06,138.666a2.039,2.039,0,0,1-.34,1.129,2.129,2.129,0,0,1-.779.7,2.042,2.042,0,0,1-.941.228H233.67a2.059,2.059,0,0,1-2.057-2.057V200.553a2.059,2.059,0,0,1,2.057-2.057H434.056a2.06,2.06,0,0,1,2.06,2.057Z" transform="translate(-2405.791 5984.326)" fill="#3f3d56"/>
<rect id="Rectangle_99" data-name="Rectangle 99" width="205.323" height="0.823" transform="translate(-2174.59 6193.538)" fill="#3f3d56"/>
<circle id="Ellipse_88" data-name="Ellipse 88" cx="2.469" cy="2.469" r="2.469" transform="translate(-2170.064 6185.703)" fill="#3f3d56"/>
<circle id="Ellipse_89" data-name="Ellipse 89" cx="2.469" cy="2.469" r="2.469" transform="translate(-2162.966 6185.703)" fill="#3f3d56"/>
<circle id="Ellipse_90" data-name="Ellipse 90" cx="2.469" cy="2.469" r="2.469" transform="translate(-2155.868 6185.703)" fill="#3f3d56"/>
<path id="Path_583-394" data-name="Path 583" d="M344.945,347.5H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5887.343)" fill="#6c63ff"/>
<path id="Path_584-395" data-name="Path 584" d="M370.968,362.93H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,0,1,0,.66Z" transform="translate(-2439.762 5874.067)" fill="#3f3d56"/>
<path id="Path_585-396" data-name="Path 585" d="M344.945,411.551H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5844.438)" fill="#6c63ff"/>
<path id="Path_586-397" data-name="Path 586" d="M370.968,426.98H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,1,1,0,.66Z" transform="translate(-2439.762 5831.162)" fill="#3f3d56"/>
<path id="Path_587-398" data-name="Path 587" d="M344.945,475.6H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5801.532)" fill="#f2f2f2"/>
<path id="Path_588-399" data-name="Path 588" d="M370.968,491.029H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,0,1,0,.66Z" transform="translate(-2439.762 5788.257)" fill="#3f3d56"/>
</g>
<path id="Path_552-400" data-name="Path 552" d="M792.253,565.923a10.091,10.091,0,0,1,1.411.787l44.852-19.143,1.6-11.815,17.922-.11-1.059,27.1L797.78,578.4a10.6,10.6,0,0,1-.448,1.208,10.235,10.235,0,1,1-5.079-13.682Z" transform="translate(-246.576 -174.461)" fill="#a0616a"/>
<path id="Path_553-401" data-name="Path 553" d="M636.98,735.021H624.72l-5.832-47.288h18.094Z" transform="translate(-19 -98)" fill="#a0616a"/>
<path id="Path_554-402" data-name="Path 554" d="M615.963,731.518h23.644V746.4H601.076a14.887,14.887,0,0,1,14.887-14.887Z" transform="translate(-19 -98)" fill="#2f2e41"/>
<path id="Path_555-403" data-name="Path 555" d="M684.66,731.557l-12.2,1.2-10.441-46.488,18.007-1.774Z" transform="translate(-19 -98)" fill="#a0616a"/>
<path id="Path_556-404" data-name="Path 556" d="M891.686,806.128H915.33v14.887H876.8a14.887,14.887,0,0,1,14.887-14.887Z" transform="translate(-322.009 -82.709) rotate(-5.625)" fill="#2f2e41"/>
<circle id="Ellipse_84" data-name="Ellipse 84" cx="24.561" cy="24.561" r="24.561" transform="translate(596.832 262.013)" fill="#a0616a"/>
<path id="Path_557-405" data-name="Path 557" d="M849.556,801.919a4.471,4.471,0,0,1-4.415-3.7C838.8,763,818.053,647.817,817.557,644.626a1.432,1.432,0,0,1-.016-.222v-8.588a1.489,1.489,0,0,1,.279-.872l2.74-3.838a1.479,1.479,0,0,1,1.144-.625c15.622-.732,66.784-2.879,69.256.209h0c2.482,3.1,1.605,12.507,1.4,14.36l.01.193,22.985,147a4.512,4.512,0,0,1-3.715,5.135l-14.356,2.365a4.521,4.521,0,0,1-5.025-3.093c-4.44-14.188-19.329-61.918-24.489-80.387a.5.5,0,0,0-.981.139c.258,17.606.881,62.523,1.1,78.037l.023,1.671a4.518,4.518,0,0,1-4.093,4.536L849.976,801.9C849.836,801.914,849.7,801.919,849.556,801.919Z" transform="translate(-246.576 -174.461)" fill="#2f2e41"/>
<path id="Path_99-406" data-name="Path 99" d="M852.381,495.254c-4.286,2.548-6.851,7.23-8.323,12a113.683,113.683,0,0,0-4.884,27.159l-1.556,27.6-19.255,73.17c16.689,14.121,26.315,10.911,48.781-.639s25.032,3.851,25.032,3.851l4.492-62.258,6.418-68.032a30.169,30.169,0,0,0-4.862-4.674,49.659,49.659,0,0,0-42.442-9Z" transform="translate(-246.576 -174.461)" fill="#6c63ff"/>
<path id="Path_558-407" data-name="Path 558" d="M846.127,580.7a10.527,10.527,0,0,1,1.5.7l44.348-22.2.736-12.026,18.294-1.261.98,27.413-59.266,19.6a10.5,10.5,0,1,1-6.593-12.232Z" transform="translate(-246.576 -174.461)" fill="#a0616a"/>
<path id="Path_101-408" data-name="Path 101" d="M902.766,508.411c10.911,3.851,12.834,45.574,12.834,45.574-12.837-7.06-28.241,4.493-28.241,4.493s-3.209-10.912-7.06-25.032a24.53,24.53,0,0,1,5.134-23.106S891.854,504.558,902.766,508.411Z" transform="translate(-246.576 -174.461)" fill="#6c63ff"/>
<path id="Path_102-409" data-name="Path 102" d="M889.991,467.531c-3.06-2.448-7.235,2-7.235,2L880.308,447.5s-15.3,1.833-25.094-.612-11.323,8.875-11.323,8.875a78.583,78.583,0,0,1-.306-13.771c.612-5.508,8.568-11.017,22.645-14.689s21.421,12.241,21.421,12.241C897.445,444.439,893.051,469.979,889.991,467.531Z" transform="translate(-246.576 -174.461)" fill="#2f2e41"/>
<g id="Group_19" data-name="Group 19" transform="translate(2910 -5674.784)">
<path id="Path_561-2-410" data-name="Path 561" d="M434.056,197.674H233.67a2.881,2.881,0,0,0-2.879,2.879V336.34a2.881,2.881,0,0,0,2.879,2.879H434.056a2.876,2.876,0,0,0,2.189-1.01.669.669,0,0,0,.063-.079,2.7,2.7,0,0,0,.413-.7,2.808,2.808,0,0,0,.218-1.093V200.553A2.882,2.882,0,0,0,434.056,197.674Zm2.06,138.666a2.039,2.039,0,0,1-.34,1.129,2.129,2.129,0,0,1-.779.7,2.042,2.042,0,0,1-.941.228H233.67a2.059,2.059,0,0,1-2.057-2.057V200.553a2.059,2.059,0,0,1,2.057-2.057H434.056a2.06,2.06,0,0,1,2.06,2.057Z" transform="translate(-2405.791 5984.326)" fill="#3f3d56"/>
<rect id="Rectangle_99-2" data-name="Rectangle 99" width="205.323" height="0.823" transform="translate(-2174.59 6193.538)" fill="#3f3d56"/>
<circle id="Ellipse_88-2" data-name="Ellipse 88" cx="2.469" cy="2.469" r="2.469" transform="translate(-2170.064 6185.703)" fill="#3f3d56"/>
<circle id="Ellipse_89-2" data-name="Ellipse 89" cx="2.469" cy="2.469" r="2.469" transform="translate(-2162.966 6185.703)" fill="#3f3d56"/>
<circle id="Ellipse_90-2" data-name="Ellipse 90" cx="2.469" cy="2.469" r="2.469" transform="translate(-2155.868 6185.703)" fill="#3f3d56"/>
<path id="Path_583-2-411" data-name="Path 583" d="M344.945,347.5H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5887.343)" fill="#6c63ff"/>
<path id="Path_584-2-412" data-name="Path 584" d="M370.968,362.93H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,0,1,0,.66Z" transform="translate(-2439.762 5874.067)" fill="#3f3d56"/>
<path id="Path_585-2-413" data-name="Path 585" d="M344.945,411.551H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5844.438)" fill="#6c63ff"/>
<path id="Path_586-2-414" data-name="Path 586" d="M370.968,426.98H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,1,1,0,.66Z" transform="translate(-2439.762 5831.162)" fill="#3f3d56"/>
<path id="Path_587-2-415" data-name="Path 587" d="M344.945,475.6H293.029a2.525,2.525,0,0,1,0-5.049h51.916a2.525,2.525,0,0,1,0,5.049Z" transform="translate(-2445.791 5801.532)" fill="#f2f2f2"/>
<path id="Path_588-2-416" data-name="Path 588" d="M370.968,491.029H281.834a.33.33,0,1,1,0-.66h89.134a.33.33,0,0,1,0,.66Z" transform="translate(-2439.762 5788.257)" fill="#3f3d56"/>
</g>
<path id="Path_561-3-417" data-name="Path 561" d="M474.659,197.674H234.245a3.457,3.457,0,0,0-3.454,3.454V364.039a3.457,3.457,0,0,0,3.454,3.454H474.659a3.451,3.451,0,0,0,2.626-1.212.8.8,0,0,0,.075-.095,3.236,3.236,0,0,0,.5-.836,3.37,3.37,0,0,0,.261-1.311V201.128A3.457,3.457,0,0,0,474.659,197.674Zm2.472,166.365a2.445,2.445,0,0,1-.408,1.355,2.554,2.554,0,0,1-.935.84,2.45,2.45,0,0,1-1.129.273H234.245a2.47,2.47,0,0,1-2.467-2.467V201.128a2.47,2.47,0,0,1,2.467-2.467H474.659a2.471,2.471,0,0,1,2.472,2.468Z" transform="translate(57.209 173.542)" fill="#3f3d56"/>
<rect id="Rectangle_99-3" data-name="Rectangle 99" width="246.338" height="0.987" transform="translate(288.492 385.058)" fill="#3f3d56"/>
<circle id="Ellipse_88-3" data-name="Ellipse 88" cx="2.962" cy="2.962" r="2.962" transform="translate(293.922 375.659)" fill="#3f3d56"/>
<circle id="Ellipse_89-3" data-name="Ellipse 89" cx="2.962" cy="2.962" r="2.962" transform="translate(302.438 375.659)" fill="#3f3d56"/>
<circle id="Ellipse_90-3" data-name="Ellipse 90" cx="2.962" cy="2.962" r="2.962" transform="translate(310.954 375.659)" fill="#3f3d56"/>
<path id="Path_589-418" data-name="Path 589" d="M923.117,453.186h-82.26a3.739,3.739,0,1,1,0-7.478h82.26a3.739,3.739,0,0,1,0,7.478Z" transform="translate(-445.702 17.923)" fill="#f2f2f2"/>
<path id="Path_590-419" data-name="Path 590" d="M869.368,419.186h-28.51a3.739,3.739,0,1,1,0-7.478h28.51a3.739,3.739,0,0,1,0,7.478Z" transform="translate(-445.702 36.032)" fill="#f2f2f2"/>
<ellipse id="Ellipse_91" data-name="Ellipse 91" cx="15.891" cy="15.891" rx="15.891" ry="15.891" transform="translate(342.006 443.883)" fill="#6c63ff"/>
<path id="Path_591-420" data-name="Path 591" d="M860.866,385.456H712.706a7.957,7.957,0,0,0-7.946,7.946v32.717a7.957,7.957,0,0,0,7.945,7.945H860.866a7.957,7.957,0,0,0,7.945-7.946V393.4a7.957,7.957,0,0,0-7.945-7.946Zm7.011,40.662a7.019,7.019,0,0,1-7.011,7.011H712.706a7.019,7.019,0,0,1-7.011-7.011V393.4a7.019,7.019,0,0,1,7.011-7.011H860.866a7.019,7.019,0,0,1,7.011,7.011Z" transform="translate(-375.206 50.014)" fill="#3f3d56"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB