first commit

This commit is contained in:
sajjadtalkhabi
2026-05-13 01:43:05 +03:30
commit d2a577f254
297 changed files with 36274 additions and 0 deletions
@@ -0,0 +1,296 @@
<template>
<div class="service-item">
<div class="service-item__user">
<div v-if="service.user?.avatarUrl" class="service-item__avatar">
<img :src="service.user.avatarUrl" :alt="userName" />
</div>
<div v-else class="service-item__avatar service-item__avatar--placeholder">
<SvgIcon name="user" :size="24" color="#bcbcbc" />
</div>
<div class="service-item__info">
<p class="service-item__name">{{ userName }}</p>
<p class="service-item__request-id">
<span class="service-item__request-id-label">شماره درخواست:</span>
<span class="service-item__request-id-value">{{ service.requestId || '—' }}</span>
</p>
</div>
</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'}`"
@click="onStatusClick"
>
<span class="service-item__status-dot" />
<span>{{ statusLabel }}</span>
</button>
</div>
<div class="service-item__actions">
<BaseButton
text="جزئیات درخواست"
custom-class="service-item__details-btn"
@click="emit('show-details', service)"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
<DropdownMenu
v-model="menuOpen"
:reference="menuTrigger"
:items="menuItems"
placement="bottom-end"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS, SERVICE_TYPE } from '@/enums'
import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({
service: { type: Object, required: true },
})
const emit = defineEmits(['show-details', 'change-status'])
const userName = computed(() => {
const u = props.service.user
if (!u) return '—'
return `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.fullName || '—'
})
const typeLabel = computed(() => props.service.typeLabel || SERVICE_TYPE[props.service.type] || '—')
const statusLabel = computed(
() => props.service.statusLabel || SERVICE_STATUS[props.service.status] || '—'
)
const createdAt = computed(() => {
if (props.service.faCreatedAt) {
return props.service.faCreatedTime
? `${props.service.faCreatedTime}، ${props.service.faCreatedAt}`
: props.service.faCreatedAt
}
return formatJalaaliDate(props.service.createdAt) || '—'
})
const menuOpen = ref(false)
const menuTrigger = ref(null)
const onStatusClick = (event) => {
const trigger = event.currentTarget
if (menuOpen.value && menuTrigger.value === trigger) {
menuOpen.value = false
return
}
menuTrigger.value = trigger
menuOpen.value = true
}
const menuItems = computed(() =>
Object.entries(SERVICE_STATUS)
.filter(([value]) => value !== props.service.status)
.map(([value, label]) => ({
key: `status-${value}`,
label: `تغییر به ${label}`,
icon: 'arrows-clockwise',
onClick: () => emit('change-status', { service: props.service, status: value }),
}))
)
</script>
<style lang="scss" scoped>
.service-item {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 50%);
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
border-radius: 0.875rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-flow: row wrap;
align-items: center;
}
&__user {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1 1 30%;
min-width: 0;
}
&__avatar {
width: 3rem;
height: 3rem;
min-width: 3rem;
border-radius: 9999px;
overflow: hidden;
background: #fff;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--placeholder {
background: #f5f5f5;
}
}
&__info {
min-width: 0;
}
&__name {
font-family: var(--font-family-fa);
font-size: 0.95rem;
color: #4b4b4b;
margin: 0 0 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
}
&__request-id {
font-family: var(--font-family-fa);
font-size: 0.7rem;
color: #8f8f8f;
margin: 0;
}
&__request-id-label {
margin-inline-end: 0.25rem;
}
&__request-id-value {
font-family: var(--font-family-en);
color: #535353;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.375rem;
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;
align-items: center;
gap: 0.375rem;
flex: 1 1 100%;
@media (min-width: 1024px) {
flex: 0 0 auto;
}
}
&__details-btn {
min-width: 9rem;
padding: 0 0.875rem;
}
}
</style>
@@ -0,0 +1,125 @@
<template>
<form class="services-filters" @submit.prevent="onSubmit">
<div class="services-filters__grid">
<TextField v-model="form.userName" name="userName" label="جستجو اسم کاربر">
<template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<SelectField
v-model="form.status"
name="status"
label="دسته بندی"
:options="statusOptions"
option-label="label"
option-value="value"
/>
<DatePickerField v-model="form.fromDate" name="fromDate" label="از تاریخ" :max="todayIso" />
<DatePickerField v-model="form.toDate" name="toDate" label="تا تاریخ" :max="todayIso" />
</div>
<div class="services-filters__actions">
<CircleButton
v-if="hasFilters"
tooltip="حذف فیلتر"
bg-color="transparent"
size="2.5rem"
type="button"
@click="onReset"
>
<template #icon>
<SvgIcon name="close" :size="20" />
</template>
</CircleButton>
<CircleButton
tooltip="اعمال فیلتر"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.5rem"
type="submit"
>
<template #icon>
<SvgIcon name="funnel" :size="20" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</form>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS } from '@/enums'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ userName: '', status: '', fromDate: '', toDate: '' }),
},
})
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ userName: '', status: '', fromDate: '', toDate: '' })
const form = ref({ ...emptyForm(), ...props.modelValue })
watch(
() => props.modelValue,
(next) => {
form.value = { ...emptyForm(), ...next }
},
{ deep: true }
)
const todayIso = new Date().toISOString()
const statusOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
value,
label,
}))
const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
)
const onSubmit = () => {
emit('update:modelValue', { ...form.value })
emit('apply', { ...form.value })
}
const onReset = () => {
form.value = emptyForm()
emit('update:modelValue', { ...form.value })
emit('reset')
}
</script>
<style lang="scss" scoped>
.services-filters {
display: flex;
align-items: flex-start;
gap: 0.625rem;
&__grid {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
}
}
&__actions {
display: flex;
align-items: center;
gap: 0.25rem;
padding-top: 1.75rem;
}
}
</style>
@@ -0,0 +1,163 @@
<template>
<BasicModal
title="ارسال پیام"
title-en="Send message"
width="95%"
max-width="58rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="send-message" @submit.prevent="onSubmit(close)">
<div class="send-message__row send-message__row--two">
<SelectField
v-model="form.category"
name="category"
label="دسته بندی"
placeholder="انتخاب کنید"
:options="categoryOptions"
option-label="label"
option-value="value"
/>
<SelectField
v-model="form.priority"
name="priority"
label="اولویت پیام"
placeholder="انتخاب کنید"
:options="priorityOptions"
option-label="label"
option-value="value"
/>
</div>
<div class="send-message__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات خود را وارد کنید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="4"
/>
</div>
<div class="send-message__divider" />
<footer class="send-message__footer">
<button type="button" class="send-message__cancel" @click="close">
<span>منصرف شدم</span>
<SvgIcon name="caret-down" :size="16" color="currentColor" />
</button>
<BaseButton
type="submit"
text="ارسال"
custom-class="send-message__submit"
:disabled="!canSubmit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_STATUS } from '@/enums'
defineOptions({ name: 'SendMessageModal' })
const categoryOptions = Object.entries(SERVICE_STATUS).map(([value, label]) => ({
value,
label,
}))
const priorityOptions = [
{ value: 'very_urgent', label: 'بسیار فوری' },
{ value: 'urgent', label: 'فوری' },
{ value: 'normal', label: 'عادی' },
{ value: 'low', label: 'کم اهمیت' },
]
const form = ref({
category: '',
priority: '',
description: '',
})
const canSubmit = computed(
() => form.value.category && form.value.priority && form.value.description.trim().length > 0
)
const onSubmit = (close) => {
if (!canSubmit.value) return
toast.success('پیام با موفقیت ارسال شد')
close?.()
}
</script>
<style lang="scss" scoped>
.send-message {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: start;
padding-block: 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
&--two {
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
}
&__divider {
border-block-end: 1px solid #dddddd;
margin-block: 0.5rem;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
&__cancel {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
padding: 0.5rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
color: #969696;
&:hover {
color: #6b6b6b;
}
}
&__submit {
min-width: 17rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,97 @@
<template>
<BasicModal
title="جزئیات خدمت"
title-en="Service Detail"
width="95%"
max-width="64rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ data }">
<div class="service-details">
<div class="service-details__row service-details__row--two">
<LineInfoBlock title="عنوان خدمت" :desc="serviceTitle(data)" />
<LineInfoBlock title="نوع خدمت" :desc="serviceTypeLabel(data)" />
</div>
<div class="service-details__row">
<LineInfoBlock title="درخواست" :desc="requestText(data)" />
</div>
<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>
</div>
</template>
</BasicModal>
</template>
<script setup>
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { SERVICE_TYPE } from '@/enums'
import useModal from '@/composables/useModal'
defineOptions({ name: 'ServiceDetailsModal' })
const { openModal } = useModal()
const serviceTitle = (data) => data?.title || data?.serviceTitle || '—'
const serviceTypeLabel = (data) => data?.typeLabel || SERVICE_TYPE[data?.type] || '—'
const requestText = (data) => data?.requestText || data?.description || '—'
const onSendMessage = (data) => {
openModal('SendMessageModal', { service: data })
}
</script>
<style lang="scss" scoped>
.service-details {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: start;
padding-block: 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
&--two {
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
}
&__divider {
border-block-end: 1px solid #dddddd;
margin-block: 0.5rem;
}
&__footer {
display: flex;
justify-content: center;
}
&__send-btn {
min-width: 17rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,108 @@
<template>
<div class="services-page">
<BoxedIconTitleBlock
class="services-page__heading"
title="مدیریت خدمات"
desc="در این قسمت می‌توانید خدمت های خواسته شده را مدیریت کنید"
>
<template #icon>
<SvgIcon name="users-three" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<ServicesFilters v-model="filters" @apply="onFilterApply" @reset="onFilterReset" />
<SimpleTitleIconBlock title="لیست تمام خدمات خواسته شده" class="services-page__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<div v-if="services.length > 0">
<ServiceItem
v-for="service in services"
:key="service.id"
:service="service"
@show-details="onShowDetails"
@change-status="onChangeStatus"
/>
</div>
<NoItems v-else title="متاسفیم" desc="درخواست خدمتی برای نمایش وجود ندارد." />
<ServiceDetailsModal v-if="isModal('ServiceDetailsModal')" />
<SendMessageModal v-if="isModal('SendMessageModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import ServicesFilters from '@/features/admin/services/components/ServicesFilters.vue'
import ServiceItem from '@/features/admin/services/components/ServiceItem.vue'
import ServiceDetailsModal from '@/features/admin/services/components/modals/ServiceDetailsModal.vue'
import SendMessageModal from '@/features/admin/services/components/modals/SendMessageModal.vue'
import useModal from '@/composables/useModal'
const { openModal, isModal } = useModal()
const filters = ref({ userName: '', status: '', fromDate: '', toDate: '' })
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 onFilterApply = () => {}
const onFilterReset = () => {}
const onShowDetails = (service) => {
openModal('ServiceDetailsModal', service)
}
const onChangeStatus = ({ service, status }) => {
const idx = allServices.value.findIndex((s) => s.id === service.id)
if (idx !== -1) allServices.value[idx] = { ...allServices.value[idx], status }
}
</script>
<style lang="scss" scoped>
.services-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-bottom: 0.375rem;
}
}
</style>