Add standalone support ticket system with customer and admin interfaces
Replaces planned SupportPal integration with a built-in ticket system. Customer side: create tickets, reply, close. Admin side: manage all tickets with search/filters, staff replies, status updates. Includes 30 Pest tests (144 total, 775 assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
233
website/resources/ts/Pages/Admin/Tickets/Index.vue
Normal file
233
website/resources/ts/Pages/Admin/Tickets/Index.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||
import type { PaginatedResponse, SupportTicket } from '@/types'
|
||||
|
||||
interface Filters {
|
||||
search: string
|
||||
status: string
|
||||
priority: string
|
||||
department: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tickets: PaginatedResponse<SupportTicket>
|
||||
filters: Filters
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const search = ref<string>(props.filters.search)
|
||||
const status = ref<string>(props.filters.status)
|
||||
const priority = ref<string>(props.filters.priority)
|
||||
const department = ref<string>(props.filters.department)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'All Statuses', value: '' },
|
||||
{ title: 'Open', value: 'open' },
|
||||
{ title: 'In Progress', value: 'in_progress' },
|
||||
{ title: 'Waiting', value: 'waiting' },
|
||||
{ title: 'Closed', value: 'closed' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ title: 'All Priorities', value: '' },
|
||||
{ title: 'Low', value: 'low' },
|
||||
{ title: 'Medium', value: 'medium' },
|
||||
{ title: 'High', value: 'high' },
|
||||
{ title: 'Urgent', value: 'urgent' },
|
||||
]
|
||||
|
||||
const departmentOptions = [
|
||||
{ title: 'All Departments', value: '' },
|
||||
{ title: 'General', value: 'general' },
|
||||
{ title: 'Billing', value: 'billing' },
|
||||
{ title: 'Technical', value: 'technical' },
|
||||
{ title: 'Sales', value: 'sales' },
|
||||
]
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function applyFilters(): void {
|
||||
router.get('/tickets', {
|
||||
search: search.value || undefined,
|
||||
status: status.value || undefined,
|
||||
priority: priority.value || undefined,
|
||||
department: department.value || undefined,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(applyFilters, 300)
|
||||
})
|
||||
|
||||
watch([status, priority, department], () => {
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
function formatStatus(statusVal: string): string {
|
||||
return statusVal.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Support Tickets
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Manage all customer support tickets
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<VCard class="mb-6">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="search"
|
||||
prepend-inner-icon="tabler-search"
|
||||
placeholder="Search by subject, customer name, or email..."
|
||||
density="compact"
|
||||
clearable
|
||||
hide-details
|
||||
@click:clear="search = ''"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="status"
|
||||
:items="statusOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Status"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="priority"
|
||||
:items="priorityOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Priority"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<VSelect
|
||||
v-model="department"
|
||||
:items="departmentOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Department"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Tickets Table -->
|
||||
<VCard>
|
||||
<VCardText v-if="tickets.data.length === 0" class="text-center py-12">
|
||||
<VIcon icon="tabler-ticket-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No tickets found.
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VTable v-else density="comfortable" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Customer</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Department</th>
|
||||
<th>Last Updated</th>
|
||||
<th class="text-center">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
||||
<td class="text-body-2 text-medium-emphasis">
|
||||
{{ ticket.id }}
|
||||
</td>
|
||||
<td class="text-body-2 font-weight-medium">
|
||||
{{ ticket.subject }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ ticket.user?.name ?? 'Unknown' }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ ticket.user?.email ?? '' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveTicketStatusColor(ticket.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ formatStatus(ticket.status) }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ ticket.priority }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td class="text-body-2 text-capitalize">
|
||||
{{ ticket.department }}
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ formatDate(ticket.updated_at) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Link :href="`/tickets/${ticket.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<VCardText v-if="tickets.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||
<VPagination
|
||||
:model-value="tickets.data.length > 0 ? Math.ceil((tickets.from ?? 1) / 25) : 1"
|
||||
:length="tickets.last_page"
|
||||
:total-visible="7"
|
||||
@update:model-value="(page: number) => router.get('/tickets', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="tickets.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||
Showing {{ tickets.from }} to {{ tickets.to }} of {{ tickets.total }} tickets
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
397
website/resources/ts/Pages/Admin/Tickets/Show.vue
Normal file
397
website/resources/ts/Pages/Admin/Tickets/Show.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<script lang="ts" setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||
import type { SupportTicket } from '@/types'
|
||||
|
||||
interface Props {
|
||||
ticket: SupportTicket
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const replyForm = useForm({
|
||||
body: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const statusForm = useForm({
|
||||
status: '',
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'No Change', value: '' },
|
||||
{ title: 'Open', value: 'open' },
|
||||
{ title: 'In Progress', value: 'in_progress' },
|
||||
{ title: 'Waiting on Customer', value: 'waiting' },
|
||||
{ title: 'Closed', value: 'closed' },
|
||||
]
|
||||
|
||||
const quickStatusOptions = [
|
||||
{ title: 'Open', value: 'open', color: 'info' },
|
||||
{ title: 'In Progress', value: 'in_progress', color: 'success' },
|
||||
{ title: 'Waiting', value: 'waiting', color: 'warning' },
|
||||
{ title: 'Closed', value: 'closed', color: 'secondary' },
|
||||
]
|
||||
|
||||
const statusDialog = ref<boolean>(false)
|
||||
const pendingStatus = ref<string>('')
|
||||
|
||||
function submitReply(): void {
|
||||
replyForm.post(`/tickets/${props.ticket.id}/reply`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
replyForm.reset('body', 'status')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openStatusDialog(newStatus: string): void {
|
||||
pendingStatus.value = newStatus
|
||||
statusDialog.value = true
|
||||
}
|
||||
|
||||
function confirmStatusUpdate(): void {
|
||||
statusForm.status = pendingStatus.value
|
||||
statusForm.put(`/tickets/${props.ticket.id}/status`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
statusDialog.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function getUserInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="d-flex align-center gap-4">
|
||||
<Link href="/tickets">
|
||||
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
|
||||
</Link>
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="text-h4 font-weight-bold">Ticket #{{ ticket.id }}</span>
|
||||
<VChip
|
||||
:color="resolveTicketStatusColor(ticket.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ formatStatus(ticket.status) }}
|
||||
</VChip>
|
||||
<VChip
|
||||
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ ticket.priority }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ ticket.user?.name ?? 'Unknown Customer' }} · {{ ticket.user?.email ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Conversation Thread -->
|
||||
<VCol cols="12" lg="8">
|
||||
<!-- Ticket Subject Card -->
|
||||
<VCard class="mb-4">
|
||||
<VCardText>
|
||||
<div class="text-h6 font-weight-bold mb-2">
|
||||
{{ ticket.subject }}
|
||||
</div>
|
||||
<div class="d-flex align-center ga-4 text-body-2 text-medium-emphasis">
|
||||
<span>
|
||||
<VIcon icon="tabler-building" size="16" class="me-1" />
|
||||
<span class="text-capitalize">{{ ticket.department }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<VIcon icon="tabler-calendar" size="16" class="me-1" />
|
||||
{{ formatDateTime(ticket.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Replies -->
|
||||
<div class="d-flex flex-column ga-4 mb-6">
|
||||
<VCard
|
||||
v-for="reply in ticket.replies"
|
||||
:key="reply.id"
|
||||
:class="reply.is_staff_reply ? 'border-s-4 border-primary' : ''"
|
||||
:variant="reply.is_staff_reply ? 'tonal' : 'elevated'"
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center ga-3 mb-3">
|
||||
<VAvatar
|
||||
:color="reply.is_staff_reply ? 'primary' : 'secondary'"
|
||||
variant="tonal"
|
||||
size="36"
|
||||
>
|
||||
<span class="text-body-2 font-weight-semibold">
|
||||
{{ getUserInitial(reply.user?.name ?? 'U') }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ reply.user?.name ?? 'Unknown' }}
|
||||
<VChip
|
||||
v-if="reply.is_staff_reply"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="ms-2"
|
||||
>
|
||||
Staff
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ formatDateTime(reply.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-2" style="white-space: pre-wrap;">{{ reply.body }}</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-message" size="22" />
|
||||
<span>Staff Reply</span>
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitReply">
|
||||
<AppTextarea
|
||||
v-model="replyForm.body"
|
||||
placeholder="Type your reply to the customer..."
|
||||
rows="5"
|
||||
:error-messages="replyForm.errors.body"
|
||||
counter="5000"
|
||||
maxlength="5000"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center justify-space-between flex-wrap ga-3">
|
||||
<div style="min-width: 200px;">
|
||||
<AppSelect
|
||||
v-model="replyForm.status"
|
||||
:items="statusOptions"
|
||||
label="Update Status"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="replyForm.processing"
|
||||
:disabled="replyForm.processing || !replyForm.body.trim()"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Send Reply
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Customer Info -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-user" size="22" />
|
||||
<span>Customer</span>
|
||||
</VCardTitle>
|
||||
<VCardText v-if="ticket.user">
|
||||
<div class="d-flex align-center gap-3 mb-4">
|
||||
<VAvatar color="primary" variant="tonal" size="40">
|
||||
<span class="text-body-1 font-weight-semibold">
|
||||
{{ getUserInitial(ticket.user.name) }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ ticket.user.name }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ ticket.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem v-if="ticket.user.status">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 80px;">Status</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-capitalize">
|
||||
{{ ticket.user.status }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="ticket.user.company">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 80px;">Company</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ ticket.user.company }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
|
||||
<div class="mt-3">
|
||||
<Link :href="`/customers/${ticket.user.id}`">
|
||||
<VBtn variant="tonal" size="small" color="primary" block>
|
||||
View Customer
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Ticket Details -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-info-circle" size="22" />
|
||||
<span>Ticket Details</span>
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Status</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<VChip
|
||||
:color="resolveTicketStatusColor(ticket.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ formatStatus(ticket.status) }}
|
||||
</VChip>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Priority</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<VChip
|
||||
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ ticket.priority }}
|
||||
</VChip>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Department</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-capitalize">
|
||||
{{ ticket.department }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Created</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatDateTime(ticket.created_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="ticket.last_reply_at">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Last Reply</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatDateTime(ticket.last_reply_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Quick Status Update -->
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-toggle-left" size="22" />
|
||||
<span>Update Status</span>
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<VBtn
|
||||
v-for="option in quickStatusOptions"
|
||||
:key="option.value"
|
||||
:color="option.color"
|
||||
variant="tonal"
|
||||
block
|
||||
:disabled="ticket.status === option.value"
|
||||
@click="openStatusDialog(option.value)"
|
||||
>
|
||||
{{ option.title }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Status Confirmation Dialog -->
|
||||
<VDialog v-model="statusDialog" max-width="500" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 pa-5">
|
||||
Update Ticket Status
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5 pb-2">
|
||||
Are you sure you want to change the ticket status to <strong class="text-capitalize">{{ formatStatus(pendingStatus) }}</strong>?
|
||||
</VCardText>
|
||||
<VCardActions class="pa-5">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" :disabled="statusForm.processing" @click="statusDialog = false">
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="statusForm.processing"
|
||||
@click="confirmStatusUpdate"
|
||||
>
|
||||
Confirm
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
latestInvoices: Invoice[]
|
||||
pendingInvoicesAmount: string
|
||||
nextRenewalDate: string | null
|
||||
openTicketsCount: number
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
@@ -378,10 +379,8 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="https://ezscale.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
href="/tickets/create"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
@@ -392,14 +391,16 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
icon="tabler-headset"
|
||||
start
|
||||
/>
|
||||
Get Support
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
end
|
||||
size="14"
|
||||
Open Support Ticket
|
||||
<VBadge
|
||||
v-if="openTicketsCount > 0"
|
||||
:content="openTicketsCount"
|
||||
color="error"
|
||||
inline
|
||||
class="ms-1"
|
||||
/>
|
||||
</VBtn>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||
import type { Subscription, Plan } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -14,89 +16,182 @@ defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cancelImmediately = ref(false)
|
||||
const showCancelDialog = ref<boolean>(false)
|
||||
const cancelImmediately = ref<boolean>(false)
|
||||
const cancelReason = ref<string>('')
|
||||
|
||||
const cancelReasons = [
|
||||
'Too expensive',
|
||||
'No longer needed',
|
||||
'Switching to competitor',
|
||||
'Poor performance',
|
||||
'Missing features',
|
||||
'Technical issues',
|
||||
'Other',
|
||||
]
|
||||
|
||||
const cancelForm = useForm({
|
||||
immediately: false,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const swapForm = useForm({
|
||||
plan_id: '',
|
||||
})
|
||||
|
||||
const cancelSubscription = (): void => {
|
||||
cancelForm.immediately = cancelImmediately.value
|
||||
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`)
|
||||
function openCancelDialog(): void {
|
||||
showCancelDialog.value = true
|
||||
}
|
||||
|
||||
const resumeSubscription = (): void => {
|
||||
function confirmCancel(): void {
|
||||
cancelForm.immediately = cancelImmediately.value
|
||||
cancelForm.reason = cancelReason.value
|
||||
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`, {
|
||||
onSuccess: () => {
|
||||
showCancelDialog.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resumeSubscription(): void {
|
||||
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`)
|
||||
}
|
||||
|
||||
const swapPlan = (): void => {
|
||||
function swapPlan(): void {
|
||||
swapForm.post(`/subscriptions/${props.subscription.id}/swap`)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const currentPlan = computed(() => props.subscription.plan)
|
||||
|
||||
const isActive = computed<boolean>(() => props.subscription.stripe_status === 'active')
|
||||
const isCancelling = computed<boolean>(() => !!props.subscription.ends_at && props.subscription.stripe_status !== 'canceled')
|
||||
const isCanceled = computed<boolean>(() => props.subscription.stripe_status === 'canceled')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none">← Back to Subscriptions</Link>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="d-flex align-center ga-2 mb-4">
|
||||
<Link href="/subscriptions" class="text-decoration-none">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-arrow-left" start />
|
||||
Subscriptions
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VIcon icon="tabler-chevron-right" size="16" color="disabled" />
|
||||
<span class="text-body-2 text-medium-emphasis">{{ currentPlan?.name || subscription.type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="text-h4 font-weight-bold mb-6">Subscription Details</div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h4 font-weight-bold">Subscription Details</div>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ subscription.stripe_status }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Subscription Info -->
|
||||
<VCol cols="12" lg="8">
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>
|
||||
{{ currentPlan?.name || subscription.type }}
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ subscription.plan?.name || subscription.type }}
|
||||
</div>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ subscription.stripe_status }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VCol cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Gateway</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.plan" cols="6">
|
||||
<VCol v-if="currentPlan" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Price</div>
|
||||
<div class="text-body-1 mt-1">{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}</div>
|
||||
<div class="text-body-1 font-weight-medium mt-1">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.current_period_start" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Current Period Start</div>
|
||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</div>
|
||||
<VCol v-if="currentPlan" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Billing Cycle</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">{{ currentPlan.billing_cycle }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.current_period_end" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Current Period End</div>
|
||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</div>
|
||||
<VCol v-if="subscription.current_period_start" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Period Start</div>
|
||||
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_start) }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.ends_at" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Cancels On</div>
|
||||
<div class="text-body-1 text-error mt-1">{{ new Date(subscription.ends_at).toLocaleDateString() }}</div>
|
||||
<VCol v-if="subscription.current_period_end" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Period End</div>
|
||||
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_end) }}</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VCol cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Created</div>
|
||||
<div class="text-body-1 mt-1">{{ new Date(subscription.created_at).toLocaleDateString() }}</div>
|
||||
<div class="text-body-1 mt-1">{{ formatDate(subscription.created_at) }}</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Cancellation Notice -->
|
||||
<VAlert
|
||||
v-if="isCancelling"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="font-weight-medium">Subscription cancelling</div>
|
||||
<div class="text-body-2">
|
||||
Your subscription will end on {{ formatDate(subscription.ends_at!) }}.
|
||||
You can resume it before then to keep your service.
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="isCanceled"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="font-weight-medium">Subscription cancelled</div>
|
||||
<div class="text-body-2">
|
||||
This subscription has been cancelled and is no longer active.
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Plan Features -->
|
||||
<VCard v-if="currentPlan?.features" class="mb-6">
|
||||
<VCardTitle>Plan Features</VCardTitle>
|
||||
<VCardText>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in currentPlan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-check" color="success" size="18" />
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="text-capitalize">{{ String(key).replace(/_/g, ' ') }}</span>:
|
||||
<span class="font-weight-medium">{{ value }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Change Plan -->
|
||||
<VCard v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'">
|
||||
<VCard v-if="availablePlans.length > 0 && isActive && !isCancelling">
|
||||
<VCardTitle>Change Plan</VCardTitle>
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Switch to a different plan. Your billing will be adjusted automatically.
|
||||
</p>
|
||||
<VForm @submit.prevent="swapPlan">
|
||||
<VRadioGroup v-model="swapForm.plan_id" class="mb-4">
|
||||
<VRadio
|
||||
@@ -105,9 +200,27 @@ const swapPlan = (): void => {
|
||||
:value="String(plan.id)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="d-flex justify-space-between w-100">
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<span>{{ plan.name }}</span>
|
||||
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
|
||||
<VChip
|
||||
v-if="currentPlan && parseFloat(plan.price) > parseFloat(currentPlan.price)"
|
||||
color="info"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
Upgrade
|
||||
</VChip>
|
||||
<VChip
|
||||
v-else-if="currentPlan"
|
||||
color="warning"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
Downgrade
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VRadio>
|
||||
@@ -115,10 +228,12 @@ const swapPlan = (): void => {
|
||||
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="swapForm.processing"
|
||||
:disabled="!swapForm.plan_id || swapForm.processing"
|
||||
prepend-icon="tabler-switch-horizontal"
|
||||
>
|
||||
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
|
||||
Change Plan
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -128,46 +243,137 @@ const swapPlan = (): void => {
|
||||
<!-- Actions Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Cancel -->
|
||||
<VCard v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="mb-6">
|
||||
<VCard v-if="isActive && !isCancelling" class="mb-6">
|
||||
<VCardTitle>Cancel Subscription</VCardTitle>
|
||||
<VCardText>
|
||||
<VCheckbox
|
||||
v-model="cancelImmediately"
|
||||
label="Cancel immediately (no grace period)"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
If you cancel, your service will remain active until the end of your current billing period.
|
||||
</p>
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
block
|
||||
:loading="cancelForm.processing"
|
||||
:disabled="cancelForm.processing"
|
||||
@click="cancelSubscription"
|
||||
prepend-icon="tabler-x"
|
||||
@click="openCancelDialog"
|
||||
>
|
||||
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
|
||||
Cancel Subscription
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Resume -->
|
||||
<VCard v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'">
|
||||
<VCard v-if="isCancelling" class="mb-6">
|
||||
<VCardTitle>Resume Subscription</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
Your subscription is set to cancel. You can resume it before it expires.
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
Your subscription is set to cancel on {{ formatDate(subscription.ends_at!) }}.
|
||||
Resume to keep your service running.
|
||||
</p>
|
||||
<VBtn
|
||||
color="success"
|
||||
block
|
||||
prepend-icon="tabler-player-play"
|
||||
@click="resumeSubscription"
|
||||
>
|
||||
Resume Subscription
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Quick Info -->
|
||||
<VCard>
|
||||
<VCardTitle>Quick Info</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 text-medium-emphasis">Status</span>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ subscription.stripe_status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<VDivider />
|
||||
<div v-if="currentPlan" class="d-flex justify-space-between">
|
||||
<span class="text-body-2 text-medium-emphasis">Plan</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ currentPlan.name }}</span>
|
||||
</div>
|
||||
<VDivider />
|
||||
<div v-if="currentPlan" class="d-flex justify-space-between">
|
||||
<span class="text-body-2 text-medium-emphasis">Price</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</span>
|
||||
</div>
|
||||
<VDivider />
|
||||
<div v-if="subscription.current_period_end" class="d-flex justify-space-between">
|
||||
<span class="text-body-2 text-medium-emphasis">Next Renewal</span>
|
||||
<span class="text-body-2">{{ formatDate(subscription.current_period_end) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Cancel Confirmation Dialog -->
|
||||
<VDialog v-model="showCancelDialog" max-width="500">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center ga-2">
|
||||
<VIcon icon="tabler-alert-triangle" color="error" />
|
||||
Cancel Subscription
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<p class="text-body-1 mb-4">
|
||||
Are you sure you want to cancel your subscription?
|
||||
</p>
|
||||
|
||||
<VAlert type="info" variant="tonal" class="mb-4">
|
||||
<div class="text-body-2">
|
||||
By default, your service will remain active until the end of your current billing period.
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<AppSelect
|
||||
v-model="cancelReason"
|
||||
:items="cancelReasons"
|
||||
label="Reason for cancelling (optional)"
|
||||
placeholder="Select a reason"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<VCheckbox
|
||||
v-model="cancelImmediately"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<div class="text-body-2 font-weight-medium">Cancel immediately</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Your service will be terminated right away with no refund.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
</VCardText>
|
||||
<VCardActions class="pa-4 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
@click="showCancelDialog = false"
|
||||
>
|
||||
Keep Subscription
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="cancelForm.processing"
|
||||
@click="confirmCancel"
|
||||
>
|
||||
Confirm Cancellation
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
114
website/resources/ts/Pages/Tickets/Create.vue
Normal file
114
website/resources/ts/Pages/Tickets/Create.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const form = useForm({
|
||||
subject: '',
|
||||
department: 'general',
|
||||
priority: 'medium',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const departmentOptions = [
|
||||
{ title: 'General', value: 'general' },
|
||||
{ title: 'Billing', value: 'billing' },
|
||||
{ title: 'Technical', value: 'technical' },
|
||||
{ title: 'Sales', value: 'sales' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ title: 'Low', value: 'low' },
|
||||
{ title: 'Medium', value: 'medium' },
|
||||
{ title: 'High', value: 'high' },
|
||||
{ title: 'Urgent', value: 'urgent' },
|
||||
]
|
||||
|
||||
function submitTicket(): void {
|
||||
form.post('/tickets', {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link href="/tickets" class="text-primary text-body-2 text-decoration-none">
|
||||
← Back to Tickets
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="text-h4 font-weight-bold mb-6">
|
||||
Create Support Ticket
|
||||
</div>
|
||||
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitTicket">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
v-model="form.subject"
|
||||
label="Subject"
|
||||
placeholder="Brief description of your issue"
|
||||
:error-messages="form.errors.subject"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<AppSelect
|
||||
v-model="form.department"
|
||||
label="Department"
|
||||
:items="departmentOptions"
|
||||
:error-messages="form.errors.department"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<AppSelect
|
||||
v-model="form.priority"
|
||||
label="Priority"
|
||||
:items="priorityOptions"
|
||||
:error-messages="form.errors.priority"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<AppTextarea
|
||||
v-model="form.message"
|
||||
label="Message"
|
||||
placeholder="Please describe your issue in detail (minimum 10 characters)..."
|
||||
rows="8"
|
||||
:error-messages="form.errors.message"
|
||||
counter="5000"
|
||||
maxlength="5000"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end ga-3">
|
||||
<Link href="/tickets" class="text-decoration-none">
|
||||
<VBtn variant="tonal" color="secondary">
|
||||
Cancel
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="form.processing"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Submit Ticket
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
127
website/resources/ts/Pages/Tickets/Index.vue
Normal file
127
website/resources/ts/Pages/Tickets/Index.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||
import type { PaginatedResponse, SupportTicket } from '@/types'
|
||||
|
||||
interface Props {
|
||||
tickets: PaginatedResponse<SupportTicket>
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Support Tickets
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
View and manage your support requests
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/tickets/create" class="text-decoration-none">
|
||||
<VBtn color="primary">
|
||||
<VIcon icon="tabler-plus" start />
|
||||
Create Ticket
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<VCard v-if="tickets.data.length === 0">
|
||||
<VCardText class="text-center py-12">
|
||||
<VIcon icon="tabler-ticket-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis mb-4">
|
||||
You don't have any support tickets yet.
|
||||
</div>
|
||||
<Link href="/tickets/create" class="text-decoration-none">
|
||||
<VBtn color="primary" variant="tonal">
|
||||
Create Your First Ticket
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard v-else>
|
||||
<VTable density="comfortable" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Department</th>
|
||||
<th>Last Updated</th>
|
||||
<th class="text-center">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
||||
<td class="text-body-2 font-weight-medium">
|
||||
{{ ticket.subject }}
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveTicketStatusColor(ticket.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ formatStatus(ticket.status) }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ ticket.priority }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td class="text-body-2 text-capitalize">
|
||||
{{ ticket.department }}
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ formatDate(ticket.updated_at) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Link :href="`/tickets/${ticket.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<VCardText v-if="tickets.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||
<VPagination
|
||||
:model-value="tickets.data.length > 0 ? Math.ceil((tickets.from ?? 1) / 15) : 1"
|
||||
:length="tickets.last_page"
|
||||
:total-visible="7"
|
||||
@update:model-value="(page: number) => router.get('/tickets', { page }, { preserveState: true, preserveScroll: true })"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="tickets.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||
Showing {{ tickets.from }} to {{ tickets.to }} of {{ tickets.total }} tickets
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
225
website/resources/ts/Pages/Tickets/Show.vue
Normal file
225
website/resources/ts/Pages/Tickets/Show.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script lang="ts" setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||
import type { SupportTicket } from '@/types'
|
||||
|
||||
interface Props {
|
||||
ticket: SupportTicket
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const closeDialog = ref<boolean>(false)
|
||||
|
||||
const replyForm = useForm({
|
||||
body: '',
|
||||
})
|
||||
|
||||
const closeForm = useForm({})
|
||||
|
||||
function submitReply(): void {
|
||||
replyForm.post(`/tickets/${props.ticket.id}/reply`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
replyForm.reset('body')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function closeTicket(): void {
|
||||
closeForm.post(`/tickets/${props.ticket.id}/close`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeDialog.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function getUserInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Back link -->
|
||||
<div class="mb-4">
|
||||
<Link href="/tickets" class="text-primary text-body-2 text-decoration-none">
|
||||
← Back to Tickets
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Header -->
|
||||
<VCard class="mb-6">
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between flex-wrap ga-4">
|
||||
<div>
|
||||
<div class="d-flex align-center ga-3 mb-2">
|
||||
<span class="text-h5 font-weight-bold">{{ ticket.subject }}</span>
|
||||
<VChip
|
||||
:color="resolveTicketStatusColor(ticket.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ formatStatus(ticket.status) }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-4 text-body-2 text-medium-emphasis">
|
||||
<span>
|
||||
<VIcon icon="tabler-tag" size="16" class="me-1" />
|
||||
<span class="text-capitalize">{{ ticket.priority }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<VIcon icon="tabler-building" size="16" class="me-1" />
|
||||
<span class="text-capitalize">{{ ticket.department }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<VIcon icon="tabler-calendar" size="16" class="me-1" />
|
||||
{{ formatDateTime(ticket.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-if="ticket.status !== 'closed'"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="closeDialog = true"
|
||||
>
|
||||
<VIcon icon="tabler-x" start />
|
||||
Close Ticket
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Conversation Thread -->
|
||||
<div class="d-flex flex-column ga-4 mb-6">
|
||||
<VCard
|
||||
v-for="reply in ticket.replies"
|
||||
:key="reply.id"
|
||||
:class="reply.is_staff_reply ? 'border-s-4 border-primary' : ''"
|
||||
:variant="reply.is_staff_reply ? 'tonal' : 'elevated'"
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center ga-3 mb-3">
|
||||
<VAvatar
|
||||
:color="reply.is_staff_reply ? 'primary' : 'secondary'"
|
||||
variant="tonal"
|
||||
size="36"
|
||||
>
|
||||
<span class="text-body-2 font-weight-semibold">
|
||||
{{ getUserInitial(reply.user?.name ?? 'U') }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ reply.user?.name ?? 'Unknown' }}
|
||||
<VChip
|
||||
v-if="reply.is_staff_reply"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="ms-2"
|
||||
>
|
||||
Staff
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ formatDateTime(reply.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-2" style="white-space: pre-wrap;">{{ reply.body }}</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
<VCard v-if="ticket.status !== 'closed'">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-message" size="22" />
|
||||
<span>Add Reply</span>
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitReply">
|
||||
<AppTextarea
|
||||
v-model="replyForm.body"
|
||||
placeholder="Type your reply..."
|
||||
rows="5"
|
||||
:error-messages="replyForm.errors.body"
|
||||
counter="5000"
|
||||
maxlength="5000"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="d-flex justify-end">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="replyForm.processing"
|
||||
:disabled="replyForm.processing || !replyForm.body.trim()"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Send Reply
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Closed Notice -->
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-2"
|
||||
>
|
||||
This ticket has been closed. If you need further assistance, please create a new ticket.
|
||||
</VAlert>
|
||||
|
||||
<!-- Close Confirmation Dialog -->
|
||||
<VDialog v-model="closeDialog" max-width="500" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 pa-5">
|
||||
Close Ticket
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5 pb-2">
|
||||
Are you sure you want to close this ticket? You will not be able to reply after closing.
|
||||
</VCardText>
|
||||
<VCardActions class="pa-5">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" :disabled="closeForm.processing" @click="closeDialog = false">
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="closeForm.processing"
|
||||
@click="closeTicket"
|
||||
>
|
||||
Close Ticket
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user