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>
226 lines
6.4 KiB
Vue
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">
|
|
← 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>
|