@@ -17,8 +17,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-for="tab in tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
v-if="tab.hasButton"
|
||||
:key="`btn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--desktop"
|
||||
>
|
||||
@@ -33,10 +34,12 @@
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-for="tab in tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
v-if="tab.hasButton"
|
||||
:key="`mbtn-${tab.name}`"
|
||||
class="tabs-block__action tabs-block__action--mobile"
|
||||
>
|
||||
@@ -51,6 +54,7 @@
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tabs-block__content">
|
||||
<slot :name="activeTab" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: '' },
|
||||
})
|
||||
|
||||
|
||||
+2
-8
@@ -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
-18
@@ -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 {
|
||||
&__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>
|
||||
|
||||
+12
-19
@@ -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>
|
||||
+24
@@ -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;
|
||||
+1
-1
@@ -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([
|
||||
'زبان انگلیسی',
|
||||
'زبان عربی',
|
||||
'تدریس قرآن',
|
||||
'تدریس احکام',
|
||||
'روضهخوانی',
|
||||
'مداحی',
|
||||
'سخنرانی',
|
||||
'مشاوره خانواده',
|
||||
'مشاوره تحصیلی',
|
||||
'قصهگویی',
|
||||
'نویسندگی',
|
||||
'خطاطی',
|
||||
'نقاشی',
|
||||
'طراحی دکوراسیون',
|
||||
'طراحی گرافیک',
|
||||
'تولید محتوا',
|
||||
'تدوین ویدئو',
|
||||
'عکاسی',
|
||||
'مدیریت فضای مجازی',
|
||||
'کار با کامپیوتر',
|
||||
])
|
||||
@@ -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" />
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
<MissionaryUserCard
|
||||
:profile="profile"
|
||||
@complete-info="onCompleteInfo"
|
||||
@settings="onProfileSettings"
|
||||
@add-narrative="onAddNarrative"
|
||||
/>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
|
||||
<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="onShowDetails"
|
||||
@show-details="onRequestDetails"
|
||||
@change-status="onAskChangeStatus"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="درخواستی برای نمایش وجود ندارد." />
|
||||
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکردهاید." />
|
||||
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
|
||||
</template>
|
||||
|
||||
<MissionaryRequestDetailsModal v-if="isModal('MissionaryRequestDetailsModal')" />
|
||||
<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"
|
||||
@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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = {}) =>
|
||||
|
||||
@@ -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 }))
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user