fix: bugs
Deploy banu-front / deploy (push) Successful in 1m28s

This commit is contained in:
sajjadtalkhabi
2026-07-09 13:25:59 +03:30
parent 38a396ba21
commit da34813b1d
45 changed files with 2486 additions and 1048 deletions
+25 -21
View File
@@ -17,10 +17,31 @@
</button>
</div>
<template v-for="tab in tabs">
<div
v-if="tab.hasButton"
:key="`btn-${tab.name}`"
class="tabs-block__action tabs-block__action--desktop"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</template>
</div>
<template v-for="tab in tabs">
<div
v-for="tab in tabs"
:key="`btn-${tab.name}`"
class="tabs-block__action tabs-block__action--desktop"
v-if="tab.hasButton"
:key="`mbtn-${tab.name}`"
class="tabs-block__action tabs-block__action--mobile"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
@@ -33,24 +54,7 @@
</template>
</BaseButton>
</div>
</div>
<div
v-for="tab in tabs"
:key="`mbtn-${tab.name}`"
class="tabs-block__action tabs-block__action--mobile"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</template>
<div class="tabs-block__content">
<slot :name="activeTab" />
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="badge-select">
<label v-if="label" class="badge-select__label">{{ label }}</label>
<div class="badge-select__chips" :class="`badge-select__chips--${size}`">
<button
v-for="option in options"
:key="option[optionValue]"
type="button"
class="badge-select__chip"
:class="[
`badge-select__chip--${variant}`,
{ 'badge-select__chip--selected': modelValue === option[optionValue] },
]"
:disabled="disabled"
@click="select(option)"
>
{{ option[optionLabel] }}
</button>
</div>
<div v-if="error" class="badge-select__error">{{ error }}</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: [String, Number], default: null },
label: { type: String, default: '' },
options: { type: Array, default: () => [] },
optionLabel: { type: String, default: 'label' },
optionValue: { type: String, default: 'value' },
variant: { type: String, default: 'neutral' },
size: { type: String, default: 'md' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'change'])
const select = (option) => {
if (props.disabled) return
const value = option[props.optionValue]
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style lang="scss" scoped>
.badge-select {
width: 100%;
margin-bottom: 0.75rem;
&__label {
display: flex;
margin-bottom: 0.375rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
&__chip {
border: 1px solid #e8e8e8;
border-radius: 9999px;
background: transparent;
font-family: var(--font-family-fa);
color: #b5b5b5;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover:enabled {
color: #7a7a7a;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--selected {
background: #fff;
border-color: #f0f0f0;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 6%);
color: #4b4b4b;
font-weight: 500;
}
&--primary#{&}--selected {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
&__chips--sm .badge-select__chip {
padding: 0.25rem 0.875rem;
font-size: 0.7rem;
}
&__chips--md .badge-select__chip {
padding: 0.375rem 1.125rem;
font-size: 0.8rem;
}
&__chips--lg .badge-select__chip {
padding: 0.5rem 1.375rem;
font-size: 0.875rem;
}
&__error {
margin-top: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
</style>
+42
View File
@@ -15,6 +15,15 @@
@click="toggle"
>
<span class="select-field__value">{{ selectedLabel }}</span>
<button
v-if="showClear"
type="button"
class="select-field__clear"
aria-label="پاک کردن"
@click.stop="clearSelection"
>
<SvgIcon name="close" :size="14" color="currentColor" />
</button>
<SvgIcon
name="caret-down"
:size="20"
@@ -90,6 +99,7 @@ const props = defineProps({
optionLabel: { type: String, default: 'name' },
optionValue: { type: String, default: 'id' },
multiple: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
@@ -130,6 +140,20 @@ const selectedLabel = computed(() => {
return found ? found[props.optionLabel] : props.placeholder
})
const hasValue = computed(() =>
props.multiple
? Array.isArray(props.modelValue) && props.modelValue.length > 0
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
)
const showClear = computed(() => props.clearable && !props.disabled && hasValue.value)
const clearSelection = () => {
const cleared = props.multiple ? [] : null
emit('update:modelValue', cleared)
emit('change', cleared)
}
const select = (opt) => {
if (props.disabled) return
const value = opt[props.optionValue]
@@ -275,6 +299,24 @@ onBeforeUnmount(() => unmountFloating())
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-inline-end: 0.375rem;
border: none;
background: transparent;
color: var(--color-thd-gray);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: var(--color-error);
}
}
&__error {
margin-top: 0.25rem;
font-family: var(--font-family-fa);
@@ -0,0 +1,127 @@
<template>
<div class="admin-missionary-page">
<BoxedIconTitleBlock
class="admin-missionary-page__heading"
title="مدیریت مبلغین"
desc="در این قسمت می‌توانید درخواست‌های اعزام مبلغین را مشاهده و مدیریت کنید"
>
<template #icon>
<SvgIcon name="user-sound" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="لیست همه درخواست‌ها" class="admin-missionary-page__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
:always-actionable="true"
@show-details="onShowDetails"
@change-status="onAskChangeStatus"
/>
</div>
<NoItems v-else title="متاسفیم" desc="درخواستی برای نمایش وجود ندارد." />
<PaginationBlock :pagination="paginationMeta" @update:page="setPage" />
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
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 SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import MissionaryRequestItem from '@/features/missionary/components/MissionaryRequestItem.vue'
import RequestDetailsModal from '@/features/missionary/components/modals/RequestDetailsModal.vue'
import {
adminMissionaryRequestsKeys,
useAdminMissionaryRequestsListQuery,
useChangeAdminMissionaryRequestStatusMutation,
} from '@/services/query/admin-missionary-requests'
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminMissionaryRequestsListQuery(filters, pagination, {
keepPreviousData: true,
})
const toRequestView = (request) => ({
...request,
code: request.id,
requestNumber: request.id,
requestDate: formatJalaaliDate(request.requestedDate),
statusLabel: MISSIONARY_REQUEST_STATUS[request.status] || request.status || '—',
phone: request.requesterPhone,
address: request.location,
})
const requests = computed(() => (data.value?.data ?? []).map((r) => toRequestView(r)))
const paginationMeta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const statusMutation = useChangeAdminMissionaryRequestStatusMutation()
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: adminMissionaryRequestsKeys.all, refetchType: 'all' })
const STATUS_CONFIRMS = {
accepted: { title: 'پذیرش درخواست', verb: 'پذیرش' },
rejected: { title: 'رد درخواست', verb: 'رد' },
}
const onAskChangeStatus = ({ id, status }) => {
const config = STATUS_CONFIRMS[status]
if (!config) return
const request = requests.value.find((r) => r.id === id)
openModal('ConfirmModal', {
title: config.title,
message: `آیا از ${config.verb} درخواست <strong>${request?.title || ''}</strong> مطمئن هستید؟`,
onConfirm: () => statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidate }),
})
}
const onShowDetails = (request) => {
openModal('RequestDetailsModal', request)
}
</script>
<style lang="scss" scoped>
.admin-missionary-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.5rem;
}
&__list-title {
margin-bottom: 0.375rem;
}
}
</style>
+11
View File
@@ -252,4 +252,15 @@ export default [
subtitle: 'جلسات مشاوره را پیگیری کرده و پاسخگوی نیازهای کاربران باشید.',
},
},
{
path: '/missionary-requests',
name: 'admin-missionary-requests',
component: () => import('@/features/admin/missionary/pages/AdminMissionaryRequestsPage.vue'),
meta: {
layout: 'admin',
role: 'admin',
title: 'مدیریت مبلغین',
subtitle: 'درخواست‌های اعزام مبلغین را مشاهده و مدیریت نمایید.',
},
},
]
@@ -92,7 +92,7 @@ const props = defineProps({
maxSeconds: { type: Number, default: 120 },
context: { type: String, default: 'verification' },
subType: { type: String, default: '' },
purpose: { type: String, default: '' },
purpose: { type: String, default: 'video' },
error: { type: String, default: '' },
})
@@ -19,11 +19,6 @@
</div>
<div class="mdis__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#007074" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mdis__btn"
@@ -40,7 +35,6 @@
<script setup>
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
dispatch: { type: Object, required: true },
@@ -87,7 +81,7 @@ const emit = defineEmits(['show-details'])
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: flex-start;
text-align: end;
gap: 0.35rem;
}
@@ -167,7 +161,7 @@ const emit = defineEmits(['show-details'])
&__btn {
min-width: 8rem;
height: 2rem;
height: 2.5rem;
padding: 0 1.25rem;
}
}
@@ -15,20 +15,23 @@
</div>
</div>
<div class="mnar__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<div class="mnar__actions" @click.stop>
<CircleButton
tooltip="ویرایش روایت"
bg-color="rgba(107, 107, 107, 0.04)"
@click="emit('edit', narrative)"
>
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
<SvgIcon name="pencil" :size="18" color="#535353" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<CircleButton
tooltip="حذف روایت"
bg-color="rgba(243, 102, 117, 0.06)"
@click="emit('delete', narrative)"
>
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(107, 107, 107, 0.04)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#535353" />
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
</div>
@@ -43,7 +46,7 @@ defineProps({
narrative: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const emit = defineEmits(['show-details', 'edit', 'delete'])
</script>
<style lang="scss" scoped>
@@ -78,7 +81,7 @@ const emit = defineEmits(['show-details'])
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: flex-start;
text-align: end;
gap: 0.2rem;
}
@@ -167,11 +170,5 @@ const emit = defineEmits(['show-details'])
justify-content: flex-start;
}
}
&__btn {
min-width: 7rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -1,56 +1,56 @@
<template>
<div class="missionary-request" @click="emit('show-details', request)">
<div class="missionary-request__heading">
<p class="missionary-request__title">{{ request.title }}</p>
<p class="missionary-request__subtitle">{{ request.requesterName }}</p>
<div class="mreq" @click="emit('show-details', request)">
<div class="mreq__heading">
<span class="mreq__code">{{ request.code }}</span>
<div class="mreq__title-wrap">
<p class="mreq__title">{{ request.title }}</p>
<p class="mreq__subtitle">شماره درخواست : {{ request.requestNumber }}</p>
</div>
</div>
<div class="missionary-request__meta">
<div class="missionary-request__pill">
<span class="missionary-request__pill-label">تاریخ درخواست :</span>
<span class="missionary-request__pill-value">
{{ formatJalaaliDate(request.requestedDate) || '—' }}
</span>
<div class="mreq__meta">
<div class="mreq__pill mreq__pill--date">
<span class="mreq__pill-label">تاریخ درخواست :</span>
<span class="mreq__pill-value">{{ request.requestDate }}</span>
</div>
<div class="missionary-request__pill">
<span class="missionary-request__pill-label">مکان :</span>
<span class="missionary-request__pill-value missionary-request__pill-value--fa">
{{ request.location || '—' }}
</span>
<div class="mreq__pill mreq__pill--name">
<span class="mreq__pill-label">نام درخواست کننده :</span>
<span class="mreq__pill-value">{{ request.requesterName }}</span>
</div>
<div class="mreq__pill mreq__pill--status" :class="`mreq__pill--${tone}`">
<span class="mreq__dot" />
<span class="mreq__pill-value mreq__pill-value--status">{{ request.statusLabel }}</span>
</div>
<Badge :variant="statusVariant" :dot="true" :value="statusLabel" />
</div>
<div class="missionary-request__actions" @click.stop>
<div class="mreq__actions" @click.stop>
<CircleButton
v-if="request.status !== 'accepted'"
v-if="canAccept"
tooltip="پذیرش درخواست"
bg-color="rgba(0, 154, 18, 0.08)"
size="2.5rem"
bg-color="rgba(0, 112, 116, 0.08)"
@click="emit('change-status', { id: request.id, status: 'accepted' })"
>
<template #icon>
<SvgIcon name="check" :size="20" color="var(--color-secondary)" />
<SvgIcon name="check" :size="18" color="#007074" />
</template>
</CircleButton>
<CircleButton
v-if="request.status !== 'rejected'"
v-if="canReject"
tooltip="رد درخواست"
bg-color="rgba(204, 40, 49, 0.08)"
size="2.5rem"
bg-color="rgba(243, 102, 117, 0.06)"
@click="emit('change-status', { id: request.id, status: 'rejected' })"
>
<template #icon>
<SvgIcon name="close" :size="20" color="var(--color-error)" />
<SvgIcon name="close" :size="18" color="var(--color-error)" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="missionary-request__details-btn"
custom-class="mreq__btn"
@click="emit('show-details', request)"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="16" color="#fff" />
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
@@ -59,39 +59,41 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const STATUS_VARIANTS = {
const TONE_MAP = {
pending: 'warning',
seen: 'info',
seen: 'neutral',
accepted: 'success',
rejected: 'danger',
}
const props = defineProps({
request: { type: Object, required: true },
alwaysActionable: { type: Boolean, default: false },
})
const emit = defineEmits(['show-details', 'change-status'])
const statusLabel = computed(() => MISSIONARY_REQUEST_STATUS[props.request.status] || '')
const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'neutral')
const tone = computed(() => TONE_MAP[props.request.status] || 'neutral')
const canAct = computed(() => props.alwaysActionable || props.request.status === 'pending')
const canAccept = computed(() => canAct.value && props.request.status !== 'accepted')
const canReject = computed(() => canAct.value && props.request.status !== 'rejected')
</script>
<style lang="scss" scoped>
.missionary-request {
.mreq {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.875rem;
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
@@ -103,15 +105,26 @@ const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'n
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1 1 33%;
min-width: 0;
align-items: flex-start;
text-align: end;
gap: 0.125rem;
}
&__title {
@@ -124,58 +137,136 @@ const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'n
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
flex: 1 1 40%;
justify-content: center;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
flex-wrap: nowrap;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
line-height: 1.5;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&--fa {
font-family: var(--font-family-fa);
&__pill-value--status {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.65rem;
}
&__dot {
width: 0.32rem;
height: 0.32rem;
border-radius: 9999px;
flex-shrink: 0;
}
&__pill--status {
padding: 0.3rem 0.75rem;
}
&__pill--danger {
background: rgba(243, 102, 117, 6%);
.mreq__pill-value {
color: #cc2831;
}
.mreq__dot {
background: #cc2831;
}
}
&__pill--success {
background: rgba(0, 112, 116, 6%);
.mreq__pill-value {
color: #007074;
}
.mreq__dot {
background: #007074;
}
}
&__pill--warning {
background: rgba(182, 132, 45, 7%);
.mreq__pill-value {
color: #b6842d;
}
.mreq__dot {
background: #b6842d;
}
}
&__pill--neutral {
background: rgba(107, 107, 107, 6%);
.mreq__pill-value {
color: #535353;
}
.mreq__dot {
background: #989898;
}
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.375rem;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__details-btn {
min-width: 9rem;
padding: 0 0.625rem;
&__btn {
min-width: 8rem;
height: 2.5rem;
padding: 0 1.25rem;
}
}
</style>
@@ -7,8 +7,8 @@
</div>
<div class="muc__profile-info">
<div class="muc__level">
<span class="muc__level-label">سطح کاربر :</span>
<span class="muc__level-value">{{ level }}</span>
<span class="muc__level-label">شماره تماس :</span>
<span class="muc__level-value muc__level-value--numeric">{{ phone }}</span>
</div>
<p class="muc__name">{{ fullName }}</p>
<div class="muc__location">
@@ -29,15 +29,6 @@
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">امتیاز کاربر</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ rating }}</span>
</div>
</div>
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">تعداد درخواستها</span>
<div class="muc__stat-value">
@@ -48,11 +39,11 @@
</div>
<div class="muc__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2.5rem">
<template #icon>
<SvgIcon name="pencil" :size="20" color="#007074" />
<BaseButton text="افزودن روایت" custom-class="muc__btn" @click="emit('add-narrative')">
<template #appendIcon>
<SvgIcon name="plus" :size="16" color="#fff" />
</template>
</CircleButton>
</BaseButton>
<BaseButton text="تکمیل اطلاعات" custom-class="muc__btn" @click="emit('complete-info')">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
@@ -66,20 +57,18 @@
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
profile: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['complete-info', 'settings'])
const emit = defineEmits(['complete-info', 'settings', 'add-narrative'])
const fullName = computed(() => props.profile.fullName || '—')
const city = computed(() => props.profile.city || '—')
const level = computed(() => props.profile.level || '—')
const phone = computed(() => props.profile.phone || '—')
const avatar = computed(() => props.profile.avatar || '')
const membershipDays = computed(() => props.profile.membershipDays ?? '—')
const rating = computed(() => props.profile.rating ?? '—')
const requestsCount = computed(() => props.profile.requestsCount ?? '—')
</script>
@@ -234,6 +223,10 @@ const requestsCount = computed(() => props.profile.requestsCount ?? '—')
&__level-value {
font-weight: 500;
font-size: 0.75rem;
&--numeric {
font-family: var(--font-family-en);
}
}
&__avatar {
@@ -0,0 +1,238 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش روایت' : 'ثبت روایت'"
:title-en="isEditMode ? 'Edit Story' : 'Create Story'"
width="95%"
max-width="58rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="create-narrative" @submit.prevent="onSubmit(close)">
<div class="create-narrative__row create-narrative__row--two">
<TextField
v-model="form.title"
name="title"
label="عنوان روایت"
:error="errors.title"
@blur="validateAt('title', form.title)"
>
<template #appendIcon>
<SvgIcon name="pencil" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<SelectField
v-model="form.missionaryRequestId"
name="missionaryRequestId"
label="درخواست مرتبط"
placeholder="انتخاب کنید"
:options="requestOptions"
option-label="title"
option-value="id"
/>
</div>
<div class="create-narrative__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات روایت خود را وارد کنید"
:row="4"
:error="errors.description"
/>
</div>
<div class="create-narrative__upload">
<FileUploader
v-model="files"
accept="image/*,video/mp4"
:max-files="5"
@select="onFilesSelect"
@error="onFileError"
/>
</div>
<div class="create-narrative__divider" />
<footer class="create-narrative__footer">
<BaseButton
type="submit"
:text="isEditMode ? 'ذخیره تغییرات' : 'ثبت روایت'"
custom-class="create-narrative__submit"
:loading="createMutation.isPending.value || updateMutation.isPending.value"
:disabled="uploadMutation.isPending.value"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</form>
</template>
</BasicModal>
</template>
<script setup>
import * as yup from 'yup'
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
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 { useQueryClient } from '@tanstack/vue-query'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { useMissionaryRequestsListQuery } from '@/services/query/missionary-requests'
import {
missionaryMemoriesKeys,
useCreateMissionaryMemoryMutation,
useUpdateMissionaryMemoryMutation,
} from '@/services/query/missionary-memories'
defineOptions({ name: 'CreateNarrativeModal' })
const queryClient = useQueryClient()
const { getModal } = useModal()
const editingMemory = getModal('CreateNarrativeModal')?.data?.memory ?? null
const isEditMode = !!editingMemory
const form = ref({
title: editingMemory?.title || '',
description: editingMemory?.description || '',
missionaryRequestId: editingMemory?.missionaryRequestId ?? null,
})
const files = ref(
(editingMemory?.media ?? []).map((media) => ({
id: media.id,
name: media.fileName,
size: media.fileSize,
url: media.url,
existing: true,
}))
)
const schema = yup.object({
title: yup.string().trim().required('عنوان روایت را وارد کنید'),
description: yup.string().trim().notRequired(),
missionaryRequestId: yup.mixed().nullable().notRequired(),
})
const { validate, validateAt, errors, resetErrors } = useYup(schema)
const requestsFilters = ref({ status: 'accepted' })
const requestsPagination = ref({ page: 1, perPage: 100 })
const { data: requestsData } = useMissionaryRequestsListQuery(requestsFilters, requestsPagination)
const requestOptions = computed(
() => requestsData.value?.data?.items ?? requestsData.value?.data ?? []
)
const uploadMutation = useUploadMediaMutation()
const createMutation = useCreateMissionaryMemoryMutation()
const updateMutation = useUpdateMissionaryMemoryMutation()
const onFilesSelect = async (picked) => {
for (const file of picked) {
try {
const fd = objectToFormData({ file, purpose: 'memory' })
const response = await uploadMutation.mutateAsync(fd)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
if (id == null) continue
files.value = [
...files.value,
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
]
} catch {
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
}
}
}
const onFileError = (msg) => toast.warning(msg)
const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
try {
const body = {
title: payload.title,
description: payload.description,
mediaIds: files.value
.filter((f) => !f.existing)
.map((f) => f.id)
.filter((id) => id != null),
}
if (form.value.missionaryRequestId) body.missionaryRequestId = form.value.missionaryRequestId
if (isEditMode) {
await updateMutation.mutateAsync({ id: editingMemory.id, payload: body })
} else {
await createMutation.mutateAsync(body)
}
await queryClient.invalidateQueries({
queryKey: missionaryMemoriesKeys.all,
refetchType: 'all',
})
toast.success(isEditMode ? 'روایت با موفقیت به‌روزرسانی شد.' : 'روایت با موفقیت ثبت شد.')
resetErrors()
form.value = { title: '', description: '', missionaryRequestId: null }
files.value = []
close?.()
} catch {
toast.error('ثبت روایت با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.create-narrative {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.875rem;
&--two {
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
}
&__upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__divider {
border-block-end: 1px solid #ddd;
margin-block: 0.25rem;
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
}
&__submit {
min-width: 16rem;
padding: 0 1.5rem;
}
}
</style>
@@ -1,123 +0,0 @@
<template>
<BasicModal
title="جزئیات درخواست"
title-en="Request Details"
width="95%"
max-width="50rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="request-details">
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="request">
<div class="request-details__status">
<Badge :variant="statusVariant" :dot="true" :value="statusLabel" />
</div>
<div class="request-details__grid">
<LineInfoBlock title="عنوان درخواست" :desc="request.title || '—'" />
<LineInfoBlock title="نام درخواست کننده" :desc="request.requesterName || '—'" />
<LineInfoBlock title="شماره تماس" :numeric-desc="request.requesterPhone || ''" />
<LineInfoBlock title="ایمیل" :numeric-desc="request.requesterEmail || ''" />
<LineInfoBlock title="مکان" :desc="request.location || '—'" />
<LineInfoBlock
title="تاریخ درخواست"
:numeric-desc="formatJalaaliDate(request.requestedDate) || ''"
/>
<LineInfoBlock
title="تاریخ ثبت"
:numeric-desc="formatJalaaliDateTime(request.createdAt) || ''"
/>
<LineInfoBlock title="منبع درخواست" :desc="request.externalSource || '—'" />
<div class="request-details__cell--full">
<LineInfoBlock title="توضیحات" :desc="request.description || '—'" />
</div>
</div>
</template>
<NoItems v-else title="یافت نشد" desc="اطلاعات این درخواست در دسترس نیست." />
<div class="request-details__actions">
<BaseButton text="بسیار خب" custom-class="request-details__btn" @click="close">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import useModal from '@/composables/useModal'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { formatJalaaliDate, formatJalaaliDateTime } from '@/utils/date-utils'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useMissionaryRequestQuery } from '@/services/query/missionary-requests'
defineOptions({ name: 'MissionaryRequestDetailsModal' })
const STATUS_VARIANTS = {
pending: 'warning',
seen: 'info',
accepted: 'success',
rejected: 'danger',
}
const { getModal } = useModal()
const modalData = computed(() => getModal('MissionaryRequestDetailsModal')?.data ?? {})
const requestId = computed(() => modalData.value.id ?? null)
const { data: request, isLoading } = useMissionaryRequestQuery(requestId, {
enabled: () => !!requestId.value,
})
const statusLabel = computed(() => MISSIONARY_REQUEST_STATUS[request.value?.status] || '—')
const statusVariant = computed(() => STATUS_VARIANTS[request.value?.status] || 'neutral')
</script>
<style lang="scss" scoped>
.request-details {
width: 100%;
text-align: start;
&__status {
margin-bottom: 0.5rem;
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__cell--full {
grid-column: 1 / -1;
}
&__actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
&__btn {
min-width: 12rem;
}
}
</style>
@@ -25,9 +25,18 @@
</div>
</div>
<div class="narrative-details__request">
<LineInfoBlock title="درخواست مرتبط" :desc="narrative.missionaryRequest?.title || '—'" />
</div>
<div class="narrative-details__body">
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="narrative.body || '—'" />
</div>
<div v-if="narrative.media?.length" class="narrative-details__media">
<p class="narrative-details__media-title">پیوستها</p>
<MessageAttachments :media="narrative.media" />
</div>
</div>
</BasicModal>
</template>
@@ -38,6 +47,7 @@ import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import MessageAttachments from '@/components/blocks/MessageAttachments.vue'
defineOptions({ name: 'NarrativeDetailsModal' })
@@ -109,6 +119,20 @@ const narrative = computed(() => getModal('NarrativeDetailsModal')?.data ?? {})
color: #4e4e4e;
}
&__media {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__media-title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.8rem;
color: #848484;
margin: 0;
}
&__footer {
display: flex;
justify-content: flex-end;
@@ -14,7 +14,7 @@
/>
<LineInfoBlock title="تاریخ ثبت درخواست" :numeric-desc="request.requestDate || ''" />
<LineInfoBlock title="شماره تماس" :numeric-desc="request.phone || ''" />
<LineInfoBlock title="کد پستی" :numeric-desc="request.postalCode || ''" />
<LineInfoBlock title="وضعیت" :desc="request.statusLabel || '—'" />
</div>
<div class="request-details__row">
@@ -0,0 +1,132 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش گواهی و تقدیرنامه' : 'افزودن گواهی و تقدیرنامه جدید'"
title-en="Certificate"
width="95%"
max-width="48rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="add-certificate" @submit.prevent="onSubmit(close)">
<TextField
v-model="form.title"
name="title"
label="عنوان"
:error="errors.title"
@blur="validateAt('title', form.title)"
/>
<TextField
v-model="form.universityName"
name="universityName"
label="نام دانشگاه"
:error="errors.universityName"
@blur="validateAt('universityName', form.universityName)"
/>
<div class="add-certificate__row">
<TextField
v-model="form.startYear"
name="startYear"
label="سال شروع"
inputmode="numeric"
:convert-digits="true"
/>
<TextField
v-model="form.endYear"
name="endYear"
label="سال پایان"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<div class="add-certificate__divider" />
<footer class="add-certificate__footer">
<BaseButton type="submit" text="ذخیره اطلاعات" custom-class="add-certificate__submit">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import useYup from '@/composables/useYup'
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 TextField from '@/components/form/TextField.vue'
import { useCompleteInfoStore } from '@/features/missionary/store/complete-info'
defineOptions({ name: 'MissionaryAddCertificateModal' })
const store = useCompleteInfoStore()
const { getModal } = useModal()
const modalData = getModal('MissionaryAddCertificateModal')?.data ?? {}
const editingIndex = modalData.index ?? null
const isEditMode = editingIndex != null
const emptyForm = () => ({
title: '',
universityName: '',
startYear: '',
endYear: '',
})
const form = ref({ ...emptyForm(), ...modalData.entry })
const schema = yup.object({
title: yup.string().trim().required('عنوان را وارد کنید'),
universityName: yup.string().trim().required('نام دانشگاه را وارد کنید'),
})
const { validate, validateAt, errors } = useYup(schema)
const onSubmit = async (close) => {
const { isValid } = await validate(form.value)
if (!isValid) return
store.upsertCertificate({ ...form.value }, editingIndex)
close?.()
}
</script>
<style lang="scss" scoped>
.add-certificate {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 0.875rem;
}
&__divider {
border-block-end: 1px solid #ddd;
margin-block: 0.5rem 0.75rem;
}
&__footer {
display: flex;
justify-content: flex-start;
}
&__submit {
min-width: 14rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,145 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش سابقه تحصیلی' : 'افزودن سابقه تحصیلی جدید'"
title-en="Education History"
width="95%"
max-width="48rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="add-education" @submit.prevent="onSubmit(close)">
<BadgeSelectField
v-model="form.degree"
label="مقطع تحصیلی"
:options="degreeOptions"
:error="errors.degree"
/>
<TextField
v-model="form.universityName"
name="universityName"
label="نام دانشگاه"
:error="errors.universityName"
@blur="validateAt('universityName', form.universityName)"
/>
<TextField
v-model="form.fieldOfStudy"
name="fieldOfStudy"
label="رشته تحصیلی"
:error="errors.fieldOfStudy"
@blur="validateAt('fieldOfStudy', form.fieldOfStudy)"
/>
<div class="add-education__row">
<TextField
v-model="form.startYear"
name="startYear"
label="سال شروع"
inputmode="numeric"
:convert-digits="true"
/>
<TextField
v-model="form.endYear"
name="endYear"
label="سال پایان"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<div class="add-education__divider" />
<footer class="add-education__footer">
<BaseButton type="submit" text="ذخیره اطلاعات" custom-class="add-education__submit">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import useYup from '@/composables/useYup'
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 TextField from '@/components/form/TextField.vue'
import BadgeSelectField from '@/components/form/BadgeSelectField.vue'
import { useCompleteInfoStore } from '@/features/missionary/store/complete-info'
import { EDUCATION_DEGREES } from '@/features/missionary/constants/complete-info'
defineOptions({ name: 'MissionaryAddEducationModal' })
const store = useCompleteInfoStore()
const { getModal } = useModal()
const modalData = getModal('MissionaryAddEducationModal')?.data ?? {}
const editingIndex = modalData.index ?? null
const isEditMode = editingIndex != null
const degreeOptions = Object.entries(EDUCATION_DEGREES).map(([value, label]) => ({ value, label }))
const emptyForm = () => ({
degree: null,
universityName: '',
fieldOfStudy: '',
startYear: '',
endYear: '',
})
const form = ref({ ...emptyForm(), ...modalData.entry })
const schema = yup.object({
degree: yup.string().nullable().required('مقطع تحصیلی را انتخاب کنید'),
universityName: yup.string().trim().required('نام دانشگاه را وارد کنید'),
fieldOfStudy: yup.string().trim().required('رشته تحصیلی را وارد کنید'),
})
const { validate, validateAt, errors } = useYup(schema)
const onSubmit = async (close) => {
const { isValid } = await validate(form.value)
if (!isValid) return
store.upsertEducation({ ...form.value }, editingIndex)
close?.()
}
</script>
<style lang="scss" scoped>
.add-education {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 0.875rem;
}
&__divider {
border-block-end: 1px solid #ddd;
margin-block: 0.5rem 0.75rem;
}
&__footer {
display: flex;
justify-content: flex-start;
}
&__submit {
min-width: 14rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,241 @@
<template>
<BasicModal
title="افزودن مهارت"
title-en="Add skills"
width="95%"
max-width="40rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="add-skill">
<div v-if="store.skills.length > 0" class="add-skill__selected">
<Badge
v-for="skill in store.skills"
:key="skill.title"
variant="neutral"
:label="skill.title"
:value="SKILL_LEVELS[skill.level] || skill.level"
>
<template #append>
<button
type="button"
class="add-skill__remove"
aria-label="حذف مهارت"
@click="store.removeSkill(skill.title)"
>
<SvgIcon name="close" :size="12" color="currentColor" />
</button>
</template>
</Badge>
</div>
<label class="add-skill__hint">مهارت های خود را با علامت Enter از هم جدا کنید.</label>
<textarea
v-model="query"
rows="4"
class="add-skill__input"
@keydown.enter.prevent="onEnter"
/>
<div v-if="suggestions.length > 0" class="add-skill__suggestions">
<div
v-for="suggestion in suggestions"
:key="suggestion"
class="add-skill__suggestion"
:class="{ 'add-skill__suggestion--open': pendingSkill === suggestion }"
>
<Badge
variant="neutral"
:value="suggestion"
clickable
@click="togglePending(suggestion)"
>
<template #append>
<SvgIcon name="plus" :size="12" color="currentColor" />
</template>
</Badge>
<div v-if="pendingSkill === suggestion" class="add-skill__levels">
<button
v-for="(label, level) in SKILL_LEVELS"
:key="level"
type="button"
class="add-skill__level"
@click="addWithLevel(suggestion, level)"
>
{{ label }}
</button>
</div>
</div>
</div>
<div class="add-skill__divider" />
<footer class="add-skill__footer">
<BaseButton text="ذخیره اطلاعات" custom-class="add-skill__submit" @click="close">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</div>
</template>
</BasicModal>
</template>
<script setup>
import { computed, ref } from 'vue'
import Badge from '@/components/Badge.vue'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useCompleteInfoStore } from '@/features/missionary/store/complete-info'
import { SKILL_LEVELS, SKILL_SUGGESTIONS } from '@/features/missionary/constants/complete-info'
defineOptions({ name: 'MissionaryAddSkillModal' })
const store = useCompleteInfoStore()
const query = ref('')
const pendingSkill = ref('')
const suggestions = computed(() => {
const term = query.value.trim()
if (!term) return []
const picked = new Set(store.skills.map((s) => s.title))
const matches = SKILL_SUGGESTIONS.filter(
(title) => title.includes(term) && !picked.has(title)
).slice(0, 8)
if (!matches.includes(term) && !picked.has(term)) matches.unshift(term)
return matches
})
const togglePending = (title) => {
pendingSkill.value = pendingSkill.value === title ? '' : title
}
const onEnter = () => {
const term = query.value.trim()
if (term) pendingSkill.value = term
}
const addWithLevel = (title, level) => {
store.addSkill({ title, level })
pendingSkill.value = ''
query.value = ''
}
</script>
<style lang="scss" scoped>
.add-skill {
display: flex;
flex-direction: column;
gap: 0.875rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__selected {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: #b1b1b1;
cursor: pointer;
&:hover {
color: var(--color-error);
}
}
&__hint {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
color: #b5b5b5;
}
&__input {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--color-thd-gray);
border-radius: 1rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #4b4b4b;
outline: none;
resize: vertical;
&:focus {
border-color: var(--color-prim-gray);
}
}
&__suggestions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem;
}
&__suggestion {
position: relative;
&--open {
z-index: 5;
}
}
&__levels {
position: absolute;
top: calc(100% + 0.25rem);
inset-inline-start: 0;
min-width: 100%;
display: flex;
flex-direction: column;
background: #f5f5f5;
border-radius: 0.625rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 8%);
overflow: hidden;
}
&__level {
border: none;
background: transparent;
padding: 0.625rem 1rem;
font-family: var(--font-family-fa);
font-size: 0.8rem;
color: #7a7a7a;
text-align: start;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #ececec;
color: #4b4b4b;
}
}
&__divider {
border-block-end: 1px solid #ddd;
margin-top: 4rem;
}
&__footer {
display: flex;
justify-content: flex-start;
}
&__submit {
min-width: 14rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,223 @@
<template>
<BasicModal
:title="isEditMode ? 'ویرایش سابقه کاری' : 'افزودن سابقه کاری'"
title-en="Work Experience"
width="95%"
max-width="58rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="add-work" @submit.prevent="onSubmit(close)">
<TextField
v-model="form.jobTitle"
name="jobTitle"
label="عنوان شغل شما"
:error="errors.jobTitle"
@blur="validateAt('jobTitle', form.jobTitle)"
/>
<div class="add-work__row">
<TextField
v-model="form.activityField"
name="activityField"
label="زمینه فعالیت شما در این شرکت"
/>
<TextField
v-model="form.organizationLevel"
name="organizationLevel"
label="رده سازمانی"
/>
</div>
<TextField
v-model="form.organizationName"
name="organizationName"
label="نام سازمان"
:error="errors.organizationName"
@blur="validateAt('organizationName', form.organizationName)"
/>
<div class="add-work__row">
<SelectField
v-model="form.provinceId"
name="provinceId"
label="استان"
:options="provinces"
option-label="name"
option-value="id"
@change="onProvinceChange"
/>
<SelectField
v-model="form.cityId"
name="cityId"
label="شهر"
:options="cities"
option-label="name"
option-value="id"
:disabled="!form.provinceId"
/>
</div>
<div class="add-work__row add-work__row--always">
<SelectField
v-model="form.startMonth"
name="startMonth"
label="ماه شروع"
:options="MONTHS"
option-label="label"
option-value="value"
/>
<TextField
v-model="form.startYear"
name="startYear"
label="سال شروع"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<div class="add-work__row add-work__row--always">
<SelectField
v-model="form.endMonth"
name="endMonth"
label="ماه پایان"
:options="MONTHS"
option-label="label"
option-value="value"
/>
<TextField
v-model="form.endYear"
name="endYear"
label="سال پایان"
inputmode="numeric"
:convert-digits="true"
/>
</div>
<TextareaField
v-model="form.achievements"
name="achievements"
label="دستاورد‌ها و وظایف کلیدی"
:row="4"
/>
<div class="add-work__divider" />
<footer class="add-work__footer">
<BaseButton type="submit" text="ذخیره اطلاعات" custom-class="add-work__submit">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</form>
</template>
</BasicModal>
</template>
<script setup>
import { ref } from 'vue'
import * as yup from 'yup'
import useYup from '@/composables/useYup'
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 TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { MONTHS } from '@/features/missionary/constants/complete-info'
import { useCompleteInfoStore } from '@/features/missionary/store/complete-info'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
defineOptions({ name: 'MissionaryAddWorkExperienceModal' })
const store = useCompleteInfoStore()
const { getModal } = useModal()
const modalData = getModal('MissionaryAddWorkExperienceModal')?.data ?? {}
const editingIndex = modalData.index ?? null
const isEditMode = editingIndex != null
const emptyForm = () => ({
jobTitle: '',
activityField: '',
organizationLevel: '',
organizationName: '',
provinceId: null,
cityId: null,
startMonth: null,
startYear: '',
endMonth: null,
endYear: '',
achievements: '',
})
const form = ref({ ...emptyForm(), ...modalData.entry })
const schema = yup.object({
jobTitle: yup.string().trim().required('عنوان شغل را وارد کنید'),
organizationName: yup.string().trim().required('نام سازمان را وارد کنید'),
})
const { validate, validateAt, errors } = useYup(schema)
const { data: provinces = ref([]) } = useGetProvincesQuery()
const provinceIdRef = ref(form.value.provinceId)
const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId
form.value.cityId = null
}
const onSubmit = async (close) => {
const { isValid } = await validate(form.value)
if (!isValid) return
store.upsertWorkExperience({ ...form.value }, editingIndex)
close?.()
}
</script>
<style lang="scss" scoped>
.add-work {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0 0.875rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
&--always {
grid-template-columns: 1fr 1fr;
}
}
&__divider {
border-block-end: 1px solid #ddd;
margin-block: 0.5rem 0.75rem;
}
&__footer {
display: flex;
justify-content: flex-start;
}
&__submit {
min-width: 14rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,259 @@
<template>
<BasicModal
title="بخش معرفی"
title-en="Introduction"
width="95%"
max-width="64rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="complete-info">
<TextareaField
v-model="store.introduction"
name="introduction"
label="لطفا خودتان را به صورت کامل معرفی نمایید."
:row="5"
/>
<section class="complete-info__section">
<div class="complete-info__section-head">
<LineTitleBlock title="مهارت های شما" title-en="Your Skills" />
<BaseButton
text="افزودن مهارت"
custom-class="complete-info__add-btn"
@click="openModal('MissionaryAddSkillModal')"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<div v-if="store.skills.length > 0" class="complete-info__badges">
<Badge
v-for="skill in store.skills"
:key="skill.title"
variant="neutral"
:label="skill.title"
:value="SKILL_LEVELS[skill.level] || skill.level"
/>
</div>
</section>
<section class="complete-info__section">
<div class="complete-info__section-head">
<LineTitleBlock title="سوابق کاری" title-en="Work Experience" />
<BaseButton
text="افـــــــــــزودن"
custom-class="complete-info__add-btn"
@click="openModal('MissionaryAddWorkExperienceModal')"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<div v-if="store.workExperiences.length > 0" class="complete-info__entries">
<EntryCard
v-for="(item, index) in store.workExperiences"
:key="index"
:title="item.jobTitle"
:subtitle="rangeSubtitle(item.organizationName, item.startYear, item.endYear)"
@edit="openModal('MissionaryAddWorkExperienceModal', { entry: item, index })"
/>
</div>
</section>
<section class="complete-info__section">
<div class="complete-info__section-head">
<LineTitleBlock title="سوابق علمی" title-en="Education History" />
<BaseButton
text="افـــــــــــزودن"
custom-class="complete-info__add-btn"
@click="openModal('MissionaryAddEducationModal')"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<div v-if="store.educationHistory.length > 0" class="complete-info__entries">
<EntryCard
v-for="(item, index) in store.educationHistory"
:key="index"
:title="educationTitle(item)"
:subtitle="rangeSubtitle(item.universityName, item.startYear, item.endYear)"
@edit="openModal('MissionaryAddEducationModal', { entry: item, index })"
/>
</div>
</section>
<section class="complete-info__section">
<div class="complete-info__section-head">
<LineTitleBlock title="گواهی ها و تقدیرنامه ها" title-en="Certificates" />
<BaseButton
text="افـــــــــــزودن"
custom-class="complete-info__add-btn"
@click="openModal('MissionaryAddCertificateModal')"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<div v-if="store.certificates.length > 0" class="complete-info__entries">
<EntryCard
v-for="(item, index) in store.certificates"
:key="index"
:title="item.title"
:subtitle="rangeSubtitle(item.universityName, item.startYear, item.endYear)"
@edit="openModal('MissionaryAddCertificateModal', { entry: item, index })"
/>
</div>
</section>
<div class="complete-info__divider" />
<footer class="complete-info__footer">
<BaseButton
text="ذخیره اطلاعات"
custom-class="complete-info__submit"
:loading="saveMutation.isPending.value"
@click="onSave(close)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</footer>
</div>
</template>
</BasicModal>
<AddSkillModal v-if="isModal('MissionaryAddSkillModal')" />
<AddWorkExperienceModal v-if="isModal('MissionaryAddWorkExperienceModal')" />
<AddEducationModal v-if="isModal('MissionaryAddEducationModal')" />
<AddCertificateModal v-if="isModal('MissionaryAddCertificateModal')" />
</template>
<script setup>
import { watch } from 'vue'
import { toast } from 'vue3-toastify'
import Badge from '@/components/Badge.vue'
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 { useQueryClient } from '@tanstack/vue-query'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useCompleteInfoStore } from '@/features/missionary/store/complete-info'
import EntryCard from '@/features/missionary/components/modals/completeInfo/EntryCard.vue'
import { EDUCATION_DEGREES, SKILL_LEVELS } from '@/features/missionary/constants/complete-info'
import AddSkillModal from '@/features/missionary/components/modals/completeInfo/AddSkillModal.vue'
import AddEducationModal from '@/features/missionary/components/modals/completeInfo/AddEducationModal.vue'
import {
authKeys,
useGetRegisterDataQuery,
useSaveRegisterDataMutation,
} from '@/services/query/auth'
import AddCertificateModal from '@/features/missionary/components/modals/completeInfo/AddCertificateModal.vue'
import AddWorkExperienceModal from '@/features/missionary/components/modals/completeInfo/AddWorkExperienceModal.vue'
defineOptions({ name: 'MissionaryCompleteInfoModal' })
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const store = useCompleteInfoStore()
store.reset()
const { data: registerData } = useGetRegisterDataQuery()
watch(
registerData,
(value) => {
if (value && Object.keys(value).length > 0) store.hydrateFromRegisterData(value)
},
{ immediate: true }
)
const rangeSubtitle = (place, startYear, endYear) => {
const parts = []
if (place) parts.push(place)
if (startYear) parts.push(`از ${startYear} تا ${endYear || 'حالا'}`)
return parts.join(' | ')
}
const educationTitle = (item) => {
const degree = EDUCATION_DEGREES[item.degree] || item.degree || ''
return [degree, item.fieldOfStudy].filter(Boolean).join(': ')
}
const saveMutation = useSaveRegisterDataMutation()
const onSave = async (close) => {
try {
await saveMutation.mutateAsync(store.buildPayload())
await queryClient.invalidateQueries({ queryKey: authKeys.registerData() })
toast.success('اطلاعات با موفقیت ذخیره شد.')
close?.()
} catch {
toast.error('ذخیره اطلاعات با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.complete-info {
display: flex;
flex-direction: column;
gap: 1.25rem;
text-align: start;
padding-block: 0.25rem 0.5rem;
&__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
&__add-btn {
min-width: 8rem;
height: 2.25rem;
padding: 0 1.25rem;
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__entries {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
&__divider {
border-block-end: 1px solid #ddd;
}
&__footer {
display: flex;
justify-content: flex-end;
}
&__submit {
min-width: 14rem;
padding: 0 1.5rem;
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="entry-card">
<div class="entry-card__info">
<p class="entry-card__title">{{ title }}</p>
<p class="entry-card__subtitle">{{ subtitle }}</p>
</div>
<CircleButton tooltip="ویرایش" bg-color="rgba(0, 112, 116, 0.06)" @click="emit('edit')">
<template #icon>
<SvgIcon name="pencil" :size="16" color="#007074" />
</template>
</CircleButton>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
title: { type: String, default: '' },
subtitle: { type: String, default: '' },
})
const emit = defineEmits(['edit'])
</script>
<style lang="scss" scoped>
.entry-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
&__info {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.875rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #9a9a9a;
margin: 0;
}
}
</style>
@@ -0,0 +1,50 @@
export const SKILL_LEVELS = Object.freeze({
beginner: 'مقدماتی',
intermediate: 'متوسط',
advanced: 'پیشرفته',
})
export const EDUCATION_DEGREES = Object.freeze({
associate: 'کاردانی',
bachelor: 'کارشناسی',
master: 'کارشناسی ارشد',
phd: 'دکترا',
})
export const MONTHS = Object.freeze([
{ value: 1, label: 'فروردین' },
{ value: 2, label: 'اردیبهشت' },
{ value: 3, label: 'خرداد' },
{ value: 4, label: 'تیر' },
{ value: 5, label: 'مرداد' },
{ value: 6, label: 'شهریور' },
{ value: 7, label: 'مهر' },
{ value: 8, label: 'آبان' },
{ value: 9, label: 'آذر' },
{ value: 10, label: 'دی' },
{ value: 11, label: 'بهمن' },
{ value: 12, label: 'اسفند' },
])
export const SKILL_SUGGESTIONS = Object.freeze([
'زبان انگلیسی',
'زبان عربی',
'تدریس قرآن',
'تدریس احکام',
'روضه‌خوانی',
'مداحی',
'سخنرانی',
'مشاوره خانواده',
'مشاوره تحصیلی',
'قصه‌گویی',
'نویسندگی',
'خطاطی',
'نقاشی',
'طراحی دکوراسیون',
'طراحی گرافیک',
'تولید محتوا',
'تدوین ویدئو',
'عکاسی',
'مدیریت فضای مجازی',
'کار با کامپیوتر',
])
+7 -1
View File
@@ -1 +1,7 @@
export { default as MissionaryRequestDetailsModal } from './components/modals/MissionaryRequestDetailsModal.vue'
export { default as DispatchDetailsModal } from './components/modals/DispatchDetailsModal.vue'
export { default as MissionaryDispatchItem } from './components/MissionaryDispatchItem.vue'
export { default as MissionaryNarrativeItem } from './components/MissionaryNarrativeItem.vue'
export { default as MissionaryRequestItem } from './components/MissionaryRequestItem.vue'
export { default as MissionaryUserCard } from './components/MissionaryUserCard.vue'
export { default as NarrativeDetailsModal } from './components/modals/NarrativeDetailsModal.vue'
export { default as RequestDetailsModal } from './components/modals/RequestDetailsModal.vue'
@@ -1,73 +1,277 @@
<template>
<div class="missionary-requests-page">
<div class="student-missionary">
<BoxedIconTitleBlock
class="missionary-requests-page__heading"
title="لیست درخواست ها"
desc="در این قسمت میتوانید درخواستهای اعزام خود را مدیریت کنید"
class="student-missionary__heading"
title="سامانه اعزام"
desc="در این قسمت شما میتوانید درخواست های اعزام، تاریخچه اعزام و... را مشاهده کنید."
>
<template #icon>
<SvgIcon name="user-sound" :size="24" color="var(--color-primary)" />
<SvgIcon name="paper-plane-right" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="لیست همه درخواست‌ها" class="missionary-requests-page__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
<MissionaryUserCard
:profile="profile"
@complete-info="onCompleteInfo"
@settings="onProfileSettings"
@add-narrative="onAddNarrative"
/>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #my-requests>
<SkeletonLoaderBlock v-if="requestsLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onRequestDetails"
@change-status="onAskChangeStatus"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onShowDetails"
@change-status="onAskChangeStatus"
/>
</div>
<NoItems v-else title=تاسفیم" desc="درخواستی برای نمایش وجود ندارد." />
<template #history>
<SkeletonLoaderBlock v-if="dispatchesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="dispatches.length > 0">
<MissionaryDispatchItem
v-for="dispatch in dispatches"
:key="dispatch.id"
:dispatch="dispatch"
@show-details="onDispatchDetails"
/>
</div>
<NoItems v-else title=وردی نیست" desc="تاریخچه اعزامی موجود نیست." />
<PaginationBlock :pagination="dispatchesMeta" @update:page="setDispatchesPage" />
</template>
<MissionaryRequestDetailsModal v-if="isModal('MissionaryRequestDetailsModal')" />
<template #narratives>
<SkeletonLoaderBlock v-if="narrativesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="narratives.length > 0">
<MissionaryNarrativeItem
v-for="narrative in narratives"
:key="narrative.id"
:narrative="narrative"
@show-details="onNarrativeDetails"
@edit="onEditNarrative"
@delete="onAskDeleteNarrative"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز روایتی ثبت نکرده‌اید." />
<PaginationBlock :pagination="narrativesMeta" @update:page="setNarrativesPage" />
</template>
</TabsBlock>
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
<DispatchDetailsModal v-if="isModal('DispatchDetailsModal')" />
<NarrativeDetailsModal v-if="isModal('NarrativeDetailsModal')" />
<CreateNarrativeModal v-if="isModal('CreateNarrativeModal')" />
<CompleteInfoModal v-if="isModal('MissionaryCompleteInfoModal')" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import useAuth from '@/composables/useAuth'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import { useRoute, useRouter } from 'vue-router'
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useQueryClient } from '@tanstack/vue-query'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { formatJalaaliDate, formatJalaaliDateTime } from '@/utils/date-utils'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import MissionaryUserCard from '@/features/missionary/components/MissionaryUserCard.vue'
import MissionaryRequestItem from '@/features/missionary/components/MissionaryRequestItem.vue'
import MissionaryRequestDetailsModal from '@/features/missionary/components/modals/MissionaryRequestDetailsModal.vue'
import MissionaryDispatchItem from '@/features/missionary/components/MissionaryDispatchItem.vue'
import RequestDetailsModal from '@/features/missionary/components/modals/RequestDetailsModal.vue'
import MissionaryNarrativeItem from '@/features/missionary/components/MissionaryNarrativeItem.vue'
import DispatchDetailsModal from '@/features/missionary/components/modals/DispatchDetailsModal.vue'
import CreateNarrativeModal from '@/features/missionary/components/modals/CreateNarrativeModal.vue'
import NarrativeDetailsModal from '@/features/missionary/components/modals/NarrativeDetailsModal.vue'
import CompleteInfoModal from '@/features/missionary/components/modals/completeInfo/CompleteInfoModal.vue'
import {
missionaryMemoriesKeys,
useDeleteMissionaryMemoryMutation,
useMissionaryMemoriesListQuery,
} from '@/services/query/missionary-memories'
import {
missionaryRequestsKeys,
useChangeMissionaryRequestStatusMutation,
useMissionaryRequestsListQuery,
} from '@/services/query/missionary-requests'
const queryClient = useQueryClient()
const { user } = useAuth()
const { openModal, isModal } = useModal()
const { data, isLoading } = useMissionaryRequestsListQuery()
const route = useRoute()
const router = useRouter()
const requests = computed(() => data.value ?? [])
const tabs = [
{ name: 'my-requests', label: 'درخواست های من', icon: 'list-bullets' },
{ name: 'history', label: 'تاریخچه اعزام ها', icon: 'paper-plane-right' },
{ name: 'narratives', label: 'روایت های من', icon: 'pencil' },
]
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: missionaryRequestsKeys.all, refetchType: 'all' })
const isValidTab = (name) => tabs.some((tab) => tab.name === name)
const activeTab = ref(isValidTab(route.query.tab) ? route.query.tab : 'my-requests')
watch(activeTab, (tab) => {
if (route.query.tab === tab) return
router.replace({ query: { ...route.query, tab } }).catch(() => {})
})
const requestsFilters = ref({})
const dispatchesFilters = ref({ status: 'accepted' })
const narrativesFilters = ref({})
const { pagination: requestsPagination, setPage: setRequestsPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: dispatchesPagination, setPage: setDispatchesPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: narrativesPagination, setPage: setNarrativesPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: requestsData, isLoading: requestsLoading } = useMissionaryRequestsListQuery(
requestsFilters,
requestsPagination,
{
enabled: () => activeTab.value === 'my-requests',
keepPreviousData: true,
}
)
const { data: dispatchesData, isLoading: dispatchesLoading } = useMissionaryRequestsListQuery(
dispatchesFilters,
dispatchesPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const { data: narrativesData, isLoading: narrativesLoading } = useMissionaryMemoriesListQuery(
narrativesFilters,
narrativesPagination,
{
enabled: () => activeTab.value === 'narratives',
keepPreviousData: true,
}
)
const requestStatusLabel = (status) => MISSIONARY_REQUEST_STATUS[status] || status || '—'
const toRequestView = (request) => ({
...request,
code: request.id,
requestNumber: request.id,
requestDate: formatJalaaliDate(request.requestedDate),
statusLabel: requestStatusLabel(request.status),
phone: request.requesterPhone,
address: request.location,
})
const toDispatchView = (request) => ({
id: request.id,
code: request.id,
title: request.title,
missionName: request.title,
location: request.location,
dispatchDate: formatJalaaliDate(request.requestedDate),
statusLabel: requestStatusLabel(request.status),
requesterName: request.requesterName,
address: request.location,
result: request.description,
})
const NARRATIVE_KIND_META = {
manuscript: { label: 'دست نوشته', tone: 'pink' },
dispatched: { label: 'اعزام شــــده', tone: 'gold' },
}
const toNarrativeView = (narrative) => {
const linkedKind =
narrative.missionaryRequestId || narrative.missionaryRequest ? 'dispatched' : 'manuscript'
const kind = narrative.type || narrative.kind || linkedKind
const kindMeta = NARRATIVE_KIND_META[kind] || NARRATIVE_KIND_META.manuscript
return {
...narrative,
code: narrative.id,
submittedAt: formatJalaaliDateTime(narrative.createdAt),
kind,
kindLabel: kindMeta.label,
kindTone: kindMeta.tone,
body: narrative.description || narrative.content || narrative.body || '',
likes: narrative.likesCount ?? narrative.likes ?? 0,
comments: narrative.commentsCount ?? narrative.comments ?? 0,
}
}
const requests = computed(() =>
(requestsData.value?.data?.items ?? []).map((r) => toRequestView(r))
)
const dispatches = computed(() =>
(dispatchesData.value?.data?.items ?? []).map((r) => toDispatchView(r))
)
const narratives = computed(() =>
(narrativesData.value?.data?.items ?? []).map((n) => toNarrativeView(n))
)
const membershipDays = computed(() => {
const created = new Date(user.value?.createdAt)
if (Number.isNaN(created.getTime())) return '—'
return Math.max(0, Math.floor((Date.now() - created.getTime()) / 86_400_000))
})
const profile = computed(() => ({
fullName: user.value?.name || '—',
city: user.value?.city?.name || '',
avatar: user.value?.avatarUrl || '',
phone: user.value?.phone || '',
rating: user.value?.rating ?? '',
membershipDays: membershipDays.value,
requestsCount: requestsData.value?.meta?.total ?? '',
}))
const requestsMeta = computed(() => ({
page: requestsPagination.value.page,
perPage: requestsPagination.value.perPage,
...requestsData.value?.meta,
}))
const dispatchesMeta = computed(() => ({
page: dispatchesPagination.value.page,
perPage: dispatchesPagination.value.perPage,
...dispatchesData.value?.meta,
}))
const narrativesMeta = computed(() => ({
page: narrativesPagination.value.page,
perPage: narrativesPagination.value.perPage,
...narrativesData.value?.meta,
}))
const onCompleteInfo = () => {
openModal('MissionaryCompleteInfoModal')
}
const onProfileSettings = () => {}
const queryClient = useQueryClient()
const statusMutation = useChangeMissionaryRequestStatusMutation()
const changeStatus = (id, status) =>
statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidate })
const onShowDetails = (request) => {
openModal('MissionaryRequestDetailsModal', { id: request.id })
if (request.status === 'pending') changeStatus(request.id, 'seen')
}
const invalidateRequests = () =>
queryClient.invalidateQueries({ queryKey: missionaryRequestsKeys.all, refetchType: 'all' })
const STATUS_CONFIRMS = {
accepted: { title: 'پذیرش درخواست', verb: 'پذیرش' },
@@ -81,23 +285,53 @@ const onAskChangeStatus = ({ id, status }) => {
openModal('ConfirmModal', {
title: config.title,
message: `آیا از ${config.verb} درخواست <strong>${request?.title || ''}</strong> مطمئن هستید؟`,
onConfirm: () => changeStatus(id, status),
onConfirm: () =>
statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidateRequests }),
})
}
const deleteMemoryMutation = useDeleteMissionaryMemoryMutation()
const invalidateMemories = () =>
queryClient.invalidateQueries({ queryKey: missionaryMemoriesKeys.all, refetchType: 'all' })
const onAddNarrative = () => {
openModal('CreateNarrativeModal')
}
const onEditNarrative = (narrative) => {
openModal('CreateNarrativeModal', { memory: narrative })
}
const onAskDeleteNarrative = (narrative) => {
openModal('ConfirmModal', {
title: 'حذف روایت',
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${narrative.title}</strong> را حذف کنید؟`,
onConfirm: () => deleteMemoryMutation.mutate(narrative.id, { onSuccess: invalidateMemories }),
})
}
const onRequestDetails = (request) => {
openModal('RequestDetailsModal', request)
}
const onDispatchDetails = (dispatch) => {
openModal('DispatchDetailsModal', dispatch)
}
const onNarrativeDetails = (narrative) => {
openModal('NarrativeDetailsModal', narrative)
}
</script>
<style lang="scss" scoped>
.missionary-requests-page {
.student-missionary {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.5rem;
}
&__list-title {
margin-bottom: 0.375rem;
margin-bottom: 0.75rem;
}
}
</style>
@@ -0,0 +1,75 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
const upsert =
(listRef) =>
(entry, index = null) => {
if (index == null || index < 0) {
listRef.value = [...listRef.value, entry]
} else {
listRef.value = listRef.value.map((item, i) => (i === index ? entry : item))
}
}
export const useCompleteInfoStore = defineStore('missionaryCompleteInfo', () => {
const introduction = ref('')
const skills = ref([])
const workExperiences = ref([])
const educationHistory = ref([])
const certificates = ref([])
const reset = () => {
introduction.value = ''
skills.value = []
workExperiences.value = []
educationHistory.value = []
certificates.value = []
}
// Keys mirror what POST /me/register-data persists — flat `{ key: value }`
// where array values hold the section entries.
const hydrateFromRegisterData = (data = {}) => {
if (typeof data.introduction === 'string') introduction.value = data.introduction
if (Array.isArray(data.skills)) skills.value = data.skills
if (Array.isArray(data.workExperiences)) workExperiences.value = data.workExperiences
if (Array.isArray(data.educationHistory)) educationHistory.value = data.educationHistory
if (Array.isArray(data.certificates)) certificates.value = data.certificates
}
const addSkill = (skill) => {
if (skills.value.some((s) => s.title === skill.title)) return
skills.value = [...skills.value, skill]
}
const removeSkill = (title) => {
skills.value = skills.value.filter((s) => s.title !== title)
}
const upsertWorkExperience = upsert(workExperiences)
const upsertEducation = upsert(educationHistory)
const upsertCertificate = upsert(certificates)
const buildPayload = () => ({
introduction: introduction.value,
skills: skills.value,
workExperiences: workExperiences.value,
educationHistory: educationHistory.value,
certificates: certificates.value,
})
return {
introduction,
skills,
workExperiences,
educationHistory,
certificates,
reset,
hydrateFromRegisterData,
addSkill,
removeSkill,
upsertWorkExperience,
upsertEducation,
upsertCertificate,
buildPayload,
}
})
@@ -1,257 +0,0 @@
<template>
<div class="mreq" @click="emit('show-details', request)">
<div class="mreq__heading">
<span class="mreq__code">{{ request.code }}</span>
<div class="mreq__title-wrap">
<p class="mreq__title">{{ request.title }}</p>
<p class="mreq__subtitle">شماره درخواست : {{ request.requestNumber }}</p>
</div>
</div>
<div class="mreq__meta">
<div class="mreq__pill mreq__pill--date">
<span class="mreq__pill-label">تاریخ درخواست :</span>
<span class="mreq__pill-value">{{ request.requestDate }}</span>
</div>
<div class="mreq__pill mreq__pill--name">
<span class="mreq__pill-label">نام درخواست کننده :</span>
<span class="mreq__pill-value">{{ request.requesterName }}</span>
</div>
<div class="mreq__pill mreq__pill--status" :class="`mreq__pill--${tone}`">
<span class="mreq__dot" />
<span class="mreq__pill-value mreq__pill-value--status">{{ request.statusLabel }}</span>
</div>
</div>
<div class="mreq__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mreq__btn"
@click="emit('show-details', request)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const TONE_MAP = {
rejected: 'danger',
approved: 'success',
pending: 'warning',
cancelled: 'neutral',
}
const props = defineProps({
request: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.request.status] || 'neutral')
</script>
<style lang="scss" scoped>
.mreq {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.125rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
flex-wrap: nowrap;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__pill-value--status {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.65rem;
}
&__dot {
width: 0.32rem;
height: 0.32rem;
border-radius: 9999px;
flex-shrink: 0;
}
&__pill--status {
padding: 0.3rem 0.75rem;
}
&__pill--danger {
background: rgba(243, 102, 117, 6%);
.mreq__pill-value {
color: #cc2831;
}
.mreq__dot {
background: #cc2831;
}
}
&__pill--success {
background: rgba(0, 112, 116, 6%);
.mreq__pill-value {
color: #007074;
}
.mreq__dot {
background: #007074;
}
}
&__pill--warning {
background: rgba(182, 132, 45, 7%);
.mreq__pill-value {
color: #b6842d;
}
.mreq__dot {
background: #b6842d;
}
}
&__pill--neutral {
background: rgba(107, 107, 107, 6%);
.mreq__pill-value {
color: #535353;
}
.mreq__dot {
background: #989898;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 8rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
-7
View File
@@ -1,7 +0,0 @@
export { default as DispatchDetailsModal } from './components/modals/DispatchDetailsModal.vue'
export { default as MissionaryDispatchItem } from './components/MissionaryDispatchItem.vue'
export { default as MissionaryNarrativeItem } from './components/MissionaryNarrativeItem.vue'
export { default as MissionaryRequestItem } from './components/MissionaryRequestItem.vue'
export { default as MissionaryUserCard } from './components/MissionaryUserCard.vue'
export { default as NarrativeDetailsModal } from './components/modals/NarrativeDetailsModal.vue'
export { default as RequestDetailsModal } from './components/modals/RequestDetailsModal.vue'
@@ -1,203 +0,0 @@
<template>
<div class="student-missionary">
<BoxedIconTitleBlock
class="student-missionary__heading"
title="سامانه اعزام"
desc="در این قسمت شما میتوانید درخواست های اعزام، تاریخچه اعزام و... را مشاهده کنید."
>
<template #icon>
<SvgIcon name="paper-plane-right" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="profileLoading && !profile" :rows="1" :cols-per-row="1" />
<MissionaryUserCard
v-else
:profile="profile || {}"
@complete-info="onCompleteInfo"
@settings="onProfileSettings"
/>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #my-requests>
<SkeletonLoaderBlock v-if="requestsLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onRequestDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
</template>
<template #history>
<SkeletonLoaderBlock v-if="dispatchesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="dispatches.length > 0">
<MissionaryDispatchItem
v-for="dispatch in dispatches"
:key="dispatch.id"
:dispatch="dispatch"
@show-details="onDispatchDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="تاریخچه اعزامی موجود نیست." />
<PaginationBlock :pagination="dispatchesMeta" @update:page="setDispatchesPage" />
</template>
<template #narratives>
<SkeletonLoaderBlock v-if="narrativesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="narratives.length > 0">
<MissionaryNarrativeItem
v-for="narrative in narratives"
:key="narrative.id"
:narrative="narrative"
@show-details="onNarrativeDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز روایتی ثبت نکرده‌اید." />
<PaginationBlock :pagination="narrativesMeta" @update:page="setNarrativesPage" />
</template>
</TabsBlock>
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
<DispatchDetailsModal v-if="isModal('DispatchDetailsModal')" />
<NarrativeDetailsModal v-if="isModal('NarrativeDetailsModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.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 MissionaryUserCard from '@/features/student/missionary/components/MissionaryUserCard.vue'
import MissionaryRequestItem from '@/features/student/missionary/components/MissionaryRequestItem.vue'
import MissionaryDispatchItem from '@/features/student/missionary/components/MissionaryDispatchItem.vue'
import RequestDetailsModal from '@/features/student/missionary/components/modals/RequestDetailsModal.vue'
import MissionaryNarrativeItem from '@/features/student/missionary/components/MissionaryNarrativeItem.vue'
import DispatchDetailsModal from '@/features/student/missionary/components/modals/DispatchDetailsModal.vue'
import NarrativeDetailsModal from '@/features/student/missionary/components/modals/NarrativeDetailsModal.vue'
import {
useMissionaryProfileQuery,
useMissionaryDispatchesQuery,
useMissionaryNarrativesQuery,
useMissionaryRequestsQuery,
} from '@/services/query/student-missionary'
const router = useRouter()
const { openModal, isModal } = useModal()
const tabs = [
{ name: 'my-requests', label: 'درخواست های من', icon: 'list-bullets' },
{ name: 'history', label: 'تاریخچه اعزام ها', icon: 'paper-plane-right' },
{ name: 'narratives', label: 'روایت های من', icon: 'pencil' },
]
const activeTab = ref('my-requests')
const { data: profile, isLoading: profileLoading } = useMissionaryProfileQuery()
const requestsFilters = ref({})
const dispatchesFilters = ref({})
const narrativesFilters = ref({})
const { pagination: requestsPagination, setPage: setRequestsPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: dispatchesPagination, setPage: setDispatchesPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: narrativesPagination, setPage: setNarrativesPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: requestsData, isLoading: requestsLoading } = useMissionaryRequestsQuery(
requestsFilters,
requestsPagination,
{
enabled: () => activeTab.value === 'my-requests',
keepPreviousData: true,
}
)
const { data: dispatchesData, isLoading: dispatchesLoading } = useMissionaryDispatchesQuery(
dispatchesFilters,
dispatchesPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const { data: narrativesData, isLoading: narrativesLoading } = useMissionaryNarrativesQuery(
narrativesFilters,
narrativesPagination,
{
enabled: () => activeTab.value === 'narratives',
keepPreviousData: true,
}
)
const requests = computed(() => requestsData.value?.data ?? [])
const dispatches = computed(() => dispatchesData.value?.data ?? [])
const narratives = computed(() => narrativesData.value?.data ?? [])
const requestsMeta = computed(() => ({
page: requestsPagination.value.page,
perPage: requestsPagination.value.perPage,
...requestsData.value?.meta,
}))
const dispatchesMeta = computed(() => ({
page: dispatchesPagination.value.page,
perPage: dispatchesPagination.value.perPage,
...dispatchesData.value?.meta,
}))
const narrativesMeta = computed(() => ({
page: narrativesPagination.value.page,
perPage: narrativesPagination.value.perPage,
...narrativesData.value?.meta,
}))
const onCompleteInfo = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onProfileSettings = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onRequestDetails = (request) => {
openModal('RequestDetailsModal', request)
}
const onDispatchDetails = (dispatch) => {
openModal('DispatchDetailsModal', dispatch)
}
const onNarrativeDetails = (narrative) => {
openModal('NarrativeDetailsModal', narrative)
}
</script>
<style lang="scss" scoped>
.student-missionary {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
-11
View File
@@ -109,17 +109,6 @@ export default [
subtitle: 'مهارت‌ها و دوره‌های خود را دنبال کنید.',
},
},
{
path: '/missionary',
name: 'student-missionary',
component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'),
meta: {
layout: 'student',
role: 'student',
title: 'سیستم مدیریت اعزام',
subtitle: 'ساده‌ترین راه برای برنامه‌ریزی و پایش مأموریت‌ها',
},
},
{
path: '/user/services',
name: 'student-services',
@@ -31,7 +31,10 @@
/>
</div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio">
<VoiceRecorder :model-value="session.audioUrl" disabled />
<VoiceRecorder
model-value="http://api.tripwisedaily.ir/storage/pending/3/test2-AmQiegjc.mp3?expires=1783677165&signature=38104395400100f1bd74594bfc64ac462c14634a8c2bc4541c988766a08ea2bd"
disabled
/>
</div>
<div v-else-if="contentKind === 'text'" class="ssd__text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
+6
View File
@@ -156,6 +156,12 @@ const menuItems = computed(() => [
to: { name: 'admin-consultations' },
active: route.name === 'admin-consultations',
},
{
title: 'مدیریت مبلغین',
icon: 'user-sound',
to: { name: 'admin-missionary-requests' },
active: route.name === 'admin-missionary-requests',
},
])
</script>
+1 -1
View File
@@ -59,7 +59,7 @@ const pageSubtitle = computed(() => route.meta?.subtitle || '')
const menuItems = computed(() => [
{
title: 'لیست درخواست ها',
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
-6
View File
@@ -78,12 +78,6 @@ const menuItems = computed(() => [
'student-course-details',
].includes(route.name),
},
{
title: 'سامانه اعزام',
icon: 'user-sound',
to: { name: 'student-missionary' },
active: route.name === 'student-missionary',
},
{
title: 'خدمات',
icon: 'folder-simple-star',
@@ -0,0 +1,11 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiListAdminMissionaryRequests = (params) =>
http.get(endpoints.listAdminMissionaryRequests, { params })
export const apiShowAdminMissionaryRequest = (id) =>
http.get(buildUrl(endpoints.showAdminMissionaryRequest, { id }))
export const apiChangeAdminMissionaryRequestStatus = (id, payload) =>
http.patch(buildUrl(endpoints.changeAdminMissionaryRequestStatus, { id }), payload)
+9 -5
View File
@@ -35,11 +35,6 @@ export const endpoints = {
showHomework: '/homeworks/:id',
startExam: '/exams/:examId/start',
getMissionaryProfile: '/student/missionary/profile',
getMissionaryRequests: '/student/missionary/requests',
getMissionaryDispatches: '/student/missionary/dispatches',
getMissionaryNarratives: '/student/missionary/narratives',
// Unified student tickets — handles services (type=service), consultants
// (type=advise) and inbox (type=ticket) via the same endpoint with a filter.
getStudentTickets: '/student/tickets',
@@ -168,6 +163,15 @@ export const endpoints = {
listMissionaryRequests: '/missionary/requests',
showMissionaryRequest: '/missionary/requests/:id',
changeMissionaryRequestStatus: '/missionary/requests/:id/status',
listMissionaryMemories: '/missionary/memories',
createMissionaryMemory: '/missionary/memories',
updateMissionaryMemory: '/missionary/memories/:id',
deleteMissionaryMemory: '/missionary/memories/:id',
listAdminMissionaryRequests: '/admin/missionary-requests',
showAdminMissionaryRequest: '/admin/missionary-requests/:id',
changeAdminMissionaryRequestStatus: '/admin/missionary-requests/:id/status',
}
export const buildUrl = (template, params = {}) =>
+14
View File
@@ -0,0 +1,14 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiListMissionaryMemories = (params) =>
http.get(endpoints.listMissionaryMemories, { params })
export const apiCreateMissionaryMemory = (payload) =>
http.post(endpoints.createMissionaryMemory, payload)
export const apiUpdateMissionaryMemory = (id, payload) =>
http.patch(buildUrl(endpoints.updateMissionaryMemory, { id }), payload)
export const apiDeleteMissionaryMemory = (id) =>
http.delete(buildUrl(endpoints.deleteMissionaryMemory, { id }))
-13
View File
@@ -1,13 +0,0 @@
import { http } from '@/services/api/http'
import { endpoints } from '@/services/api/endpoints'
export const apiGetMissionaryProfile = () => http.get(endpoints.getMissionaryProfile)
export const apiGetMissionaryRequests = (params) =>
http.get(endpoints.getMissionaryRequests, { params })
export const apiGetMissionaryDispatches = (params) =>
http.get(endpoints.getMissionaryDispatches, { params })
export const apiGetMissionaryNarratives = (params) =>
http.get(endpoints.getMissionaryNarratives, { params })
@@ -0,0 +1,40 @@
const SAMPLE_BODY = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد.`
export const missionaryMemories = [
{
id: 1,
missionaryId: 8,
title: 'سفر به شهر زیبای مشهد مقدس، شهر علم و ادب',
description: SAMPLE_BODY,
media: [],
createdAt: '2026-06-10T08:30:00+00:00',
},
{
id: 2,
missionaryId: 8,
title: 'سفر به اصفهان نصف جهان',
description: SAMPLE_BODY,
media: [
{
id: 63,
collectionName: 'memory',
fileName: 'trip-photo.png',
mimeType: 'image/png',
fileSize: 20_727,
url: '',
downloadUrl: '',
},
],
missionaryRequestId: 3,
missionaryRequest: { id: 3, title: 'جلسه پرسش و پاسخ اعتقادی دانشگاه' },
createdAt: '2026-06-18T09:15:00+00:00',
},
{
id: 3,
missionaryId: 8,
title: 'روایت جلسات هفتگی قم',
description: SAMPLE_BODY,
media: [],
createdAt: '2026-06-25T14:45:00+00:00',
},
]
@@ -1,157 +0,0 @@
export const missionaryProfile = {
id: 'me',
fullName: 'آقای علیرضا احمدی',
city: 'قم، ایران',
level: 'پیشرفته',
membershipDays: 23,
rating: 4.8,
requestsCount: 451,
avatar: '',
}
const STATUS_LABELS = {
pending: 'در انتظار بررسی',
approved: 'تایید شده',
rejected: 'رد شده توسط موسسه',
cancelled: 'لغو شده',
}
const REQUEST_DESCRIPTION = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.`
export const missionaryRequests = [
{
id: 'r-1',
code: 1,
title: 'درخواست شماره یک',
requestNumber: 387,
requesterName: 'حسین رضایی',
requestDate: '1404/05/12',
status: 'rejected',
statusLabel: STATUS_LABELS.rejected,
phone: '0912 152 1413',
postalCode: '1232146890345',
address:
'قم، سالاریه، میدان پیچک، به سمت فرعی اول، میدان الهیه، ساختمان الیزیوم، طبقه منفی 1 واحد 152',
description: REQUEST_DESCRIPTION,
},
{
id: 'r-2',
code: 2,
title: 'درخواست شماره دو',
requestNumber: 401,
requesterName: 'مریم اکبری',
requestDate: '1404/06/04',
status: 'pending',
statusLabel: STATUS_LABELS.pending,
phone: '0912 884 2210',
postalCode: '8136987710332',
address: 'اصفهان، خیابان چهارباغ بالا، کوچه گلستان، پلاک 12',
description: REQUEST_DESCRIPTION,
},
{
id: 'r-3',
code: 3,
title: 'درخواست شماره سه',
requestNumber: 412,
requesterName: 'فاطمه حسینی',
requestDate: '1404/06/18',
status: 'approved',
statusLabel: STATUS_LABELS.approved,
phone: '0935 410 0091',
postalCode: '9176548132201',
address: 'مشهد، خیابان امام رضا، کوچه 14، پلاک 5',
description: REQUEST_DESCRIPTION,
},
]
const DISPATCH_RESULT = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.`
export const missionaryDispatches = [
{
id: 'd-1',
code: 1_547_890,
title: 'اعزام شماره یک',
missionName: 'سفر به مشهد مقدس',
location: 'زنجان، ایران',
dispatchDate: '1404/12/12',
statusLabel: 'انجام شده',
requesterName: 'حسین مولایی',
address:
'قم، سالاریه، میدان پیچک، به سمت فرعی اول، میدان الهیه، ساختمان الیزیوم، طبقه منفی 1 واحد 152',
result: DISPATCH_RESULT,
},
{
id: 'd-2',
code: 1_547_891,
title: 'اعزام شماره دو',
missionName: 'سفر به اصفهان',
location: 'اصفهان، ایران',
dispatchDate: '1404/04/09',
statusLabel: 'در حال انجام',
requesterName: 'مریم اکبری',
address: 'اصفهان، خیابان چهارباغ، کوچه گلستان، پلاک 12',
result: DISPATCH_RESULT,
},
{
id: 'd-3',
code: 1_547_892,
title: 'اعزام شماره سه',
missionName: 'سفر به مشهد مقدس',
location: 'مشهد، ایران',
dispatchDate: '1404/03/21',
statusLabel: 'انجام شده',
requesterName: 'فاطمه حسینی',
address: 'مشهد، خیابان امام رضا، کوچه 14، پلاک 5',
result: DISPATCH_RESULT,
},
]
const NARRATIVE_KIND = {
manuscript: { key: 'manuscript', label: 'دست نوشته', tone: 'pink' },
dispatched: { key: 'dispatched', label: 'اعزام شــــده', tone: 'gold' },
}
const SAMPLE_BODY = `لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و آینده شناخت فراوان جامعه و متخصصان را می طلبد.
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد.
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.`
export const missionaryNarratives = [
{
id: 'n-1',
code: 1,
title: 'سفر به شهر زیبای مشهد مقدس، شهر علم و ادب',
submittedAt: '۱۴۰۰/۷/۱۹, ۸:۳۰',
kind: NARRATIVE_KIND.manuscript.key,
kindLabel: NARRATIVE_KIND.manuscript.label,
kindTone: NARRATIVE_KIND.manuscript.tone,
body: SAMPLE_BODY,
likes: 12,
comments: 56,
},
{
id: 'n-2',
code: 2,
title: 'سفر به اصفهان نصف جهان',
submittedAt: '۱۴۰۰/۷/۱۹, ۸:۳۰',
kind: NARRATIVE_KIND.dispatched.key,
kindLabel: NARRATIVE_KIND.dispatched.label,
kindTone: NARRATIVE_KIND.dispatched.tone,
body: SAMPLE_BODY,
likes: 24,
comments: 9,
},
{
id: 'n-3',
code: 3,
title: 'روایت شماره سه',
submittedAt: '۱۴۰۰/۸/۲, ۹:۱۵',
kind: NARRATIVE_KIND.manuscript.key,
kindLabel: NARRATIVE_KIND.manuscript.label,
kindTone: NARRATIVE_KIND.manuscript.tone,
body: SAMPLE_BODY,
likes: 3,
comments: 1,
},
]
@@ -1,13 +1,24 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { findOrThrow, updateById } from '@/services/mock/helpers'
import { missionaryRequests } from '@/services/mock/fixtures/missionary-requests'
import { missionaryMemories } from '@/services/mock/fixtures/missionary-memories'
import {
filterItems,
findOrThrow,
isoNow,
makeId,
paginate,
updateById,
} from '@/services/mock/helpers'
register('GET', endpoints.listMissionaryRequests, () => ({
success: true,
message: 'OK',
data: missionaryRequests,
}))
const paginatedItems = (items, query) => {
const { data, meta } = paginate(items, query)
return { success: true, message: 'OK', data: { items: data, meta } }
}
register('GET', endpoints.listMissionaryRequests, ({ query }) =>
paginatedItems(filterItems(missionaryRequests, query, { status: 'eq' }), query)
)
register('GET', endpoints.showMissionaryRequest, ({ params }) => ({
success: true,
@@ -20,3 +31,68 @@ register('PATCH', endpoints.changeMissionaryRequestStatus, ({ params, data }) =>
message: 'Status updated.',
data: updateById(missionaryRequests, params.id, { status: data.status }),
}))
register('GET', endpoints.listMissionaryMemories, ({ query }) =>
paginatedItems(missionaryMemories, query)
)
register('POST', endpoints.createMissionaryMemory, ({ data }) => {
const memory = {
id: makeId(),
missionaryId: 8,
title: data.title || '',
description: data.description || '',
missionaryRequestId: data.missionaryRequestId ?? null,
media: (data.mediaIds ?? []).map((id) => ({
id,
collectionName: 'memory',
fileName: `memory-${id}`,
mimeType: 'image/png',
fileSize: 0,
url: '',
downloadUrl: '',
})),
createdAt: isoNow(),
}
missionaryMemories.unshift(memory)
return { success: true, message: 'Memory created.', data: memory }
})
register('PATCH', endpoints.updateMissionaryMemory, ({ params, data }) => {
const memory = findOrThrow(missionaryMemories, params.id)
if (data.title != null) memory.title = data.title
if (data.description != null) memory.description = data.description
const appended = (data.mediaIds ?? []).map((id) => ({
id,
collectionName: 'memory',
fileName: `memory-${id}`,
mimeType: 'image/png',
fileSize: 0,
url: '',
downloadUrl: '',
}))
memory.media = [...(memory.media ?? []), ...appended]
return { success: true, message: 'Memory updated.', data: memory }
})
register('DELETE', endpoints.deleteMissionaryMemory, ({ params }) => {
const index = missionaryMemories.findIndex((m) => String(m.id) === String(params.id))
if (index >= 0) missionaryMemories.splice(index, 1)
return { success: true, message: 'Memory deleted.', data: null }
})
register('GET', endpoints.listAdminMissionaryRequests, ({ query }) =>
paginatedItems(filterItems(missionaryRequests, query, { status: 'eq' }), query)
)
register('GET', endpoints.showAdminMissionaryRequest, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(missionaryRequests, params.id),
}))
register('PATCH', endpoints.changeAdminMissionaryRequestStatus, ({ params, data }) => ({
success: true,
message: 'Status updated.',
data: updateById(missionaryRequests, params.id, { status: data.status }),
}))
@@ -1,21 +0,0 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
missionaryDispatches,
missionaryNarratives,
missionaryProfile,
missionaryRequests,
} from '@/services/mock/fixtures/student-missionary'
register('GET', endpoints.getMissionaryProfile, () => ({ data: missionaryProfile }))
register('GET', endpoints.getMissionaryRequests, ({ query }) => paginate(missionaryRequests, query))
register('GET', endpoints.getMissionaryDispatches, ({ query }) =>
paginate(missionaryDispatches, query)
)
register('GET', endpoints.getMissionaryNarratives, ({ query }) =>
paginate(missionaryNarratives, query)
)
@@ -0,0 +1,28 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiChangeAdminMissionaryRequestStatus,
apiListAdminMissionaryRequests,
} from '@/services/api/admin-missionary-requests'
export const adminMissionaryRequestsKeys = {
all: ['admin', 'missionary-requests'],
list: (filters, pagination) => ['admin', 'missionary-requests', 'list', filters, pagination],
}
export const useAdminMissionaryRequestsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['admin', 'missionary-requests', 'list', filtersRef, paginationRef],
queryFn: () =>
apiListAdminMissionaryRequests({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
export const useChangeAdminMissionaryRequestStatusMutation = () =>
useMutation({
mutationFn: ({ id, payload }) => apiChangeAdminMissionaryRequestStatus(id, payload),
})
+34
View File
@@ -0,0 +1,34 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiCreateMissionaryMemory,
apiDeleteMissionaryMemory,
apiListMissionaryMemories,
apiUpdateMissionaryMemory,
} from '@/services/api/missionary-memories'
export const missionaryMemoriesKeys = {
all: ['missionary', 'memories'],
list: (filters, pagination) => ['missionary', 'memories', 'list', filters, pagination],
}
export const useMissionaryMemoriesListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['missionary', 'memories', 'list', filtersRef, paginationRef],
queryFn: () =>
apiListMissionaryMemories({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
export const useCreateMissionaryMemoryMutation = () =>
useMutation({ mutationFn: apiCreateMissionaryMemory })
export const useUpdateMissionaryMemoryMutation = () =>
useMutation({ mutationFn: ({ id, payload }) => apiUpdateMissionaryMemory(id, payload) })
export const useDeleteMissionaryMemoryMutation = () =>
useMutation({ mutationFn: apiDeleteMissionaryMemory })
+10 -5
View File
@@ -1,3 +1,4 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiChangeMissionaryRequestStatus,
@@ -7,15 +8,19 @@ import {
export const missionaryRequestsKeys = {
all: ['missionary', 'requests'],
list: () => ['missionary', 'requests', 'list'],
list: (filters, pagination) => ['missionary', 'requests', 'list', filters, pagination],
detail: (id) => ['missionary', 'requests', 'detail', id],
}
export const useMissionaryRequestsListQuery = (options = {}) =>
export const useMissionaryRequestsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: missionaryRequestsKeys.list(),
queryFn: () => apiListMissionaryRequests(),
select: (response) => response?.data ?? [],
queryKey: ['missionary', 'requests', 'list', filtersRef, paginationRef],
queryFn: () =>
apiListMissionaryRequests({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
-56
View File
@@ -1,56 +0,0 @@
import { useQuery } from '@tanstack/vue-query'
import { cleanFilters } from '@/utils/clean-filters'
import {
apiGetMissionaryDispatches,
apiGetMissionaryNarratives,
apiGetMissionaryProfile,
apiGetMissionaryRequests,
} from '@/services/api/student-missionary'
export const missionaryKeys = {
profile: ['student', 'missionary', 'profile'],
requests: (filters, pagination) => ['student', 'missionary', 'requests', filters, pagination],
dispatches: (filters, pagination) => ['student', 'missionary', 'dispatches', filters, pagination],
narratives: (filters, pagination) => ['student', 'missionary', 'narratives', filters, pagination],
}
export const useMissionaryProfileQuery = (options = {}) =>
useQuery({
queryKey: missionaryKeys.profile,
queryFn: () => apiGetMissionaryProfile(),
select: (response) => response?.data ?? response,
...options,
})
export const useMissionaryRequestsQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'requests', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryRequests({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useMissionaryDispatchesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'dispatches', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryDispatches({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})
export const useMissionaryNarrativesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'missionary', 'narratives', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryNarratives({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
...options,
})