fix: tickets
Deploy banu-front / deploy (push) Failing after 16s

This commit is contained in:
sajjadtalkhabi
2026-06-05 19:42:09 +03:30
parent ea562aeefa
commit b8ccbcc030
24 changed files with 447 additions and 222 deletions
@@ -1,17 +1,20 @@
<template>
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
<BasicModal
title="جزئیات درخواست"
title-en="Request details"
width="95%"
max-width="58rem"
min-width="auto"
:show-close-button="true"
>
<template #default>
<div class="sdm">
<header class="sdm__header">
<LineTitleBlock title="جزئیات درخواست" title-en="Request details" />
</header>
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="2" :cols-per-row="1" />
<template v-else-if="service">
<div class="sdm__top">
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
<span class="sdm__date">{{ requestDate }}</span>
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
</div>
<div v-if="messages.length > 0" class="sdm__messages">
@@ -36,7 +39,6 @@ import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useStudentTicketQuery } from '@/services/query/student-tickets'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
@@ -83,15 +85,9 @@ const messageTime = (message) => {
padding-block: 0.25rem 0.5rem;
text-align: start;
&__header {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__top {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
@@ -118,7 +114,7 @@ const messageTime = (message) => {
position: relative;
background: #f6f6f6;
padding: 1rem 1rem 1.5rem;
border-radius: 0.75rem 0.75rem 0 0.75rem;
border-radius: 0.75rem 0.75rem 0;
color: #454545;
}
@@ -1,25 +1,29 @@
<template>
<div class="status-tile">
<div class="status-tile__icon" :style="{ color: iconColor }">
<SvgIcon :name="icon" :size="32" />
</div>
<button
type="button"
class="status-tile"
:class="{ 'status-tile--active': active }"
:aria-pressed="active"
@click="emit('click')"
>
<img :src="image" :alt="label" class="status-tile__image" />
<p class="status-tile__label">{{ label }}</p>
<p class="status-tile__count" :style="{ color: countColor }">{{ count }}</p>
</div>
</button>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
icon: { type: String, required: true },
/** Resolved PNG / image src (use a build-time import, e.g. `import img from '@/assets/...'`). */
image: { type: String, required: true },
label: { type: String, required: true },
count: { type: [Number, String], default: 0 },
/** Hex color for the count number. Defaults to a neutral gray. */
countColor: { type: String, default: '#4C4C4C' },
/** Hex color for the icon. Defaults to a neutral gray. */
iconColor: { type: String, default: '#A7A7A7' },
active: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
</script>
<style lang="scss" scoped>
@@ -34,14 +38,34 @@ defineProps({
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.4px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
border: 1px solid transparent;
min-height: 7rem;
cursor: pointer;
font: inherit;
text-align: center;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(255, 255, 255, 70%);
}
&:focus-visible {
outline: none;
border-color: var(--color-primary);
}
&--active {
background: rgba(255, 255, 255, 90%);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(243, 102, 117, 12%);
}
&__image {
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
flex-shrink: 0;
}
&__label {
@@ -51,7 +75,6 @@ defineProps({
line-height: 1.25;
color: #4c4c4c;
margin: 0;
text-align: center;
}
&__count {
@@ -60,7 +83,6 @@ defineProps({
font-size: 1rem;
line-height: 1.75;
margin: 0;
text-align: center;
}
}
</style>
@@ -17,7 +17,14 @@
</div>
<div class="ticket-item__meta">
<Badge variant="neutral" size="sm" label="تاریخ پیام :" :value="createdAt" />
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ ثبت :" :value="createdAt" />
<Badge
v-if="priorityLabel"
:variant="priorityVariant"
size="sm"
label="اولویت :"
:value="priorityLabel"
/>
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
</div>
@@ -37,11 +44,11 @@
<script setup>
import { computed } from 'vue'
import { TICKET_STATUS } from '@/enums'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import { TICKET_PRIORITY, TICKET_STATUS } from '@/enums'
const STATUS_VARIANT = {
open: 'warning',
@@ -49,6 +56,13 @@ const STATUS_VARIANT = {
closed: 'info',
}
const PRIORITY_VARIANT = {
low: 'neutral',
normal: 'info',
high: 'warning',
urgent: 'danger',
}
const props = defineProps({
ticket: { type: Object, required: true },
})
@@ -60,6 +74,9 @@ const userName = computed(() => props.ticket.student?.name || props.ticket.subje
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
const statusVariant = computed(() => STATUS_VARIANT[props.ticket.status] || 'neutral')
const priorityLabel = computed(() => TICKET_PRIORITY[props.ticket.priority] || '')
const priorityVariant = computed(() => PRIORITY_VARIANT[props.ticket.priority] || 'neutral')
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
</script>
@@ -1,11 +1,14 @@
<template>
<BasicModal width="95%" max-width="58rem" min-width="auto" :show-close-button="true">
<BasicModal
title="ارسال تیکت جدید"
title-en="New ticket"
width="95%"
max-width="58rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="ctm" @submit.prevent="onSubmit(close)">
<header class="ctm__header">
<LineTitleBlock title="ارسال تیکت جدید" title-en="New ticket" />
</header>
<div class="ctm__row ctm__row--two">
<TextField
v-model="form.subject"
@@ -76,6 +79,7 @@
import { ref } from 'vue'
import * as yup from 'yup'
import { toast } from 'vue3-toastify'
import { TICKET_PRIORITY } from '@/enums'
import useYup from '@/composables/useYup'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
@@ -83,7 +87,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useQueryClient } from '@tanstack/vue-query'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
@@ -97,16 +100,14 @@ defineOptions({ name: 'CreateTicketModal' })
const queryClient = useQueryClient()
const priorityOptions = [
{ value: 'very_urgent', label: 'بسیار فوری' },
{ value: 'urgent', label: 'فوری' },
{ value: 'normal', label: 'عادی' },
{ value: 'low', label: 'کم اهمیت' },
]
const priorityOptions = Object.entries(TICKET_PRIORITY).map(([value, label]) => ({
value,
label,
}))
const form = ref({
subject: '',
priority: 'very_urgent',
priority: 'normal',
message: '',
})
@@ -159,9 +160,7 @@ const onSubmit = async (close) => {
try {
await createMutation.mutateAsync({
type: 'ticket',
// Backend POST /student/tickets accepts `category` — we use it to carry
// the FE-only priority value until the backend adds a dedicated field.
category: payload.priority,
priority: payload.priority,
subject: payload.subject,
message: payload.message,
mediaIds: files.value.map((f) => f.id).filter((id) => id != null),
@@ -169,7 +168,7 @@ const onSubmit = async (close) => {
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
toast.success('تیکت با موفقیت ثبت شد.')
resetErrors()
form.value = { subject: '', priority: 'very_urgent', message: '' }
form.value = { subject: '', priority: 'normal', message: '' }
files.value = []
close?.()
} catch {
@@ -186,12 +185,6 @@ const onSubmit = async (close) => {
text-align: start;
padding-block: 0.25rem 0.5rem;
&__header {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__row {
display: grid;
grid-template-columns: 1fr;
@@ -8,48 +8,68 @@
:show-close-button="true"
>
<template #default>
<div class="tdm">
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="2" :cols-per-row="1" />
<div class="ticket-details">
<div v-if="ticket" class="ticket-details__date">
<span>{{ ticketDate }}</span>
</div>
<template v-else-if="ticket">
<div class="tdm__top">
<Badge :variant="statusVariant" size="sm" dot :value="statusLabel" />
<span class="tdm__date">{{ requestDate }}</span>
</div>
<div v-if="messages.length > 0" class="tdm__messages">
<div v-for="message in messages" :key="message.id" class="tdm__bubble">
<p class="tdm__text">{{ message.message }}</p>
<span class="tdm__time">{{ messageTime(message) }}</span>
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
<div v-else-if="messages.length > 0" ref="thread" class="ticket-details__thread">
<div
v-for="message in messages"
:key="message.id"
class="ticket-details__row"
:class="`ticket-details__row--${senderClass(message)}`"
>
<div class="ticket-details__bubble">
<p class="ticket-details__text">{{ message.message }}</p>
<span class="ticket-details__time">{{ messageTime(message) }}</span>
</div>
</div>
</div>
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این تیکت ثبت نشده است." />
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی برای این تیکت ثبت نشده است." />
</template>
<div class="ticket-details__divider" />
<form class="ticket-details__compose" @submit.prevent="onSend">
<button
type="submit"
:disabled="!canSend"
class="ticket-details__send"
aria-label="ارسال"
>
<SvgIcon name="paper-plane-right" :size="22" color="var(--color-primary)" />
</button>
<input
v-model="text"
type="text"
placeholder="ارسال پیام"
class="ticket-details__input"
/>
</form>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import { TICKET_STATUS } from '@/enums'
import Badge from '@/components/Badge.vue'
import useModal from '@/composables/useModal'
import { computed, nextTick, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import { useStudentTicketQuery } from '@/services/query/student-tickets'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import {
studentTicketsKeys,
useSendStudentTicketMessageMutation,
useStudentTicketQuery,
} from '@/services/query/student-tickets'
defineOptions({ name: 'TicketDetailsModal' })
const STATUS_VARIANT = {
open: 'warning',
answered: 'success',
closed: 'info',
}
const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
@@ -61,84 +81,184 @@ const { data: ticket, isLoading } = useStudentTicketQuery(ticketId, {
const messages = computed(() => ticket.value?.messages ?? [])
const statusLabel = computed(() => TICKET_STATUS[ticket.value?.status] || '—')
const statusVariant = computed(() => STATUS_VARIANT[ticket.value?.status] || 'neutral')
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
const requestDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
// Student viewing their own ticket — their own messages get the highlighted
// (primary-color) `self` bubble; admin/counselor replies get the muted `other`
// bubble. The viewer's id matches the ticket's studentId here.
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'self' : 'other')
const messageTime = (message) => {
if (!message.createdAt) return ''
if (!message.createdAt) return ''
const d = new Date(message.createdAt)
if (Number.isNaN(d.getTime())) return ''
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
}
const text = ref('')
const canSend = computed(() => text.value.trim().length > 0)
const sendMutation = useSendStudentTicketMessageMutation()
const onSend = async () => {
if (!canSend.value || !ticketId.value) return
const value = text.value.trim()
text.value = ''
// Backend POST /student/tickets/:id/messages — body is { message: string }.
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
await queryClient.invalidateQueries({ queryKey: studentTicketsKeys.all })
}
const thread = ref(null)
watch(messages, async () => {
await nextTick()
if (thread.value) {
thread.value.scrollTop = thread.value.scrollHeight
}
})
</script>
<style lang="scss" scoped>
.tdm {
.ticket-details {
width: 100%;
text-align: start;
display: flex;
flex-direction: column;
gap: 1rem;
padding-block: 0.25rem 0.5rem;
text-align: start;
&__header {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
max-height: calc(100dvh - 8rem);
min-height: 28rem;
&__date {
background: #f5f5f5;
color: #a3a3a3;
border-radius: 9999px;
padding: 0.3rem 0.875rem;
font-family: var(--font-family-fa);
font-size: 0.7rem;
line-height: 1.45;
white-space: nowrap;
display: flex;
justify-content: center;
margin: 0.5rem 0 0.875rem;
}
&__messages {
&__date span {
background: #f7f7f7;
color: #9c9c9c;
border-radius: 9999px;
padding: 0.5rem 1.25rem;
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__thread {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-block: 0.5rem 1rem;
padding-inline-end: 0.25rem;
}
&__row {
display: flex;
// `self` (the viewer / student) sits on the visual end (left in RTL) with
// the highlighted primary bubble. `other` sits at the start (right in
// RTL) with the muted gray bubble.
&--self {
justify-content: flex-end;
}
&--other {
justify-content: flex-start;
}
}
&__bubble {
position: relative;
background: #f6f6f6;
padding: 1rem 1rem 1.5rem;
border-radius: 0.75rem 0.75rem 0;
color: #454545;
max-width: 72%;
border-radius: 1rem;
padding: 0.75rem 1.25rem;
.ticket-details__row--self & {
background: var(--color-primary);
color: #fff;
border-end-end-radius: 0;
}
.ticket-details__row--other & {
background: #f8f8f8;
color: #5d5d5d;
border-end-start-radius: 0;
}
}
&__text {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.8rem;
font-size: 0.875rem;
line-height: 1.7;
margin: 0;
text-align: justify;
white-space: pre-wrap;
}
&__time {
position: absolute;
inset-inline-end: 1rem;
inset-block-end: 0.5rem;
display: block;
margin-top: 0.375rem;
font-family: var(--font-family-en);
font-size: 0.7rem;
.ticket-details__row--self & {
color: rgba(255, 255, 255, 80%);
}
.ticket-details__row--other & {
color: #b1b1b1;
}
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 0.75rem;
}
&__compose {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
&__send {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
&:hover:enabled {
background: var(--color-primary);
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__input {
flex: 1;
height: 2.5rem;
border: 1px solid transparent;
border-radius: 1.5rem;
padding: 0 1rem;
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #a7a7a7;
line-height: 1.45;
font-size: 0.875rem;
color: #4b4b4b;
outline: none;
&:focus {
border-color: var(--color-thd-gray);
}
}
}
</style>
@@ -15,11 +15,12 @@
<StatusTile
v-for="tile in statusTiles"
:key="tile.key"
:icon="tile.icon"
:image="tile.image"
:label="tile.label"
:count="tile.count"
:count-color="tile.countColor"
:icon-color="tile.iconColor"
:active="activeTile === tile.key"
@click="onTileSelect(tile.key)"
/>
</div>
</div>
@@ -66,9 +67,12 @@ import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import chatsPng from '@/assets/images/tickets/chats.png'
import { usePagination } from '@/composables/usePagination'
import chatTextPng from '@/assets/images/tickets/chat-text.png'
import chatDotsPng from '@/assets/images/tickets/chat-dots.png'
import chatSlashPng from '@/assets/images/tickets/chat-slash.png'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useStudentTicketsQuery } from '@/services/query/student-tickets'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import StatusTile from '@/features/student/tickets/components/StatusTile.vue'
@@ -76,19 +80,35 @@ import TicketItem from '@/features/student/tickets/components/TicketItem.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CreateTicketModal from '@/features/student/tickets/components/modals/CreateTicketModal.vue'
import TicketDetailsModal from '@/features/student/tickets/components/modals/TicketDetailsModal.vue'
import {
useStudentTicketsQuery,
useStudentTicketStatsQuery,
} from '@/services/query/student-tickets'
const { openModal, isModal } = useModal()
const filters = ref({ type: 'ticket' })
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const TILE_STATUS = {
all: '',
in_review: 'open',
answered: 'answered',
closed: 'closed',
}
const activeTile = ref('all')
const filters = ref({ type: 'ticket', status: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const onTileSelect = (key) => {
activeTile.value = key
filters.value = { type: 'ticket', status: TILE_STATUS[key] || '' }
resetPagination()
}
const { data, isLoading } = useStudentTicketsQuery(filters, pagination, {
keepPreviousData: true,
})
// The query select normalizes to { data: Array, meta }. The page also tolerates
// a backend that nests under `data.items` (Laravel-style pagination) until the
// backend shape is locked in.
const tickets = computed(() => {
const raw = data.value?.data
if (Array.isArray(raw)) return raw
@@ -102,52 +122,37 @@ const paginationMeta = computed(() => ({
...data.value?.meta,
}))
const statusTiles = computed(() => {
const list = tickets.value
const countBy = (predicate) => list.filter((_el) => predicate(_el)).length
return [
{
key: 'all',
icon: 'list-bullets',
label: 'همه تیکت‌ها',
count: list.length,
iconColor: '#A7A7A7',
countColor: '#4C4C4C',
},
{
key: 'open',
icon: 'chat',
label: 'باز',
count: countBy((t) => t.status === 'open'),
iconColor: '#CC6F00',
countColor: '#CC6F00',
},
{
key: 'in_review',
icon: 'chat-centered-dots',
label: 'در حال بررسی',
count: countBy((t) => t.status === 'answered' && t.assignee),
iconColor: '#007074',
countColor: '#007074',
},
{
key: 'answered',
icon: 'check-square',
label: 'پاسخ داده شده',
count: countBy((t) => t.status === 'answered'),
iconColor: '#33BE65',
countColor: '#33BE65',
},
{
key: 'closed',
icon: 'close',
label: 'بسته شده',
count: countBy((t) => t.status === 'closed'),
iconColor: '#CC2831',
countColor: '#CC2831',
},
]
})
const { data: stats } = useStudentTicketStatsQuery({ type: 'ticket' })
const statusTiles = computed(() => [
{
key: 'all',
image: chatTextPng,
label: 'همه تیکت‌ها',
count: stats.value?.all ?? 0,
countColor: '#B6842D',
},
{
key: 'in_review',
image: chatDotsPng,
label: 'در حال بررسی',
count: stats.value?.openTickets ?? 0,
countColor: '#4C4C4C',
},
{
key: 'answered',
image: chatsPng,
label: 'پاسخ داده شده',
count: stats.value?.answeredTickets ?? 0,
countColor: '#662BB9',
},
{
key: 'closed',
image: chatSlashPng,
label: 'بسته شده',
count: stats.value?.closedTickets ?? 0,
countColor: '#CC2831',
},
])
const onShowDetails = (ticket) => {
openModal('TicketDetailsModal', { id: ticket.id })