@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user