fix: ticket

This commit is contained in:
sajjadtalkhabi
2026-06-05 18:43:02 +03:30
parent 1c665830b0
commit ea562aeefa
53 changed files with 2475 additions and 859 deletions
@@ -17,25 +17,16 @@
</div>
<div class="service-item__meta">
<div class="service-item__pill">
<SvgIcon name="file" :size="14" color="#535353" />
<span class="service-item__pill-label">نوع خدمت :</span>
<span class="service-item__pill-value">{{ typeLabel }}</span>
</div>
<div class="service-item__pill">
<SvgIcon name="calendar" :size="14" color="#535353" />
<span class="service-item__pill-label">تاریخ پیام :</span>
<span class="service-item__pill-value">{{ createdAt }}</span>
</div>
<button
type="button"
class="service-item__status"
:class="`service-item__status--${service.status || 'pending'}`"
<Badge variant="neutral" size="sm" icon="file" label="نوع خدمت :" :value="typeLabel" />
<Badge variant="neutral" size="sm" icon="calendar" label="تاریخ پیام :" :value="createdAt" />
<Badge
:variant="statusVariant"
size="sm"
dot
clickable
:value="statusLabel"
@click="onStatusClick"
>
<span class="service-item__status-dot" />
<span>{{ statusLabel }}</span>
</button>
/>
</div>
<div class="service-item__actions">
@@ -61,12 +52,23 @@
<script setup>
import { computed, ref } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
import { SERVICE_TYPE, TICKET_STATUS } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils'
import DropdownMenu from '@/components/DropdownMenu.vue'
const STATUS_VARIANT = {
open: 'warning',
answered: 'primary',
closed: 'info',
approved: 'success',
pending: 'warning',
in_progress: 'primary',
completed: 'info',
}
const props = defineProps({
service: { type: Object, required: true },
})
@@ -82,9 +84,11 @@ const userName = computed(() => {
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
const statusLabel = computed(
() => props.service.statusLabel || SERVICE_STATUS[props.service.status] || '—'
() => props.service.statusLabel || TICKET_STATUS[props.service.status] || '—'
)
const statusVariant = computed(() => STATUS_VARIANT[props.service.status] || 'neutral')
const createdAt = computed(() => {
if (props.service.faCreatedAt) {
return props.service.faCreatedTime
@@ -108,7 +112,7 @@ const onStatusClick = (event) => {
}
const menuItems = computed(() =>
Object.entries(SERVICE_STATUS)
Object.entries(TICKET_STATUS)
.filter(([value]) => value !== props.service.status)
.map(([value, label]) => ({
key: `status-${value}`,
@@ -204,77 +208,6 @@ const menuItems = computed(() =>
flex: 1 1 45%;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(107, 107, 107, 5%);
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.7rem;
font-weight: 500;
color: #535353;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.875rem;
border: none;
font-family: var(--font-family-fa);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: filter 0.15s ease;
&:hover {
filter: brightness(0.96);
}
&--approved {
background: rgba(0, 153, 76, 10%);
color: #00994c;
}
&--pending {
background: rgba(204, 154, 40, 10%);
color: #cc6f00;
}
&--in_progress {
background: rgba(0, 112, 116, 10%);
color: #007074;
}
&--completed {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
}
&__status-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 9999px;
background: currentcolor;
}
&__actions {
display: flex;
justify-content: flex-end;
@@ -46,7 +46,7 @@
</template>
<script setup>
import { SERVICE_STATUS } from '@/enums'
import { TICKET_STATUS } from '@/enums'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
@@ -76,7 +76,7 @@ watch(
const todayIso = new Date().toISOString()
const statusOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
const statusOptions = Object.entries(TICKET_STATUS).map(([value, label]) => ({
value,
label,
}))
@@ -52,6 +52,7 @@
text="ارسال"
custom-class="send-message__submit"
:disabled="!canSubmit"
:loading="sendMutation.isPending.value"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
@@ -66,19 +67,34 @@
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { SERVICE_STATUS } from '@/enums'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import {
adminServicesKeys,
useSendAdminServiceMessageMutation,
} from '@/services/query/admin-services'
defineOptions({ name: 'SendMessageModal' })
const categoryOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
value,
label,
}))
const { getModal } = useModal()
const queryClient = useQueryClient()
const modalData = computed(() => getModal('SendMessageModal')?.data ?? {})
const serviceId = computed(() => modalData.value.id ?? null)
// FE-only — the message endpoint doesn't accept category. Keeping the dropdown
// visible per the Figma until the backend supports it.
const categoryOptions = [
{ value: 'general', label: 'عمومی' },
{ value: 'financial', label: 'مالی' },
{ value: 'technical', label: 'فنی' },
{ value: 'other', label: 'سایر' },
]
const priorityOptions = [
{ value: 'very_urgent', label: 'بسیار فوری' },
@@ -97,8 +113,18 @@ const canSubmit = computed(
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
)
const onSubmit = (close) => {
if (!canSubmit.value) return
const sendMutation = useSendAdminServiceMessageMutation()
const onSubmit = async (close) => {
if (!canSubmit.value || !serviceId.value) return
// Backend POST /admin/tickets/:id/messages accepts only { message }.
// category + priority are collected in the form but not yet supported
// server-side (see backend-vs-ui-gaps).
await sendMutation.mutateAsync({
id: serviceId.value,
payload: { message: form.value.description.trim() },
})
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
toast.success('پیام با موفقیت ارسال شد')
close?.()
}
@@ -7,55 +7,81 @@
min-width="auto"
:show-close-button="true"
>
<template #default="{ data }">
<template #default>
<div class="service-details">
<div class="service-details__row service-details__row--two">
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle(data)" />
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel(data)" />
</div>
<SkeletonLoaderBlock v-if="isLoading && !service" :rows="3" :cols-per-row="1" />
<template v-else>
<div class="service-details__row service-details__row--two">
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle" />
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel" />
</div>
<div class="service-details__row">
<LineInfoBlock title="درخواست" :desc="requestText(data)" />
</div>
<div class="service-details__row">
<LineInfoBlock title="درخواست" :desc="requestText" />
</div>
<div class="service-details__divider" />
<div class="service-details__divider" />
<footer class="service-details__footer">
<BaseButton
text="ارسال پیام"
custom-class="service-details__send-btn"
@click="onSendMessage(data)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
<footer class="service-details__footer">
<BaseButton
text="ارسال پیام"
custom-class="service-details__send-btn"
@click="onSendMessage"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</template>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import { SERVICE_TYPE } from '@/enums'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { useAdminServiceQuery } from '@/services/query/admin-services'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
defineOptions({ name: 'ServiceDetailsModal' })
const { openModal } = useModal()
const { openModal, getModal } = useModal()
const serviceTitle = (data) => data?.title || data?.serviceTitle || '—'
const modalData = computed(() => getModal('ServiceDetailsModal')?.data ?? {})
const serviceId = computed(() => modalData.value.id ?? null)
const serviceTypeLabel = (data) => data?.typeLabel || SERVICE_TYPE[data?.type] || '—'
const { data: service, isLoading } = useAdminServiceQuery(serviceId, {
enabled: () => !!serviceId.value,
})
const requestText = (data) => data?.requestText || data?.description || '—'
// Subject is the canonical title on the new schema; older fixtures used `title`.
const serviceTitle = computed(
() => service.value?.subject || service.value?.title || service.value?.serviceTitle || '—'
)
const onSendMessage = (data) => {
openModal('SendMessageModal', { service: data })
const serviceTypeLabel = computed(
() => service.value?.typeLabel || SERVICE_TYPE[service.value?.type] || '—'
)
// Pre-migration fixtures used `requestText`/`description`; the new schema puts
// the body inside the first message.
const requestText = computed(
() =>
service.value?.requestText ||
service.value?.description ||
service.value?.messages?.[0]?.message ||
'—'
)
const onSendMessage = () => {
openModal('SendMessageModal', { id: serviceId.value })
}
</script>
@@ -18,7 +18,8 @@
</template>
</SimpleTitleIconBlock>
<div v-if="services.length > 0">
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="services.length > 0">
<ServiceItem
v-for="service in services"
:key="service.id"
@@ -29,6 +30,8 @@
</div>
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
<SendMessageModal v-if="isModal('SendMessageModal')" />
</div>
@@ -37,56 +40,73 @@
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
import {
adminServicesKeys,
useAdminServicesListQuery,
useChangeAdminServiceStatusMutation,
} from '@/services/query/admin-services'
const { openModal, isModal } = useModal()
const queryClient = useQueryClient()
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
const filters = ref({ type: 'service', userName: '', status: '', fromDate: '', toDate: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const allServices = ref([
{
id: 1,
requestId: '387',
user: { firstName: 'علیرضا', lastName: 'محمدی' },
title: 'گفتگو درباره وام',
type: 'loan',
status: 'approved',
requestText:
'من علاقه‌مند به دنیای تکنولوژی، طراحی رابط کاربری و توسعه نرم‌افزار هستم. در کنار کار حرفه‌ای، به یادگیری موضوعات جدید، مطالعه و موسیقی هم علاقه دارم. هدفم ساختن محصولاتی است.',
faCreatedAt: '1404/05/12',
faCreatedTime: '13:54',
},
])
const services = computed(() => {
const f = filters.value
return allServices.value.filter((s) => {
if (f.status && s.status !== f.status) return false
if (f.userName) {
const fullName = `${s.user?.firstName || ''} ${s.user?.lastName || ''}`.trim()
if (!fullName.includes(f.userName)) return false
}
return true
})
const { data, isLoading } = useAdminServicesListQuery(filters, pagination, {
keepPreviousData: true,
})
const onFilterApply = () => {}
const onFilterReset = () => {}
// Backend payload doesn't include the UI's legacy fields (requestId, faCreatedAt,
// user.firstName/lastName, typeLabel/statusLabel). Bridge them here so ServiceItem
// and the modals can stay untouched.
const services = computed(() =>
(data.value?.data?.items ?? []).map((s) => ({
...s,
requestId: s.requestId ?? s.id,
user:
s.user ??
(s.student
? {
id: s.student.id,
firstName: s.student.name?.split(' ')[0] || '',
lastName: s.student.name?.split(' ').slice(1).join(' ') || '',
fullName: s.student.name,
avatarUrl: s.student.avatarUrl,
}
: null),
}))
)
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onFilterApply = () => resetPagination()
const onFilterReset = () => resetPagination()
const onShowDetails = (service) => {
openModal('ServiceDetailsModal', service)
openModal('ServiceDetailsModal', { id: service.id })
}
const onChangeStatus = ({ service, status }) => {
const idx = allServices.value.findIndex((s) => s.id === service.id)
if (idx !== -1) allServices.value[idx] = { ...allServices.value[idx], status }
const changeStatusMutation = useChangeAdminServiceStatusMutation()
const onChangeStatus = async ({ service, status }) => {
await changeStatusMutation.mutateAsync({ id: service.id, payload: { status } })
await queryClient.invalidateQueries({ queryKey: adminServicesKeys.all })
}
</script>