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:
Claude Dev
2026-02-09 16:20:12 -05:00
parent 9603803928
commit 6f39c32270
23 changed files with 2343 additions and 76 deletions

View 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>

View 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' }} &middot; {{ 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>

View File

@@ -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>

View File

@@ -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">&larr; 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>

View 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">
&larr; 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>

View 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>

View 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">
&larr; 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>