Files
website/website/resources/ts/Pages/Tickets/Show.vue
Claude Dev 6f39c32270 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>
2026-02-09 16:20:12 -05:00

226 lines
6.4 KiB
Vue

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