Skip to content

Commit

Permalink
PUSH
Browse files Browse the repository at this point in the history
-> Ticket system list
-> Ticket system create ticket
-> Ticket system translation!
NaysKutzu committed Jan 21, 2025
1 parent e9d2477 commit c4a2594
Showing 22 changed files with 1,564 additions and 1,200 deletions.
19 changes: 16 additions & 3 deletions backend/app/Api/User/Ticket/Create.php
Original file line number Diff line number Diff line change
@@ -48,12 +48,14 @@
if (isset($_POST['department_id']) && $_POST['department_id'] != '') {
$departmentId = $_POST['department_id'];
if (Departments::exists((int) $departmentId)) {
/**
* Make that every info needed is provided.
*/
if (isset($_POST['service_id']) && $_POST['service_id'] != '') {
$serviceId = $_POST['service_id'];
} else {
$serviceId = null;
}

if (isset($_POST['subject']) && $_POST['subject'] != '') {
$subject = $_POST['subject'];
} else {
@@ -73,8 +75,19 @@
}

// TODO: Check if service exists
// TODO: Limit to 3 open tickets user

/**
* Check if the user has more than 3 open tickets.
*/
$userTickets = Tickets::getAllTicketsByUser($session->getInfo(UserColumns::UUID, false), 150);
$openTickets = array_filter($userTickets, function ($ticket) {
return in_array($ticket['status'], ['open', 'waiting', 'replied', 'inprogress']);
});
if (count($openTickets) >= 3) {
$appInstance->BadRequest('You have reached the limit of 3 open tickets!', ['error_code' => 'LIMIT_REACHED']);
}
/**
* Create the ticket.
*/
$ticketId = Tickets::create($session->getInfo(UserColumns::UUID, false), $departmentId, $serviceId, $subject, $message, $priority);
if ($ticketId == 0) {
$appInstance->BadRequest('Failed to create ticket!', ['error_code' => 'FAILED_TO_CREATE_TICKET']);
31 changes: 31 additions & 0 deletions backend/app/Api/User/Ticket/List.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of MythicalClient.
* Please view the LICENSE file that was distributed with this source code.
*
* # MythicalSystems License v2.0
*
* ## Copyright (c) 2021–2025 MythicalSystems and Cassian Gherman
*
* Breaking any of the following rules will result in a permanent ban from the MythicalSystems community and all of its services.
*/

use MythicalClient\App;
use MythicalClient\Chat\Session;
use MythicalClient\Chat\Tickets;
use MythicalClient\Chat\columns\UserColumns;

$router->get('/api/user/ticket/list', function () {
App::init();
$appInstance = App::getInstance(true);
$appInstance->allowOnlyGET();
$s = new Session($appInstance);

$tickets = Tickets::getAllTicketsByUser($s->getInfo(UserColumns::UUID, false), 150);

$appInstance->OK('Tickets', [
'tickets' => $tickets,
]);

});
12 changes: 6 additions & 6 deletions backend/app/Chat/Departments.php
Original file line number Diff line number Diff line change
@@ -47,7 +47,7 @@ public static function update(
string $close,
): void {
try {
if (self::exists($id)) {
if (!self::exists($id)) {
App::getInstance(true)->getLogger()->warning('Department does not exist but tried to update it.', true);

return;
@@ -72,7 +72,7 @@ public static function exists(int $id): bool
$con = self::getPdoConnection();
$sql = 'SELECT id FROM ' . self::TABLE_NAME . ' WHERE id = :id';
$stmt = $con->prepare($sql);
$stmt->bindParam(':id', $id);
$stmt->bindParam(':id', $id, \PDO::PARAM_INT);
$stmt->execute();

return $stmt->rowCount() > 0;
@@ -86,8 +86,8 @@ public static function exists(int $id): bool
public static function delete(int $id): bool
{
try {
if (self::exists($id)) {
App::getInstance(true)->getLogger()->warning('Department does not exist but tried to update it.', true);
if (!self::exists($id)) {
App::getInstance(true)->getLogger()->warning('Department does not exist but tried to delete it.', true);

return false;
}
@@ -124,8 +124,8 @@ public static function getAll(): array
public static function get(int $id): array
{
try {
if (self::exists($id)) {
App::getInstance(true)->getLogger()->warning('Department does not exist but tried to update it.', true);
if (!self::exists($id)) {
App::getInstance(true)->getLogger()->warning('Department does not exist but tried to get it: ' . $id . '.', true);

return [];
}
38 changes: 38 additions & 0 deletions backend/app/Chat/Tickets.php
Original file line number Diff line number Diff line change
@@ -96,6 +96,44 @@ public static function getAllTickets(int $limit = 150): array
}
}

public static function getAllTicketsByUser(string $uuid, int $limit = 150): array
{
try {
$con = self::getPdoConnection();
$sql = 'SELECT * FROM ' . self::TABLE_NAME . ' WHERE user = :uuid ORDER BY id DESC LIMIT ' . $limit;
$stmt = $con->prepare($sql);
$stmt->bindParam('uuid', $uuid, \PDO::PARAM_STR);
$stmt->execute();

$tickets = $stmt->fetchAll(\PDO::FETCH_ASSOC);

foreach ($tickets as $key => $ticket) {
$tickets[$key]['department_id'] = $ticket['department'];
$tickets[$key]['department'] = Departments::get((int) $ticket['department']);

if (empty($tickets[$key]['department'])) {
$tickets[$key]['department'] = [
'id' => 0,
'name' => 'Deleted Department',
'description' => 'This department has been deleted.',
'time_open' => '08:30',
'time_close' => '17:30',
'enabled' => 'true',
'deleted' => 'false',
'locked' => 'false',
'date' => '2024-12-25 22:25:09',
];
}
}

return $tickets;
} catch (\Exception $e) {
self::db_Error('Failed to get all tickets by user: ' . $e->getMessage());

return [];
}
}

public static function getTicket(int $id): array
{
try {
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `mythicalclient_tickets` ADD `status` ENUM('open','closed','waiting','replied','inprogress') NOT NULL DEFAULT 'open' AFTER `priority`;
563 changes: 306 additions & 257 deletions frontend/src/components/client/Dashboard/Main/Announcements.vue

Large diffs are not rendered by default.

45 changes: 23 additions & 22 deletions frontend/src/components/client/Dashboard/Main/BillingInfo.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
<template>
<CardComponent>
<h2 class="text-white text-lg font-semibold mb-4">Billing Summary</h2>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-purple-200">Current Balance:</span>
<span class="text-white font-medium">$250.00</span>
</div>
<div class="flex justify-between">
<span class="text-purple-200">Next Invoice:</span>
<span class="text-white font-medium">$120.00</span>
</div>
<div class="flex justify-between">
<span class="text-purple-200">Due Date:</span>
<span class="text-white font-medium">15 Jul 2023</span>
</div>
</div>
<RouterLink to="/billing"
class="mt-4 block w-full px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-center text-sm">
View Invoices
</RouterLink>
</CardComponent>
<CardComponent>
<h2 class="text-white text-lg font-semibold mb-4">Billing Summary</h2>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-purple-200">Current Balance:</span>
<span class="text-white font-medium">$250.00</span>
</div>
<div class="flex justify-between">
<span class="text-purple-200">Next Invoice:</span>
<span class="text-white font-medium">$120.00</span>
</div>
<div class="flex justify-between">
<span class="text-purple-200">Due Date:</span>
<span class="text-white font-medium">15 Jul 2023</span>
</div>
</div>
<RouterLink
to="/billing"
class="mt-4 block w-full px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-center text-sm"
>
View Invoices
</RouterLink>
</CardComponent>
</template>
<script lang="ts" setup>
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
</script>
39 changes: 20 additions & 19 deletions frontend/src/components/client/Dashboard/Main/Header.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
<template>
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-white mb-2">My Dashboard</h1>
<div class="text-purple-200 text-sm">
<RouterLink to="/" class="hover:text-white transition-colors">Portal Home</RouterLink>
<span class="mx-2">/</span>
<span>Client Area</span>
</div>
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-white mb-2">My Dashboard</h1>
<div class="text-purple-200 text-sm">
<RouterLink to="/" class="hover:text-white transition-colors">Portal Home</RouterLink>
<span class="mx-2">/</span>
<span>Client Area</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-right">
<p class="text-purple-200 text-sm">Welcome back,</p>
<p class="text-white font-semibold">{{ Session.getInfo('first_name') }}</p>
</div>
<img
:src="`${Session.getInfo('avatar')}?height=40&width=40`"
alt="Profile"
class="w-10 h-10 rounded-full border-2 border-purple-500"
/>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-right">
<p class="text-purple-200 text-sm">Welcome back,</p>
<p class="text-white font-semibold">{{ Session.getInfo('first_name') }}</p>
</div>
<img :src="`${Session.getInfo('avatar')}?height=40&width=40`" alt="Profile"
class="w-10 h-10 rounded-full border-2 border-purple-500" />
</div>
</div>
</template>
<script setup lang="ts">
import Session from '@/mythicalclient/Session';
</script>
73 changes: 38 additions & 35 deletions frontend/src/components/client/Dashboard/Main/ProductList.vue
Original file line number Diff line number Diff line change
@@ -1,45 +1,48 @@
<template>
<CardComponent>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Your Active Products/Services</h2>
<button class="text-purple-500 hover:text-white transition-colors">
<MenuIcon class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div v-for="(server, index) in servers" :key="index"
class="flex items-center justify-between py-3 border-b border-purple-700 last:border-0">
<div>
<div class="font-medium text-white">{{ server.name }}</div>
<div class="text-sm text-purple-500">{{ server.hostname }}</div>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded text-xs font-medium">
Active
</span>
<button
class="px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-sm">
Manage
</button>
</div>
</div>
</div>
</CardComponent>

<CardComponent>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Your Active Products/Services</h2>
<button class="text-purple-500 hover:text-white transition-colors">
<MenuIcon class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div
v-for="(server, index) in servers"
:key="index"
class="flex items-center justify-between py-3 border-b border-purple-700 last:border-0"
>
<div>
<div class="font-medium text-white">{{ server.name }}</div>
<div class="text-sm text-purple-500">{{ server.hostname }}</div>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded text-xs font-medium">
Active
</span>
<button
class="px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-sm"
>
Manage
</button>
</div>
</div>
</div>
</CardComponent>
</template>
<script setup lang="ts">
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
import { ref } from 'vue';
import { MenuIcon } from 'lucide-vue-next';
const servers = ref([
{
name: 'Storage Root Server Frankfurt - Storage KVM S',
hostname: 'backup2.mythical.systems',
},
{
name: 'Storage Root Server Frankfurt - Storage KVM S',
hostname: 'backup.mythical.systems',
},
{
name: 'Storage Root Server Frankfurt - Storage KVM S',
hostname: 'backup2.mythical.systems',
},
{
name: 'Storage Root Server Frankfurt - Storage KVM S',
hostname: 'backup.mythical.systems',
},
]);
</script>
26 changes: 11 additions & 15 deletions frontend/src/components/client/Dashboard/Main/Stats.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
<template>
<div class="grid grid-cols-3 gap-4">
<CardComponent v-for="(stat, index) in stats" :key="index">
<component :is="stat.icon" class="w-6 h-6 text-purple-500 mb-2" />
<div class="text-3xl font-bold text-white mb-1">{{ stat.value }}</div>
<div class="text-purple-200 text-sm">{{ stat.label }}</div>
</CardComponent>
</div>
<div class="grid grid-cols-3 gap-4">
<CardComponent v-for="(stat, index) in stats" :key="index">
<component :is="stat.icon" class="w-6 h-6 text-purple-500 mb-2" />
<div class="text-3xl font-bold text-white mb-1">{{ stat.value }}</div>
<div class="text-purple-200 text-sm">{{ stat.label }}</div>
</CardComponent>
</div>
</template>
<script lang="ts" setup>
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
import {
Server as ServerIcon,
FileText as FileTextIcon,
Ticket as TicketIcon,
} from 'lucide-vue-next';
import { Server as ServerIcon, FileText as FileTextIcon, Ticket as TicketIcon } from 'lucide-vue-next';
const stats = [
{ icon: ServerIcon, value: '2', label: 'Services' },
{ icon: FileTextIcon, value: '0', label: 'Unpaid Invoices' },
{ icon: TicketIcon, value: '1', label: 'Tickets' },
{ icon: ServerIcon, value: '2', label: 'Services' },
{ icon: FileTextIcon, value: '0', label: 'Unpaid Invoices' },
{ icon: TicketIcon, value: '1', label: 'Tickets' },
];
</script>
114 changes: 55 additions & 59 deletions frontend/src/components/client/Dashboard/Main/SupportPin.vue
Original file line number Diff line number Diff line change
@@ -1,78 +1,74 @@
<template>
<!-- Support PIN -->
<CardComponent>
<h2 class="text-purple-200 text-sm font-medium mb-2">{{ t('Components.SupportPin.title') }}</h2>
<div class="flex items-center gap-2">
<span class="text-emerald-400 text-2xl font-mono font-bold">{{ Session.getInfo('support_pin') }}</span>
<button @click="copyPin" class="text-purple-500 hover:text-white transition-colors">
<CopyIcon class="w-4 h-4" />
</button>
<button @click="resetPin" class="text-purple-500 hover:text-white transition-colors">
<RefreshCcwIcon class="w-4 h-4 transition-transform duration-500 hover:rotate-180" />
</button>

</div>
</CardComponent>
<!-- Support PIN -->
<CardComponent>
<h2 class="text-purple-200 text-sm font-medium mb-2">{{ t('Components.SupportPin.title') }}</h2>
<div class="flex items-center gap-2">
<span class="text-emerald-400 text-2xl font-mono font-bold">{{ Session.getInfo('support_pin') }}</span>
<button @click="copyPin" class="text-purple-500 hover:text-white transition-colors">
<CopyIcon class="w-4 h-4" />
</button>
<button @click="resetPin" class="text-purple-500 hover:text-white transition-colors">
<RefreshCcwIcon class="w-4 h-4 transition-transform duration-500 hover:rotate-180" />
</button>
</div>
</CardComponent>
</template>
<script setup lang="ts">
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
import Auth from '@/mythicalclient/Auth';
import Session from '@/mythicalclient/Session';
import Swal from 'sweetalert2';
import { useI18n } from 'vue-i18n';
import {
RefreshCcw as RefreshCcwIcon,
Copy as CopyIcon,
} from 'lucide-vue-next';
import { RefreshCcw as RefreshCcwIcon, Copy as CopyIcon } from 'lucide-vue-next';
const { t } = useI18n();
const copyPin = async () => {
const pin = Session.getInfo('support_pin');
try {
if (!navigator.clipboard) {
const textArea = document.createElement('textarea')
textArea.value = pin
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
} else {
await navigator.clipboard.writeText(pin)
}
const pin = Session.getInfo('support_pin');
try {
if (!navigator.clipboard) {
const textArea = document.createElement('textarea');
textArea.value = pin;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
} else {
await navigator.clipboard.writeText(pin);
}
Swal.fire({
icon: 'success',
title: t('Components.Global.Navigation.Copy.Title'),
text: t('Components.Global.Navigation.Copy.Success'),
footer: t('Components.Global.Navigation.Copy.Footer'),
})
} catch (err) {
console.error('Failed to copy command to clipboard', err)
}
Swal.fire({
icon: 'success',
title: t('Components.Global.Navigation.Copy.Title'),
text: t('Components.Global.Navigation.Copy.Success'),
footer: t('Components.Global.Navigation.Copy.Footer'),
});
} catch (err) {
console.error('Failed to copy command to clipboard', err);
}
};
const resetPin = async () => {
try {
const ping = await Auth.resetPin();
const pinElement = document.querySelector('span.text-emerald-400');
if (pinElement) {
pinElement.textContent = ping.toString();
Swal.fire({
title: t('Components.SupportPin.alerts.success.title'),
text: t('Components.SupportPin.alerts.success.pin_success'),
icon: 'success',
footer: t('Components.SupportPin.alerts.success.footer'),
});
try {
const ping = await Auth.resetPin();
const pinElement = document.querySelector('span.text-emerald-400');
if (pinElement) {
pinElement.textContent = ping.toString();
Swal.fire({
title: t('Components.SupportPin.alerts.success.title'),
text: t('Components.SupportPin.alerts.success.pin_success'),
icon: 'success',
footer: t('Components.SupportPin.alerts.success.footer'),
});
}
} catch (error) {
console.error('Failed to reset support pin:', error);
Swal.fire({
title: t('Components.SupportPin.alerts.error.title'),
text: t('Components.SupportPin.alerts.error.generic'),
icon: 'error',
footer: t('Components.SupportPin.alerts.error.footer'),
});
}
} catch (error) {
console.error('Failed to reset support pin:', error);
Swal.fire({
title: t('Components.SupportPin.alerts.error.title'),
text: t('Components.SupportPin.alerts.error.generic'),
icon: 'error',
footer: t('Components.SupportPin.alerts.error.footer'),
});
}
};
</script>
63 changes: 34 additions & 29 deletions frontend/src/components/client/Dashboard/Main/TicketList.vue
Original file line number Diff line number Diff line change
@@ -1,40 +1,45 @@
<template>
<!-- Recent Tickets -->
<CardComponent>
<h2 class="text-lg font-semibold text-white mb-4">Recent Tickets</h2>
<div class="space-y-3">
<div v-for="ticket in recentTickets" :key="ticket.id"
class="flex items-center justify-between py-2 border-b border-purple-700 last:border-0">
<div>
<div class="font-medium text-white">{{ ticket.title }}</div>
<div class="text-sm text-purple-500">{{ ticket.date }}</div>
<!-- Recent Tickets -->
<CardComponent>
<h2 class="text-lg font-semibold text-white mb-4">Recent Tickets</h2>
<div class="space-y-3">
<div
v-for="ticket in recentTickets"
:key="ticket.id"
class="flex items-center justify-between py-2 border-b border-purple-700 last:border-0"
>
<div>
<div class="font-medium text-white">{{ ticket.title }}</div>
<div class="text-sm text-purple-500">{{ ticket.date }}</div>
</div>
<span
:class="[
'px-2 py-1 rounded text-xs font-medium',
ticket.status === 'Open'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-emerald-500/20 text-emerald-400',
]"
>
{{ ticket.status }}
</span>
</div>
</div>
<span :class="[
'px-2 py-1 rounded text-xs font-medium',
ticket.status === 'Open'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-emerald-500/20 text-emerald-400',
]">
{{ ticket.status }}
</span>
</div>
</div>
<RouterLink to="/support"
class="mt-4 block w-full px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-center text-sm">
View All Tickets
</RouterLink>
</CardComponent>

<RouterLink
to="/support"
class="mt-4 block w-full px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-center text-sm"
>
View All Tickets
</RouterLink>
</CardComponent>
</template>

<script setup lang="ts">
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
import { ref } from 'vue';
const recentTickets = ref([
{ id: 1, title: 'Server Performance Issue', date: '2023-07-01', status: 'Open' },
{ id: 2, title: 'Billing Inquiry', date: '2023-06-28', status: 'Closed' },
{ id: 3, title: 'Domain Transfer Request', date: '2023-06-25', status: 'Open' },
{ id: 1, title: 'Server Performance Issue', date: '2023-07-01', status: 'Open' },
{ id: 2, title: 'Billing Inquiry', date: '2023-06-28', status: 'Closed' },
{ id: 3, title: 'Domain Transfer Request', date: '2023-06-25', status: 'Open' },
]);
</script>
60 changes: 31 additions & 29 deletions frontend/src/components/client/Dashboard/Main/UserInfo.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
<template>
<!-- Profile Card -->
<CardComponent>
<div class="flex flex-col items-center text-center">
<MarketIcon class="w-20 h-20 text-purple-500 mb-4" />
<div class="text-xl text-white mb-2">
{{ Session.getInfo('company_name') }}
</div>
<div class="text-purple-200 text-sm space-y-1 mb-4">
<div>{{ Session.getInfo('vat_number') }}</div>
<div>{{ Session.getInfo('address1') }}</div>
<div>
{{ Session.getInfo('city') }} ({{ Session.getInfo('postcode') }}),
{{ Session.getInfo('country') }}
<!-- Profile Card -->
<CardComponent>
<div class="flex flex-col items-center text-center">
<MarketIcon class="w-20 h-20 text-purple-500 mb-4" />
<div class="text-xl text-white mb-2">
{{ Session.getInfo('company_name') }}
</div>
<div class="text-purple-200 text-sm space-y-1 mb-4">
<div>{{ Session.getInfo('vat_number') }}</div>
<div>{{ Session.getInfo('address1') }}</div>
<div>
{{ Session.getInfo('city') }} ({{ Session.getInfo('postcode') }}),
{{ Session.getInfo('country') }}
</div>
</div>
<div class="flex gap-2 w-full">
<RouterLink
to="/account"
class="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-sm"
>
Update
</RouterLink>
<a
href="/api/auth/logout"
class="flex-1 px-4 py-2 bg-purple-800 hover:bg-purple-700 text-white rounded transition-colors text-sm"
>
Logout
</a>
</div>
</div>
</div>
<div class="flex gap-2 w-full">
<RouterLink to="/account"
class="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded transition-colors text-sm">
Update
</RouterLink>
<a href="/api/auth/logout"
class="flex-1 px-4 py-2 bg-purple-800 hover:bg-purple-700 text-white rounded transition-colors text-sm">
Logout
</a>
</div>
</div>
</CardComponent>
</CardComponent>
</template>
<script lang="ts" setup>
import CardComponent from '@/components/client/ui/Card/CardComponent.vue';
import Session from '@/mythicalclient/Session';
import {
BookMarkedIcon as MarketIcon,
} from 'lucide-vue-next';
import { BookMarkedIcon as MarketIcon } from 'lucide-vue-next';
</script>
157 changes: 81 additions & 76 deletions frontend/src/components/client/ui/TextForms/SelectInput.vue
Original file line number Diff line number Diff line change
@@ -3,23 +3,23 @@ import { ref, onMounted, computed, onBeforeUnmount, watch, nextTick } from 'vue'
import TextInput from './TextInput.vue';
const props = defineProps({
modelValue: String,
options: {
type: Array as () => Array<{ value: string; label: string }>,
default: () => [],
},
inputClass: {
type: String,
default:
'w-full bg-gray-800/50 border border-gray-700/50 rounded-lg pl-4 pr-10 py-2 text-sm text-gray-100 focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/50 focus:outline-none',
},
modelValue: String,
options: {
type: Array as () => Array<{ value: string; label: string }>,
default: () => [],
},
inputClass: {
type: String,
default:
'w-full bg-gray-800/50 border border-gray-700/50 rounded-lg pl-4 pr-10 py-2 text-sm text-gray-100 focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/50 focus:outline-none',
},
});
const emit = defineEmits(['update:modelValue']);
const inputValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const searchQuery = ref('');
@@ -29,112 +29,117 @@ const optionsRef = ref<HTMLUListElement | null>(null);
const selectedIndex = ref(-1);
const filteredOptions = computed(() => {
return props.options.filter(option =>
option.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
return props.options.filter((option) => option.label.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
onMounted(() => {
if (!props.modelValue && props.options.length > 0) {
emit('update:modelValue', props.options[0].value);
}
if (!props.modelValue && props.options.length > 0) {
emit('update:modelValue', props.options[0].value);
}
document.addEventListener('click', handleClickOutside);
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('click', handleClickOutside);
});
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
isDropdownOpen.value = false;
}
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
isDropdownOpen.value = false;
}
};
const selectOption = (value: string) => {
inputValue.value = value;
isDropdownOpen.value = false;
inputValue.value = value;
isDropdownOpen.value = false;
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredOptions.value.length - 1);
scrollToSelectedOption();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex.value = Math.max(selectedIndex.value - 1, -1);
scrollToSelectedOption();
} else if (event.key === 'Enter') {
event.preventDefault();
if (selectedIndex.value >= 0 && selectedIndex.value < filteredOptions.value.length) {
selectOption(filteredOptions.value[selectedIndex.value].value);
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredOptions.value.length - 1);
scrollToSelectedOption();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex.value = Math.max(selectedIndex.value - 1, -1);
scrollToSelectedOption();
} else if (event.key === 'Enter') {
event.preventDefault();
if (selectedIndex.value >= 0 && selectedIndex.value < filteredOptions.value.length) {
selectOption(filteredOptions.value[selectedIndex.value].value);
}
}
}
};
const scrollToSelectedOption = () => {
nextTick(() => {
const selectedElement = optionsRef.value?.children[selectedIndex.value] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' });
}
});
nextTick(() => {
const selectedElement = optionsRef.value?.children[selectedIndex.value] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' });
}
});
};
watch(isDropdownOpen, async (newValue) => {
if (newValue) {
await nextTick();
searchInputRef.value?.focus();
selectedIndex.value = -1;
} else {
searchQuery.value = '';
}
if (newValue) {
await nextTick();
searchInputRef.value?.focus();
selectedIndex.value = -1;
} else {
searchQuery.value = '';
}
});
watch(filteredOptions, () => {
selectedIndex.value = -1;
selectedIndex.value = -1;
});
</script>

<template>
<div class="relative dropdown-container">
<div :class="inputClass" @click="isDropdownOpen = !isDropdownOpen">
<span>{{ props.options.find(option => option.value === inputValue)?.label || 'Select an option' }}</span>
</div>
<div v-if="isDropdownOpen" class="absolute z-10 w-full bg-gray-800 border border-gray-700 rounded-lg mt-1">
<TextInput v-model="searchQuery" placeholder="Search..." class="mb-2" ref="searchInputRef"
@keydown="handleKeydown" />
<ul ref="optionsRef">
<li v-for="(option, index) in filteredOptions" :key="option.value" @click="selectOption(option.value)"
@mouseover="selectedIndex = index" :class="[
'px-4 py-2 cursor-pointer hover:bg-gray-700',
{ 'bg-gray-700': index === selectedIndex }
]">
{{ option.label }}
</li>
</ul>
<div class="relative dropdown-container">
<div :class="inputClass" @click="isDropdownOpen = !isDropdownOpen">
<span>{{ props.options.find((option) => option.value === inputValue)?.label || 'Select an option' }}</span>
</div>
<div v-if="isDropdownOpen" class="absolute z-10 w-full bg-gray-800 border border-gray-700 rounded-lg mt-1">
<TextInput
v-model="searchQuery"
placeholder="Search..."
class="mb-2"
ref="searchInputRef"
@keydown="handleKeydown"
/>
<ul ref="optionsRef">
<li
v-for="(option, index) in filteredOptions"
:key="option.value"
@click="selectOption(option.value)"
@mouseover="selectedIndex = index"
:class="['px-4 py-2 cursor-pointer hover:bg-gray-700', { 'bg-gray-700': index === selectedIndex }]"
>
{{ option.label }}
</li>
</ul>
</div>
</div>
</div>
</template>

<style scoped>
.dropdown-container {
position: relative;
position: relative;
}
.dropdown-container>div {
cursor: pointer;
.dropdown-container > div {
cursor: pointer;
}
.dropdown-container ul {
max-height: 200px;
overflow-y: auto;
max-height: 200px;
overflow-y: auto;
}
.dropdown-container li {
transition: background-color 0.2s;
transition: background-color 0.2s;
}
</style>
26 changes: 13 additions & 13 deletions frontend/src/components/client/ui/TextForms/TextArea.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
<template>
<textarea v-model="inputValue" :class="inputClass" :placeholder="placeholder"></textarea>
<textarea v-model="inputValue" :class="inputClass" :placeholder="placeholder"></textarea>
</template>

<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
modelValue: String,
inputClass: {
type: String,
default:
'w-full bg-gray-800/50 border border-gray-700/50 rounded-lg pl-4 pr-10 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/50 focus:outline-none',
},
placeholder: {
type: String,
default: '',
},
modelValue: String,
inputClass: {
type: String,
default:
'w-full bg-gray-800/50 border border-gray-700/50 rounded-lg pl-4 pr-10 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/50 focus:outline-none',
},
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const inputValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
</script>
114 changes: 82 additions & 32 deletions frontend/src/locale/en.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
---

#-------------------------------------------------------------#
#
# Global Components
@@ -8,44 +7,44 @@
#
#-------------------------------------------------------------#
Components:
ErrorsPage:
Buttons:
GoBack: "Go Back"
GoHome: "Go Home"
Global:
Navigation:
Loading: "Loading..."
Copy:
Title: "Copied"
Success: "Copied to clipboard just like a boss."
Footer: "Yay it worked ;)"
Error: "An error occurred. Please try again later."
Announcements:
Title: "Announcements"
Card:
ReadMore: "Read More"
SupportPin:
title: "Support PIN"
copy: "Copy"
alerts:
success:
title: "Success"
pin_success: "Support PIN generated"
footer: "Your support PIN has been generated"
error:
title: "Error"
generic: "An error occurred. Please try again later"
footer: "Please contact support for assistance"
ErrorsPage:
Buttons:
GoBack: 'Go Back'
GoHome: 'Go Home'
Global:
Navigation:
Loading: 'Loading...'
Copy:
Title: 'Copied'
Success: 'Copied to clipboard just like a boss.'
Footer: 'Yay it worked ;)'
Error: 'An error occurred. Please try again later.'
Announcements:
Title: 'Announcements'
Card:
ReadMore: 'Read More'
SupportPin:
title: 'Support PIN'
copy: 'Copy'
alerts:
success:
title: 'Success'
pin_success: 'Support PIN generated'
footer: 'Your support PIN has been generated'
error:
title: 'Error'
generic: 'An error occurred. Please try again later'
footer: 'Please contact support for assistance'

#-------------------------------------------------------------#
#
# Auth Pages (/auth/*)
#
# @public
#
#-------------------------------------------------------------#
dashboard:
title: Dashboard

dashboard:
title: Dashboard

auth:
logic:
@@ -282,6 +281,57 @@ errors:
#-------------------------------------------------------------#
account:
pages:
tickets:
title: Tickets
actions:
newTicket: 'Tickets'
view: 'View'
alerts:
error:
generic: 'An error occurred. Please try again later'
table:
title: 'Tickets'
subject: 'Subject'
status: 'Status'
priority: 'Priority'
department: 'Department'
created: 'Created'
actions: 'Actions'
noTickets: 'No tickets found'
create_ticket:
title: 'New Ticket'
subTitle: 'Create a new ticket'
form:
service: 'Related Service (if any)'
subject: 'Subject'
department: 'Department'
priority: 'Priority'
message: 'Message'
submit: 'Submit'
loading: 'Submitting...'
reset: 'Reset'
back: 'Back'
types:
priority:
low: 'Low'
medium: 'Medium'
high: 'High'
urgent: 'Urgent'
alerts:
success:
title: 'Success'
ticket_success: 'Ticket created'
footer: 'Your ticket has been created'
error:
title: 'Error'
generic: 'An error occurred. Please try again later'
footer: 'Please contact support for assistance'
department_not_found: 'Department not found'
department_id_missing: 'Department ID is missing'
message_missing: 'Message is missing'
subject_missing: 'Subject is missing'
limit_reached: 'You have reached the limit of tickets you can create'
failed_to_create_ticket: 'Cluster is down, try again later'
settings:
alerts:
success:
460 changes: 230 additions & 230 deletions frontend/src/mythicalclient/Auth.ts

Large diffs are not rendered by default.

61 changes: 34 additions & 27 deletions frontend/src/mythicalclient/Tickets.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
class Tickets {
public static async getTicketCreateInfo() {
const response = await fetch('/api/user/ticket/create', {
method: 'GET',
});
const data = await response.json();
return data;
}
static async createTicket(
department_id: number,
subject: string,
message: string,
priority: string,
service: number,
) {
const response = await fetch('/api/user/ticket/create', {
method: 'POST',
body: new URLSearchParams({
department_id: department_id.toString(),
subject: subject,
message: message,
priority: priority,
service: service.toString() || '',
}),
});
const data = await response.json();
return data;
}
public static async getTicketCreateInfo() {
const response = await fetch('/api/user/ticket/create', {
method: 'GET',
});
const data = await response.json();
return data;
}
static async createTicket(
department_id: number,
subject: string,
message: string,
priority: string,
service: number,
) {
const response = await fetch('/api/user/ticket/create', {
method: 'POST',
body: new URLSearchParams({
department_id: department_id.toString(),
subject: subject,
message: message,
priority: priority,
service: service.toString() || '',
}),
});
const data = await response.json();
return data;
}
static async getTickets() {
const response = await fetch('/api/user/ticket/list', {
method: 'GET',
});
const data = await response.json();
return data;
}
}

export default Tickets;
196 changes: 98 additions & 98 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,110 @@
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
{
path: '/auth/login',
name: 'Login',
component: () => import('@/views/client/auth/Login.vue'),
},
{
path: '/auth/register',
name: 'Register',
component: () => import('@/views/client/auth/Register.vue'),
},
{
path: '/auth/forgot-password',
name: 'Forgot Password',
component: () => import('@/views/client/auth/ForgotPassword.vue'),
},
{
path: '/auth/reset-password',
name: 'Reset Password',
component: () => import('@/views/client/auth/ResetPassword.vue'),
},
{
path: '/auth/2fa/setup',
name: 'Two Factor Setup',
component: () => import('@/views/client/auth/TwoFactorSetup.vue'),
},
{
path: '/errors/403',
name: 'Forbidden',
component: () => import('@/views/client/errors/Forbidden.vue'),
},
{
path: '/errors/500',
name: 'ServerError',
component: () => import('@/views/client/errors/ServerError.vue'),
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/client/Home.vue'),
},
{
path: '/account',
name: 'Account',
component: () => import('@/views/client/Account.vue'),
},
{
path: '/ticket',
name: 'Ticket',
component: () => import('@/views/client/ticket/List.vue'),
},
{
path: '/ticket/create',
name: 'Create Ticket',
component: () => import('@/views/client/ticket/Create.vue'),
},
{
path: '/ticket/:id',
name: 'Ticket Detail',
component: () => import('@/views/client/ticket/[id].vue'),
},
{
path: '/auth/sso',
name: 'SSO',
component: () => import('@/views/client/auth/sso.vue'),
},
{
path: '/auth/2fa/setup/disband',
redirect: () => {
window.location.href = '/api/auth/2fa/setup/kill';
return '/api/auth/2fa/setup/kill';
},
},
{
path: '/auth/logout',
redirect: () => {
window.location.href = '/api/user/auth/logout';
return '/api/user/auth/logout';
},
},
{
path: '/auth/2fa/verify',
name: 'Two Factor Verify',
component: () => import('@/views/client/auth/TwoFactorVerify.vue'),
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/mc-admin',
name: 'Admin Home',
component: () => import('@/views/admin/Home.vue'),
},
{
path: '/auth/login',
name: 'Login',
component: () => import('@/views/client/auth/Login.vue'),
},
{
path: '/auth/register',
name: 'Register',
component: () => import('@/views/client/auth/Register.vue'),
},
{
path: '/auth/forgot-password',
name: 'Forgot Password',
component: () => import('@/views/client/auth/ForgotPassword.vue'),
},
{
path: '/auth/reset-password',
name: 'Reset Password',
component: () => import('@/views/client/auth/ResetPassword.vue'),
},
{
path: '/auth/2fa/setup',
name: 'Two Factor Setup',
component: () => import('@/views/client/auth/TwoFactorSetup.vue'),
},
{
path: '/errors/403',
name: 'Forbidden',
component: () => import('@/views/client/errors/Forbidden.vue'),
},
{
path: '/errors/500',
name: 'ServerError',
component: () => import('@/views/client/errors/ServerError.vue'),
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/client/Home.vue'),
},
{
path: '/account',
name: 'Account',
component: () => import('@/views/client/Account.vue'),
},
{
path: '/ticket',
name: 'Ticket',
component: () => import('@/views/client/ticket/List.vue'),
},
{
path: '/ticket/create',
name: 'Create Ticket',
component: () => import('@/views/client/ticket/Create.vue'),
},
{
path: '/ticket/:id',
name: 'Ticket Detail',
component: () => import('@/views/client/ticket/[id].vue'),
},
{
path: '/auth/sso',
name: 'SSO',
component: () => import('@/views/client/auth/sso.vue'),
},
{
path: '/auth/2fa/setup/disband',
redirect: () => {
window.location.href = '/api/auth/2fa/setup/kill';
return '/api/auth/2fa/setup/kill';
},
},
{
path: '/auth/logout',
redirect: () => {
window.location.href = '/api/user/auth/logout';
return '/api/user/auth/logout';
},
},
{
path: '/auth/2fa/verify',
name: 'Two Factor Verify',
component: () => import('@/views/client/auth/TwoFactorVerify.vue'),
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/mc-admin',
name: 'Admin Home',
component: () => import('@/views/admin/Home.vue'),
},
];

routes.push({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/client/errors/NotFound.vue'),
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/client/errors/NotFound.vue'),
});

const router = createRouter({
history: createWebHistory(),
routes,
history: createWebHistory(),
routes,
});

export default router;
54 changes: 26 additions & 28 deletions frontend/src/views/client/Home.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
<template>
<LayoutDashboard>
<div class="p-6 space-y-6">
<Header />
<LayoutDashboard>
<div class="p-6 space-y-6">
<Header />

<div class="grid gap-6 lg:grid-cols-4">
<!-- Left Column -->
<div class="space-y-6">
<SupportPin />
<!-- User Info -->
<UserInfo />
<!-- Billing Summary -->
<BillingInfo />
<div class="grid gap-6 lg:grid-cols-4">
<!-- Left Column -->
<div class="space-y-6">
<SupportPin />
<!-- User Info -->
<UserInfo />
<!-- Billing Summary -->
<BillingInfo />
</div>
<!-- Main Content -->
<div class="lg:col-span-3 space-y-6">
<!-- Stats Grid -->
<Stats />
<!-- Active Products -->
<ProductList />
<!-- Recent Tickets -->
<TicketList />
<!-- Announcements -->
</div>
</div>
<Announcements />
</div>
<!-- Main Content -->
<div class="lg:col-span-3 space-y-6">
<!-- Stats Grid -->
<Stats />
<!-- Active Products -->
<ProductList />
<!-- Recent Tickets -->
<TicketList />
<!-- Announcements -->
</div>

</div>
<Announcements />

</div>
</LayoutDashboard>
</LayoutDashboard>
</template>

<script setup lang="ts">
@@ -44,5 +42,5 @@ import Header from '@/components/client/Dashboard/Main/Header.vue';
import Settings from '@/mythicalclient/Settings';
const { t } = useI18n();
document.title = Settings.getSetting('app_name') + " - " + t("dashboard.title");
document.title = Settings.getSetting('app_name') + ' - ' + t('dashboard.title');
</script>
362 changes: 196 additions & 166 deletions frontend/src/views/client/ticket/Create.vue
Original file line number Diff line number Diff line change
@@ -18,202 +18,232 @@ const { play: playError } = useSound(failedAlertSfx);
const { play: playSuccess } = useSound(successAlertSfx);
const router = useRouter();
document.title = t('account.pages.create_ticket.title');
interface Department {
id: number;
name: string;
description: string;
time_open: string;
time_close: string;
enabled: string;
deleted: string;
locked: string;
date: string;
id: number;
name: string;
description: string;
time_open: string;
time_close: string;
enabled: string;
deleted: string;
locked: string;
date: string;
}
interface Service {
id: number;
name: string;
active: boolean;
id: number;
name: string;
active: boolean;
}
interface TicketCreateInfo {
departments: Department[];
services: Service[];
departments: Department[];
services: Service[];
}
const ticketCreateInfo = ref<TicketCreateInfo | null>(null);
const loading = ref(false);
const fetchTicketCreateInfo = async () => {
try {
const response = await Tickets.getTicketCreateInfo();
if (response.success) {
ticketCreateInfo.value = {
departments: response.departments,
services: response.services
};
} else {
console.error('Failed to fetch ticket create info:', response.error);
try {
const response = await Tickets.getTicketCreateInfo();
if (response.success) {
ticketCreateInfo.value = {
departments: response.departments,
services: response.services,
};
} else {
console.error('Failed to fetch ticket create info:', response.error);
}
} catch (error) {
console.error('Error fetching ticket create info:', error);
}
} catch (error) {
console.error('Error fetching ticket create info:', error);
}
};
onMounted(() => {
fetchTicketCreateInfo();
fetchTicketCreateInfo();
});
const ticket = ref({
service: '',
department: '',
priority: 'medium',
subject: '',
message: ''
service: '',
department: '',
priority: 'medium',
subject: '',
message: '',
});
const priorities = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
{ value: 'low', label: t('account.pages.create_ticket.types.priority.low') },
{ value: 'medium', label: t('account.pages.create_ticket.types.priority.medium') },
{ value: 'high', label: t('account.pages.create_ticket.types.priority.high') },
{ value: 'urgent', label: t('account.pages.create_ticket.types.priority.urgent') },
];
const submitTicket = async () => {
loading.value = true;
try {
// Handle ticket submission
console.log('Submitting ticket:', ticket.value);
const response = await Tickets.createTicket(Number(ticket.value.department), ticket.value.subject, ticket.value.message, ticket.value.priority, Number(ticket.value.service));
if (!response.success) {
const error_code = response.error_code as keyof typeof errorMessages;
const errorMessages = {
};
if (errorMessages[error_code]) {
playError();
Swal.fire({
icon: 'error',
title: "Error while creating ticket!",
text: errorMessages[error_code],
footer: "Kinda funny since you tried to create a ticket",
showConfirmButton: true,
});
loading.value = true;
try {
// Handle ticket submission
console.log('Submitting ticket:', ticket.value);
const response = await Tickets.createTicket(
Number(ticket.value.department),
ticket.value.subject,
ticket.value.message,
ticket.value.priority,
Number(ticket.value.service),
);
if (!response.success) {
const error_code = response.error_code as keyof typeof errorMessages;
const errorMessages = {
LIMIT_REACHED: t('account.pages.create_ticket.alerts.error.limit_reached'),
FAILED_TO_CREATE_TICKET: t('account.pages.create_ticket.alerts.error.generic'),
DEPARTMENT_NOT_FOUND: t('account.pages.create_ticket.alerts.error.department_not_found'),
DEPARTMENT_ID_MISSING: t('account.pages.create_ticket.alerts.error.department_id_missing'),
MESSAGE_MISSING: t('account.pages.create_ticket.alerts.error.message_missing'),
SUBJECT_MISSING: t('account.pages.create_ticket.alerts.error.subject_missing'),
};
if (errorMessages[error_code]) {
playError();
Swal.fire({
icon: 'error',
title: t('account.pages.create_ticket.alerts.error.title'),
text: errorMessages[error_code],
footer: t('account.pages.create_ticket.alerts.error.footer'),
showConfirmButton: true,
});
loading.value = false;
throw new Error('Login failed');
} else {
playError();
Swal.fire({
icon: 'error',
title: t('account.pages.create_ticket.alerts.error.title'),
text: t('account.pages.create_ticket.alerts.error.generic'),
footer: t('account.pages.create_ticket.alerts.error.footer'),
showConfirmButton: true,
});
loading.value = false;
throw new Error('Ticket creation failed');
}
} else {
playSuccess();
Swal.fire({
icon: 'success',
title: t('account.pages.create_ticket.alerts.success.title'),
text: t('account.pages.create_ticket.alerts.success.ticket_success'),
footer: t('account.pages.create_ticket.alerts.success.footer'),
showConfirmButton: true,
});
loading.value = false;
setTimeout(() => {
router.push('/ticket');
}, 1500);
console.log('Ticket submitted successfully:', response.ticket);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch (error) {
console.error('Error submitting ticket:', error);
} finally {
loading.value = false;
throw new Error('Login failed');
} else {
playError();
Swal.fire({
icon: 'error',
title: "Error while creating ticket!",
text: "We never expected this to happen",
footer: "Kinda funny since you tried to create a ticket",
showConfirmButton: true,
});
loading.value = false;
throw new Error('Ticket creation failed');
}
} else {
playSuccess();
Swal.fire({
icon: 'success',
title: "Ticket submitted successfully!",
text: "Your ticket has been submitted successfully",
footer: "You will be redirected to the ticket page",
showConfirmButton: true,
});
loading.value = false;
setTimeout(() => {
router.push('/ticket');
}, 1500);
console.log('Ticket submitted successfully:', response.ticket);
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error('Error submitting ticket:', error);
} finally {
loading.value = false;
}
};
</script>

<template>
<LayoutDashboard>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-semibold text-gray-100">Support Tickets</h1>
<router-link to="/ticket">
<button
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2.5 rounded-lg font-medium transition-colors duration-200">
Back
</button>
</router-link>
</div>
<CardComponent cardTitle="Create a Ticket" cardDescription="Submit a new ticket to our support team.">
<form @submit.prevent="submitTicket" class="space-y-6">
<!-- Service Selection -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Related Service
</label>
<SelectInput v-model="ticket.service" :options="ticketCreateInfo?.services.map(service => ({
value: service.id.toString(),
label: service.name
})) || []" />
</div>

<!-- Department -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Department
</label>
<SelectInput v-model="ticket.department" :options="ticketCreateInfo?.departments
.filter(dept => dept.enabled === 'true')
.map(dept => ({
value: dept.id.toString(),
label: `${dept.name} (${dept.time_open} - ${dept.time_close}) - ${dept.description}`
})) || []" />
</div>

<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Priority
</label>
<SelectInput v-model="ticket.priority" :options="priorities" />
</div>

<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Subject
</label>
<TextInput v-model="ticket.subject" type="text" required placeholder="Enter ticket subject" />
</div>

<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Message
</label>
<TextArea v-model="ticket.message" rows="6" required placeholder="Describe your issue..." />
</div>

<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2.5 rounded-lg font-medium transition-colors duration-200">
<span v-if="loading">Submitting...</span>
<span v-else>Submit Ticket</span>
</button>
</div>
</form>
</CardComponent>
</div>
</LayoutDashboard>
<LayoutDashboard>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-semibold text-gray-100">{{ t('account.pages.create_ticket.title') }}</h1>
<router-link to="/ticket">
<button
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2.5 rounded-lg font-medium transition-colors duration-200"
>
{{ t('account.pages.create_ticket.form.back') }}
</button>
</router-link>
</div>
<CardComponent
:cardTitle="t('account.pages.create_ticket.title')"
:cardDescription="t('account.pages.create_ticket.subTitle')"
>
<form @submit.prevent="submitTicket" class="space-y-6">
<!-- Service Selection -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ t('account.pages.create_ticket.form.service') }}
</label>
<SelectInput
v-model="ticket.service"
:options="
ticketCreateInfo?.services.map((service) => ({
value: service.id.toString(),
label: service.name,
})) || []
"
/>
</div>

<!-- Department -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ t('account.pages.create_ticket.form.department') }}
</label>
<SelectInput
v-model="ticket.department"
:options="
ticketCreateInfo?.departments
.filter((dept) => dept.enabled === 'true')
.map((dept) => ({
value: dept.id.toString(),
label: `${dept.name} (${dept.time_open} - ${dept.time_close}) - ${dept.description}`,
})) || []
"
/>
</div>

<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ t('account.pages.create_ticket.form.priority') }}
</label>
<SelectInput v-model="ticket.priority" :options="priorities" />
</div>

<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ t('account.pages.create_ticket.form.subject') }}
</label>
<TextInput v-model="ticket.subject" type="text" required placeholder="Enter ticket subject" />
</div>

<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ t('account.pages.create_ticket.form.message') }}
</label>
<TextArea v-model="ticket.message" rows="6" required placeholder="Describe your issue..." />
</div>

<!-- Submit Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="loading"
class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2.5 rounded-lg font-medium transition-colors duration-200"
>
<span v-if="loading">{{ t('account.pages.create_ticket.form.loading') }}</span>
<span v-else>{{ t('account.pages.create_ticket.form.submit') }}</span>
</button>
</div>
</form>
</CardComponent>
</div>
</LayoutDashboard>
</template>
250 changes: 194 additions & 56 deletions frontend/src/views/client/ticket/List.vue
Original file line number Diff line number Diff line change
@@ -1,99 +1,237 @@
<script setup lang="ts">
import { ref, onMounted, onErrorCaptured, computed, h } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import LayoutDashboard from '@/components/client/LayoutDashboard.vue';
import TableTanstack from '@/components/client/ui/Table/TableTanstack.vue';
import { h } from 'vue';
import { useRouter } from 'vue-router';
import Tickets from '@/mythicalclient/Tickets';
import { AlertCircle, Plus, Ticket } from 'lucide-vue-next';
const router = useRouter();
const { t } = useI18n();
const Tickets = [
{
id: 1,
department: 'IT Support',
subject: 'Computer not starting',
status: 'Open',
category: 'Hardware',
created_at: '2023-10-01',
},
{
id: 2,
department: 'HR',
subject: 'Payroll issue',
status: 'In Progress',
category: 'Payroll',
created_at: '2023-10-01',
},
{
id: 3,
department: 'IT Support',
subject: 'Email not working',
status: 'Closed',
category: 'Software',
created_at: '2023-10-01',
},
{
id: 4,
department: 'Facilities',
subject: 'Broken AC',
status: 'Open',
category: 'Maintenance',
created_at: '2023-10-01',
},
];
interface Department {
id: number;
name: string;
description: string;
time_open: string;
time_close: string;
enabled: string;
deleted: string;
locked: string;
date: string;
}
interface Ticket {
id: number;
user: string;
department: Department;
priority: string;
status: string;
service: number | null;
title: string;
description: string;
deleted: string;
locked: string;
date: string;
department_id: number;
}
const tickets = ref<Ticket[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const fetchTickets = async () => {
try {
const response = await Tickets.getTickets();
if (response.success && Array.isArray(response.tickets)) {
tickets.value = response.tickets;
} else {
throw new Error(response.error || 'Failed to fetch tickets');
}
} catch (err) {
error.value = err instanceof Error ? err.message : t('account.pages.tickets.alerts.error.generic');
} finally {
loading.value = false;
}
};
onMounted(fetchTickets);
onErrorCaptured((err) => {
error.value = t('account.pages.tickets.alerts.error.generic');
console.error('Error captured:', err);
return false;
});
const filteredTickets = computed(() => {
return tickets.value.filter((ticket) => ticket.status.toUpperCase() !== 'CLOSED');
});
const columnsTickets = [
{
accessorKey: 'department',
header: 'Department',
accessorKey: 'title',
header: t('account.pages.tickets.table.subject'),
},
{
accessorKey: 'subject',
header: 'Subject',
accessorKey: 'status',
header: t('account.pages.tickets.table.status'),
cell: (info: { getValue: () => string }) => {
const status = info.getValue().toUpperCase();
let statusClass = '';
switch (status) {
case 'OPEN':
statusClass = 'bg-green-100 text-green-800';
break;
case 'CLOSED':
statusClass = 'bg-red-100 text-red-800';
break;
case 'WAITING':
statusClass = 'bg-yellow-100 text-yellow-800';
break;
case 'REPLIED':
statusClass = 'bg-blue-100 text-blue-800';
break;
case 'INPROGRESS':
statusClass = 'bg-orange-100 text-orange-800';
break;
default:
statusClass = 'bg-gray-100 text-gray-800';
}
return h(
'span',
{ class: `px-2 py-1 rounded-full text-xs font-semibold ${statusClass} dark:bg-opacity-50` },
status === 'INPROGRESS' ? 'IN PROGRESS' : status,
);
},
},
{
accessorKey: 'status',
header: 'Status',
accessorKey: 'priority',
header: t('account.pages.tickets.table.priority'),
cell: (info: { getValue: () => string }) => {
const priority = info.getValue().toUpperCase();
let priorityClass = '';
switch (priority) {
case 'LOW':
priorityClass = 'bg-green-100 text-green-800';
break;
case 'MEDIUM':
priorityClass = 'bg-yellow-100 text-yellow-800';
break;
case 'HIGH':
priorityClass = 'bg-orange-100 text-orange-800';
break;
case 'URGENT':
priorityClass = 'bg-red-100 text-red-800';
break;
default:
priorityClass = 'bg-gray-100 text-gray-800';
}
return h(
'span',
{ class: `px-2 py-1 rounded-full text-xs font-semibold ${priorityClass} dark:bg-opacity-50` },
priority,
);
},
},
{
accessorKey: 'category',
header: 'Category',
accessorKey: 'department.name',
header: t('account.pages.tickets.table.department'),
},
{
accessorKey: 'created_at',
header: 'Created',
accessorKey: 'date',
header: t('account.pages.tickets.table.created'),
cell: (info: { getValue: () => string | number | Date }) => format(new Date(info.getValue()), 'MMM d, yyyy'),
},
{
accessorKey: 'actions',
header: 'Actions',
header: t('account.pages.tickets.table.actions'),
enableSorting: false,
cell: ({ row }: { row: { original: { id: number } } }) =>
h(
'button',
{
onClick: () => callMovetoTicket(row.original.id),
onClick: () => viewTicket(row.original.id),
class: 'text-purple-500 hover:underline',
},
'View',
t('account.pages.tickets.actions.view'),
),
},
];
function callMovetoTicket(id: number) {
console.log('Move to ticket with ID:', id);
function viewTicket(id: number) {
router.push(`/ticket/${id}`);
}
function createNewTicket() {
router.push('/ticket/create');
}
</script>

<template>
<LayoutDashboard>
<div class="space-y-6">
<div class="space-y-6 p-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-semibold text-gray-100">Support Tickets</h1>
<h1 class="text-3xl font-bold text-gray-100">{{ t('account.pages.tickets.title') }}</h1>
<button
class="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors"
@click="createNewTicket"
class="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors flex items-center space-x-2"
>
New Ticket
<Plus class="w-4 h-4" />
<span>{{ t('account.pages.tickets.actions.newTicket') }}</span>
</button>
</div>
<TableTanstack :data="Tickets" :columns="columnsTickets" tableName="Your tickets" />

<Transition name="fade" mode="out-in">
<div v-if="loading" class="space-y-4" key="loading">
<div v-for="i in 5" :key="i" class="bg-gray-800 rounded-lg p-4 animate-pulse">
<div class="h-6 bg-gray-700 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
</div>

<div
v-else-if="error"
class="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded-lg flex items-center space-x-2"
role="alert"
key="error"
>
<AlertCircle class="w-5 h-5 flex-shrink-0" />
<p>{{ error }}</p>
</div>

<div v-else-if="tickets.length === 0" class="text-center py-12 bg-gray-800 rounded-lg" key="empty">
<Ticket class="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p class="text-xl font-semibold text-gray-300">{{ t('account.pages.tickets.noTickets') }}</p>
<p class="text-gray-400 mt-2">{{ t('account.pages.tickets.createNewTicket') }}</p>
<button
@click="createNewTicket"
class="mt-4 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors"
>
{{ t('account.pages.tickets.actions.newTicket') }}
</button>
</div>

<div v-else key="table">
<TableTanstack
:data="filteredTickets"
:columns="columnsTickets"
:tableName="t('account.pages.tickets.table.title')"
/>
</div>
</Transition>
</div>
</LayoutDashboard>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

0 comments on commit c4a2594

Please sign in to comment.