@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="mdis" @click="emit('show-details', dispatch)">
|
||||
<div class="mdis__heading">
|
||||
<span class="mdis__code">{{ dispatch.code }}</span>
|
||||
<div class="mdis__title-wrap">
|
||||
<p class="mdis__title">{{ dispatch.title }}</p>
|
||||
<div class="mdis__location">
|
||||
<SvgIcon name="paper-plane-right" :size="11" color="#989898" />
|
||||
<span>{{ dispatch.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mdis__meta">
|
||||
<div class="mdis__pill">
|
||||
<span class="mdis__pill-label">تاریخ اعزام :</span>
|
||||
<span class="mdis__pill-value">{{ dispatch.dispatchDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mdis__actions" @click.stop>
|
||||
<BaseButton
|
||||
text="جزئیات درخواست"
|
||||
custom-class="mdis__btn"
|
||||
@click="emit('show-details', dispatch)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
dispatch: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['show-details'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mdis {
|
||||
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: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
color: #989898;
|
||||
}
|
||||
|
||||
&__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: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
min-width: 8rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="mnar" @click="emit('show-details', narrative)">
|
||||
<div class="mnar__heading">
|
||||
<span class="mnar__code">{{ narrative.code }}</span>
|
||||
<div class="mnar__title-wrap">
|
||||
<p class="mnar__title">{{ narrative.title }}</p>
|
||||
<p class="mnar__subtitle">تاریخ ثبت: {{ narrative.submittedAt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mnar__meta">
|
||||
<div class="mnar__pill" :class="`mnar__pill--${narrative.kindTone}`">
|
||||
<span class="mnar__pill-label">روایت :</span>
|
||||
<span class="mnar__pill-value">{{ narrative.kindLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mnar__actions" @click.stop>
|
||||
<CircleButton
|
||||
tooltip="ویرایش روایت"
|
||||
bg-color="rgba(107, 107, 107, 0.04)"
|
||||
@click="emit('edit', narrative)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="18" color="#535353" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="حذف روایت"
|
||||
bg-color="rgba(243, 102, 117, 0.06)"
|
||||
@click="emit('delete', narrative)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
|
||||
defineProps({
|
||||
narrative: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['show-details', 'edit', 'delete'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mnar {
|
||||
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;
|
||||
|
||||
@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-start;
|
||||
text-align: end;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.55rem;
|
||||
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: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__pill--pink {
|
||||
background: rgba(243, 102, 117, 7%);
|
||||
|
||||
.mnar__pill-label,
|
||||
.mnar__pill-value {
|
||||
color: #f55d6d;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill--gold {
|
||||
background: rgba(116, 102, 0, 4%);
|
||||
|
||||
.mnar__pill-label,
|
||||
.mnar__pill-value {
|
||||
color: #b6842d;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +1,56 @@
|
||||
<template>
|
||||
<div class="missionary-request" @click="emit('show-details', request)">
|
||||
<div class="missionary-request__heading">
|
||||
<p class="missionary-request__title">{{ request.title }}</p>
|
||||
<p class="missionary-request__subtitle">{{ request.requesterName }}</p>
|
||||
<div class="mreq" @click="emit('show-details', request)">
|
||||
<div class="mreq__heading">
|
||||
<span class="mreq__code">{{ request.code }}</span>
|
||||
<div class="mreq__title-wrap">
|
||||
<p class="mreq__title">{{ request.title }}</p>
|
||||
<p class="mreq__subtitle">شماره درخواست : {{ request.requestNumber }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="missionary-request__meta">
|
||||
<div class="missionary-request__pill">
|
||||
<span class="missionary-request__pill-label">تاریخ درخواست :</span>
|
||||
<span class="missionary-request__pill-value">
|
||||
{{ formatJalaaliDate(request.requestedDate) || '—' }}
|
||||
</span>
|
||||
<div class="mreq__meta">
|
||||
<div class="mreq__pill mreq__pill--date">
|
||||
<span class="mreq__pill-label">تاریخ درخواست :</span>
|
||||
<span class="mreq__pill-value">{{ request.requestDate }}</span>
|
||||
</div>
|
||||
<div class="missionary-request__pill">
|
||||
<span class="missionary-request__pill-label">مکان :</span>
|
||||
<span class="missionary-request__pill-value missionary-request__pill-value--fa">
|
||||
{{ request.location || '—' }}
|
||||
</span>
|
||||
<div class="mreq__pill mreq__pill--name">
|
||||
<span class="mreq__pill-label">نام درخواست کننده :</span>
|
||||
<span class="mreq__pill-value">{{ request.requesterName }}</span>
|
||||
</div>
|
||||
<div class="mreq__pill mreq__pill--status" :class="`mreq__pill--${tone}`">
|
||||
<span class="mreq__dot" />
|
||||
<span class="mreq__pill-value mreq__pill-value--status">{{ request.statusLabel }}</span>
|
||||
</div>
|
||||
<Badge :variant="statusVariant" :dot="true" :value="statusLabel" />
|
||||
</div>
|
||||
|
||||
<div class="missionary-request__actions" @click.stop>
|
||||
<div class="mreq__actions" @click.stop>
|
||||
<CircleButton
|
||||
v-if="request.status !== 'accepted'"
|
||||
v-if="canAccept"
|
||||
tooltip="پذیرش درخواست"
|
||||
bg-color="rgba(0, 154, 18, 0.08)"
|
||||
size="2.5rem"
|
||||
bg-color="rgba(0, 112, 116, 0.08)"
|
||||
@click="emit('change-status', { id: request.id, status: 'accepted' })"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="check" :size="20" color="var(--color-secondary)" />
|
||||
<SvgIcon name="check" :size="18" color="#007074" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
v-if="request.status !== 'rejected'"
|
||||
v-if="canReject"
|
||||
tooltip="رد درخواست"
|
||||
bg-color="rgba(204, 40, 49, 0.08)"
|
||||
size="2.5rem"
|
||||
bg-color="rgba(243, 102, 117, 0.06)"
|
||||
@click="emit('change-status', { id: request.id, status: 'rejected' })"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" color="var(--color-error)" />
|
||||
<SvgIcon name="close" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<BaseButton
|
||||
text="جزئیات درخواست"
|
||||
custom-class="missionary-request__details-btn"
|
||||
custom-class="mreq__btn"
|
||||
@click="emit('show-details', request)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="caret-left" :size="16" color="#fff" />
|
||||
<SvgIcon name="arrow-left" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -59,39 +59,41 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
|
||||
const STATUS_VARIANTS = {
|
||||
const TONE_MAP = {
|
||||
pending: 'warning',
|
||||
seen: 'info',
|
||||
seen: 'neutral',
|
||||
accepted: 'success',
|
||||
rejected: 'danger',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
request: { type: Object, required: true },
|
||||
alwaysActionable: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['show-details', 'change-status'])
|
||||
|
||||
const statusLabel = computed(() => MISSIONARY_REQUEST_STATUS[props.request.status] || '—')
|
||||
const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'neutral')
|
||||
const tone = computed(() => TONE_MAP[props.request.status] || 'neutral')
|
||||
|
||||
const canAct = computed(() => props.alwaysActionable || props.request.status === 'pending')
|
||||
const canAccept = computed(() => canAct.value && props.request.status !== 'accepted')
|
||||
const canReject = computed(() => canAct.value && props.request.status !== 'rejected')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.missionary-request {
|
||||
.mreq {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.625rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
@@ -103,15 +105,26 @@ const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'n
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1 1 33%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
text-align: end;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@@ -124,58 +137,136 @@ const statusVariant = computed(() => STATUS_VARIANTS[props.request.status] || 'n
|
||||
|
||||
&__subtitle {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
color: #848484;
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 800;
|
||||
font-size: 2.75rem;
|
||||
line-height: 1;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 40%;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex: 1;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(107, 107, 107, 4%);
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
color: #848484;
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
|
||||
&--fa {
|
||||
font-family: var(--font-family-fa);
|
||||
&__pill-value--status {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 0.32rem;
|
||||
height: 0.32rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__pill--status {
|
||||
padding: 0.3rem 0.75rem;
|
||||
}
|
||||
|
||||
&__pill--danger {
|
||||
background: rgba(243, 102, 117, 6%);
|
||||
|
||||
.mreq__pill-value {
|
||||
color: #cc2831;
|
||||
}
|
||||
|
||||
.mreq__dot {
|
||||
background: #cc2831;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill--success {
|
||||
background: rgba(0, 112, 116, 6%);
|
||||
|
||||
.mreq__pill-value {
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
.mreq__dot {
|
||||
background: #007074;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill--warning {
|
||||
background: rgba(182, 132, 45, 7%);
|
||||
|
||||
.mreq__pill-value {
|
||||
color: #b6842d;
|
||||
}
|
||||
|
||||
.mreq__dot {
|
||||
background: #b6842d;
|
||||
}
|
||||
}
|
||||
|
||||
&__pill--neutral {
|
||||
background: rgba(107, 107, 107, 6%);
|
||||
|
||||
.mreq__pill-value {
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
.mreq__dot {
|
||||
background: #989898;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-btn {
|
||||
min-width: 9rem;
|
||||
padding: 0 0.625rem;
|
||||
&__btn {
|
||||
min-width: 8rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="muc">
|
||||
<div class="muc__profile">
|
||||
<div class="muc__avatar">
|
||||
<img v-if="avatar" :src="avatar" alt="" />
|
||||
<SvgIcon v-else name="user" :size="40" color="#c2c2c2" />
|
||||
</div>
|
||||
<div class="muc__profile-info">
|
||||
<div class="muc__level">
|
||||
<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">
|
||||
<SvgIcon name="map-pin-simple-area" :size="12" />
|
||||
<span>{{ city }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muc__stats">
|
||||
<div class="muc__stat">
|
||||
<span class="muc__stat-label">مدت عضویت</span>
|
||||
<div class="muc__stat-value">
|
||||
<span class="muc__stat-num">{{ membershipDays }}</span>
|
||||
<span class="muc__stat-unit">روز</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="muc__divider" />
|
||||
|
||||
<div class="muc__stat">
|
||||
<span class="muc__stat-label">تعداد درخواستها</span>
|
||||
<div class="muc__stat-value">
|
||||
<span class="muc__stat-num">{{ requestsCount }}</span>
|
||||
<span class="muc__stat-unit">درخواست</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muc__actions">
|
||||
<BaseButton text="افزودن روایت" custom-class="muc__btn" @click="emit('add-narrative')">
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="plus" :size="16" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
<BaseButton text="تکمیل اطلاعات" custom-class="muc__btn" @click="emit('complete-info')">
|
||||
<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'
|
||||
|
||||
const props = defineProps({
|
||||
profile: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['complete-info', 'settings', 'add-narrative'])
|
||||
|
||||
const fullName = computed(() => props.profile.fullName || '—')
|
||||
const city = computed(() => props.profile.city || '—')
|
||||
const phone = computed(() => props.profile.phone || '—')
|
||||
const avatar = computed(() => props.profile.avatar || '')
|
||||
const membershipDays = computed(() => props.profile.membershipDays ?? '—')
|
||||
const requestsCount = computed(() => props.profile.requestsCount ?? '—')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.muc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
min-width: 9rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
&__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 112, 116, 4%);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 112, 116, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.7rem;
|
||||
color: #a7a7a7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 400;
|
||||
font-size: 1.2rem;
|
||||
color: #818181;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__stat-unit {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.6rem;
|
||||
color: #929292;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
&__profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
&__profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
color: #989898;
|
||||
}
|
||||
|
||||
&__level {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 112, 116, 4%);
|
||||
font-family: var(--font-family-fa);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&__level-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
&__level-value {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&--numeric {
|
||||
font-family: var(--font-family-en);
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 5.625rem;
|
||||
height: 5.625rem;
|
||||
border-radius: 9999px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات اعزام"
|
||||
title-en="Dispatch Details"
|
||||
width="95%"
|
||||
max-width="56rem"
|
||||
min-width="auto"
|
||||
>
|
||||
<div class="dispatch-details">
|
||||
<div class="dispatch-details__row dispatch-details__row--summary">
|
||||
<LineInfoBlock
|
||||
title="نام مأموریت/اعزام"
|
||||
:desc="dispatch.missionName || dispatch.title || '—'"
|
||||
/>
|
||||
<LineInfoBlock title="کد اعزام" :numeric-desc="dispatch.code || '—'" />
|
||||
<LineInfoBlock title="تاریخ اعزام" :numeric-desc="dispatch.dispatchDate || '—'" />
|
||||
<LineInfoBlock title="وضعیت فعلی" :desc="dispatch.statusLabel || '—'" />
|
||||
<LineInfoBlock title="نام درخواست کننده" :desc="dispatch.requesterName || '—'" />
|
||||
</div>
|
||||
|
||||
<div class="dispatch-details__row">
|
||||
<LineInfoBlock title="آدرس" :desc="dispatch.address || '—'" />
|
||||
</div>
|
||||
|
||||
<div class="dispatch-details__row dispatch-details__row--body">
|
||||
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="dispatch.result || '—'" />
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
|
||||
defineOptions({ name: 'DispatchDetailsModal' })
|
||||
|
||||
const { getModal } = useModal()
|
||||
|
||||
const dispatch = computed(() => getModal('DispatchDetailsModal')?.data ?? {})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dispatch-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-family-fa);
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
|
||||
&--summary {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
:deep(.line-info) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--body :deep(.line-info__desc) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات روایت شما"
|
||||
title-en="Story Details"
|
||||
width="95%"
|
||||
max-width="56rem"
|
||||
min-width="auto"
|
||||
>
|
||||
<div class="narrative-details">
|
||||
<div class="narrative-details__head">
|
||||
<div class="narrative-details__title-block">
|
||||
<LineInfoBlock title="عنوان روایت" :desc="narrative.title || '—'" />
|
||||
</div>
|
||||
|
||||
<div class="narrative-details__stats">
|
||||
<div class="narrative-details__stat">
|
||||
<span class="narrative-details__stat-value">{{ narrative.comments ?? 0 }}</span>
|
||||
<SvgIcon name="chat-centered-dots" :size="20" />
|
||||
</div>
|
||||
<span class="narrative-details__stat-divider" />
|
||||
<div class="narrative-details__stat">
|
||||
<span class="narrative-details__stat-value">{{ narrative.likes ?? 0 }}</span>
|
||||
<SvgIcon name="heart" :size="20" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
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' })
|
||||
|
||||
const { getModal } = useModal()
|
||||
|
||||
const narrative = computed(() => getModal('NarrativeDetailsModal')?.data ?? {})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.narrative-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
font-family: var(--font-family-fa);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
align-self: flex-end;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
&__stat-divider {
|
||||
width: 1.5px;
|
||||
height: 1.5rem;
|
||||
background: #efefef;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 300;
|
||||
font-size: 0.95rem;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
&__body :deep(.line-info__desc) {
|
||||
white-space: pre-line;
|
||||
line-height: 1.7;
|
||||
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;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
&__close-btn {
|
||||
min-width: 10rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
title="جزئیات درخواست"
|
||||
title-en="Request Details"
|
||||
width="95%"
|
||||
max-width="56rem"
|
||||
min-width="auto"
|
||||
>
|
||||
<div class="request-details">
|
||||
<div class="request-details__row request-details__row--summary">
|
||||
<LineInfoBlock
|
||||
title="نام و نام خانوادگی درخواست کننده"
|
||||
:desc="request.requesterName || '—'"
|
||||
/>
|
||||
<LineInfoBlock title="تاریخ ثبت درخواست" :numeric-desc="request.requestDate || '—'" />
|
||||
<LineInfoBlock title="شماره تماس" :numeric-desc="request.phone || '—'" />
|
||||
<LineInfoBlock title="وضعیت" :desc="request.statusLabel || '—'" />
|
||||
</div>
|
||||
|
||||
<div class="request-details__row">
|
||||
<LineInfoBlock title="آدرس" :desc="request.address || '—'" />
|
||||
</div>
|
||||
|
||||
<div class="request-details__row request-details__row--body">
|
||||
<LineInfoBlock title="توضیحات" :desc="request.description || '—'" />
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
|
||||
defineOptions({ name: 'RequestDetailsModal' })
|
||||
|
||||
const { getModal } = useModal()
|
||||
|
||||
const request = computed(() => getModal('RequestDetailsModal')?.data ?? {})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.request-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-family-fa);
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
|
||||
&--summary {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
:deep(.line-info) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--body :deep(.line-info__desc) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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" />
|
||||
<MissionaryUserCard
|
||||
:profile="profile"
|
||||
@complete-info="onCompleteInfo"
|
||||
@settings="onProfileSettings"
|
||||
@add-narrative="onAddNarrative"
|
||||
/>
|
||||
|
||||
<TabsBlock v-model="activeTab" :tabs="tabs">
|
||||
<template #my-requests>
|
||||
<SkeletonLoaderBlock v-if="requestsLoading" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="requests.length > 0">
|
||||
<MissionaryRequestItem
|
||||
v-for="request in requests"
|
||||
:key="request.id"
|
||||
:request="request"
|
||||
@show-details="onRequestDetails"
|
||||
@change-status="onAskChangeStatus"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکردهاید." />
|
||||
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="requests.length > 0">
|
||||
<MissionaryRequestItem
|
||||
v-for="request in requests"
|
||||
:key="request.id"
|
||||
:request="request"
|
||||
@show-details="onShowDetails"
|
||||
@change-status="onAskChangeStatus"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="درخواستی برای نمایش وجود ندارد." />
|
||||
<template #history>
|
||||
<SkeletonLoaderBlock v-if="dispatchesLoading" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="dispatches.length > 0">
|
||||
<MissionaryDispatchItem
|
||||
v-for="dispatch in dispatches"
|
||||
:key="dispatch.id"
|
||||
:dispatch="dispatch"
|
||||
@show-details="onDispatchDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="موردی نیست" desc="تاریخچه اعزامی موجود نیست." />
|
||||
<PaginationBlock :pagination="dispatchesMeta" @update:page="setDispatchesPage" />
|
||||
</template>
|
||||
|
||||
<MissionaryRequestDetailsModal v-if="isModal('MissionaryRequestDetailsModal')" />
|
||||
<template #narratives>
|
||||
<SkeletonLoaderBlock v-if="narrativesLoading" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="narratives.length > 0">
|
||||
<MissionaryNarrativeItem
|
||||
v-for="narrative in narratives"
|
||||
:key="narrative.id"
|
||||
:narrative="narrative"
|
||||
@show-details="onNarrativeDetails"
|
||||
@edit="onEditNarrative"
|
||||
@delete="onAskDeleteNarrative"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="موردی نیست" desc="هنوز روایتی ثبت نکردهاید." />
|
||||
<PaginationBlock :pagination="narrativesMeta" @update:page="setNarrativesPage" />
|
||||
</template>
|
||||
</TabsBlock>
|
||||
|
||||
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
|
||||
<DispatchDetailsModal v-if="isModal('DispatchDetailsModal')" />
|
||||
<NarrativeDetailsModal v-if="isModal('NarrativeDetailsModal')" />
|
||||
<CreateNarrativeModal v-if="isModal('CreateNarrativeModal')" />
|
||||
<CompleteInfoModal v-if="isModal('MissionaryCompleteInfoModal')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import useAuth from '@/composables/useAuth'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { MISSIONARY_REQUEST_STATUS } from '@/enums'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import { formatJalaaliDate, formatJalaaliDateTime } from '@/utils/date-utils'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||
import MissionaryUserCard from '@/features/missionary/components/MissionaryUserCard.vue'
|
||||
import MissionaryRequestItem from '@/features/missionary/components/MissionaryRequestItem.vue'
|
||||
import MissionaryRequestDetailsModal from '@/features/missionary/components/modals/MissionaryRequestDetailsModal.vue'
|
||||
import MissionaryDispatchItem from '@/features/missionary/components/MissionaryDispatchItem.vue'
|
||||
import RequestDetailsModal from '@/features/missionary/components/modals/RequestDetailsModal.vue'
|
||||
import MissionaryNarrativeItem from '@/features/missionary/components/MissionaryNarrativeItem.vue'
|
||||
import DispatchDetailsModal from '@/features/missionary/components/modals/DispatchDetailsModal.vue'
|
||||
import CreateNarrativeModal from '@/features/missionary/components/modals/CreateNarrativeModal.vue'
|
||||
import NarrativeDetailsModal from '@/features/missionary/components/modals/NarrativeDetailsModal.vue'
|
||||
import CompleteInfoModal from '@/features/missionary/components/modals/completeInfo/CompleteInfoModal.vue'
|
||||
import {
|
||||
missionaryMemoriesKeys,
|
||||
useDeleteMissionaryMemoryMutation,
|
||||
useMissionaryMemoriesListQuery,
|
||||
} from '@/services/query/missionary-memories'
|
||||
import {
|
||||
missionaryRequestsKeys,
|
||||
useChangeMissionaryRequestStatusMutation,
|
||||
useMissionaryRequestsListQuery,
|
||||
} from '@/services/query/missionary-requests'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const { data, isLoading } = useMissionaryRequestsListQuery()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const requests = computed(() => data.value ?? [])
|
||||
const tabs = [
|
||||
{ name: 'my-requests', label: 'درخواست های من', icon: 'list-bullets' },
|
||||
{ name: 'history', label: 'تاریخچه اعزام ها', icon: 'paper-plane-right' },
|
||||
{ name: 'narratives', label: 'روایت های من', icon: 'pencil' },
|
||||
]
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: missionaryRequestsKeys.all, refetchType: 'all' })
|
||||
const isValidTab = (name) => tabs.some((tab) => tab.name === name)
|
||||
const activeTab = ref(isValidTab(route.query.tab) ? route.query.tab : 'my-requests')
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (route.query.tab === tab) return
|
||||
router.replace({ query: { ...route.query, tab } }).catch(() => {})
|
||||
})
|
||||
|
||||
const requestsFilters = ref({})
|
||||
const dispatchesFilters = ref({ status: 'accepted' })
|
||||
const narrativesFilters = ref({})
|
||||
|
||||
const { pagination: requestsPagination, setPage: setRequestsPage } = usePagination({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
})
|
||||
const { pagination: dispatchesPagination, setPage: setDispatchesPage } = usePagination({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
})
|
||||
const { pagination: narrativesPagination, setPage: setNarrativesPage } = usePagination({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
})
|
||||
|
||||
const { data: requestsData, isLoading: requestsLoading } = useMissionaryRequestsListQuery(
|
||||
requestsFilters,
|
||||
requestsPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'my-requests',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: dispatchesData, isLoading: dispatchesLoading } = useMissionaryRequestsListQuery(
|
||||
dispatchesFilters,
|
||||
dispatchesPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'history',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: narrativesData, isLoading: narrativesLoading } = useMissionaryMemoriesListQuery(
|
||||
narrativesFilters,
|
||||
narrativesPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'narratives',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const requestStatusLabel = (status) => MISSIONARY_REQUEST_STATUS[status] || status || '—'
|
||||
|
||||
const toRequestView = (request) => ({
|
||||
...request,
|
||||
code: request.id,
|
||||
requestNumber: request.id,
|
||||
requestDate: formatJalaaliDate(request.requestedDate),
|
||||
statusLabel: requestStatusLabel(request.status),
|
||||
phone: request.requesterPhone,
|
||||
address: request.location,
|
||||
})
|
||||
|
||||
const toDispatchView = (request) => ({
|
||||
id: request.id,
|
||||
code: request.id,
|
||||
title: request.title,
|
||||
missionName: request.title,
|
||||
location: request.location,
|
||||
dispatchDate: formatJalaaliDate(request.requestedDate),
|
||||
statusLabel: requestStatusLabel(request.status),
|
||||
requesterName: request.requesterName,
|
||||
address: request.location,
|
||||
result: request.description,
|
||||
})
|
||||
|
||||
const NARRATIVE_KIND_META = {
|
||||
manuscript: { label: 'دست نوشته', tone: 'pink' },
|
||||
dispatched: { label: 'اعزام شــــده', tone: 'gold' },
|
||||
}
|
||||
|
||||
const toNarrativeView = (narrative) => {
|
||||
const linkedKind =
|
||||
narrative.missionaryRequestId || narrative.missionaryRequest ? 'dispatched' : 'manuscript'
|
||||
const kind = narrative.type || narrative.kind || linkedKind
|
||||
const kindMeta = NARRATIVE_KIND_META[kind] || NARRATIVE_KIND_META.manuscript
|
||||
return {
|
||||
...narrative,
|
||||
code: narrative.id,
|
||||
submittedAt: formatJalaaliDateTime(narrative.createdAt),
|
||||
kind,
|
||||
kindLabel: kindMeta.label,
|
||||
kindTone: kindMeta.tone,
|
||||
body: narrative.description || narrative.content || narrative.body || '',
|
||||
likes: narrative.likesCount ?? narrative.likes ?? 0,
|
||||
comments: narrative.commentsCount ?? narrative.comments ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const requests = computed(() =>
|
||||
(requestsData.value?.data?.items ?? []).map((r) => toRequestView(r))
|
||||
)
|
||||
const dispatches = computed(() =>
|
||||
(dispatchesData.value?.data?.items ?? []).map((r) => toDispatchView(r))
|
||||
)
|
||||
const narratives = computed(() =>
|
||||
(narrativesData.value?.data?.items ?? []).map((n) => toNarrativeView(n))
|
||||
)
|
||||
|
||||
const membershipDays = computed(() => {
|
||||
const created = new Date(user.value?.createdAt)
|
||||
if (Number.isNaN(created.getTime())) return '—'
|
||||
return Math.max(0, Math.floor((Date.now() - created.getTime()) / 86_400_000))
|
||||
})
|
||||
|
||||
const profile = computed(() => ({
|
||||
fullName: user.value?.name || '—',
|
||||
city: user.value?.city?.name || '',
|
||||
avatar: user.value?.avatarUrl || '',
|
||||
phone: user.value?.phone || '',
|
||||
rating: user.value?.rating ?? '',
|
||||
membershipDays: membershipDays.value,
|
||||
requestsCount: requestsData.value?.meta?.total ?? '',
|
||||
}))
|
||||
|
||||
const requestsMeta = computed(() => ({
|
||||
page: requestsPagination.value.page,
|
||||
perPage: requestsPagination.value.perPage,
|
||||
...requestsData.value?.meta,
|
||||
}))
|
||||
const dispatchesMeta = computed(() => ({
|
||||
page: dispatchesPagination.value.page,
|
||||
perPage: dispatchesPagination.value.perPage,
|
||||
...dispatchesData.value?.meta,
|
||||
}))
|
||||
const narrativesMeta = computed(() => ({
|
||||
page: narrativesPagination.value.page,
|
||||
perPage: narrativesPagination.value.perPage,
|
||||
...narrativesData.value?.meta,
|
||||
}))
|
||||
|
||||
const onCompleteInfo = () => {
|
||||
openModal('MissionaryCompleteInfoModal')
|
||||
}
|
||||
|
||||
const onProfileSettings = () => {}
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const statusMutation = useChangeMissionaryRequestStatusMutation()
|
||||
|
||||
const changeStatus = (id, status) =>
|
||||
statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidate })
|
||||
|
||||
const onShowDetails = (request) => {
|
||||
openModal('MissionaryRequestDetailsModal', { id: request.id })
|
||||
if (request.status === 'pending') changeStatus(request.id, 'seen')
|
||||
}
|
||||
const invalidateRequests = () =>
|
||||
queryClient.invalidateQueries({ queryKey: missionaryRequestsKeys.all, refetchType: 'all' })
|
||||
|
||||
const STATUS_CONFIRMS = {
|
||||
accepted: { title: 'پذیرش درخواست', verb: 'پذیرش' },
|
||||
@@ -81,23 +285,53 @@ const onAskChangeStatus = ({ id, status }) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: config.title,
|
||||
message: `آیا از ${config.verb} درخواست <strong>${request?.title || ''}</strong> مطمئن هستید؟`,
|
||||
onConfirm: () => changeStatus(id, status),
|
||||
onConfirm: () =>
|
||||
statusMutation.mutate({ id, payload: { status } }, { onSuccess: invalidateRequests }),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteMemoryMutation = useDeleteMissionaryMemoryMutation()
|
||||
|
||||
const invalidateMemories = () =>
|
||||
queryClient.invalidateQueries({ queryKey: missionaryMemoriesKeys.all, refetchType: 'all' })
|
||||
|
||||
const onAddNarrative = () => {
|
||||
openModal('CreateNarrativeModal')
|
||||
}
|
||||
|
||||
const onEditNarrative = (narrative) => {
|
||||
openModal('CreateNarrativeModal', { memory: narrative })
|
||||
}
|
||||
|
||||
const onAskDeleteNarrative = (narrative) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: 'حذف روایت',
|
||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${narrative.title}</strong> را حذف کنید؟`,
|
||||
onConfirm: () => deleteMemoryMutation.mutate(narrative.id, { onSuccess: invalidateMemories }),
|
||||
})
|
||||
}
|
||||
|
||||
const onRequestDetails = (request) => {
|
||||
openModal('RequestDetailsModal', request)
|
||||
}
|
||||
|
||||
const onDispatchDetails = (dispatch) => {
|
||||
openModal('DispatchDetailsModal', dispatch)
|
||||
}
|
||||
|
||||
const onNarrativeDetails = (narrative) => {
|
||||
openModal('NarrativeDetailsModal', narrative)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.missionary-requests-page {
|
||||
.student-missionary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__heading {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
margin-bottom: 0.375rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const upsert =
|
||||
(listRef) =>
|
||||
(entry, index = null) => {
|
||||
if (index == null || index < 0) {
|
||||
listRef.value = [...listRef.value, entry]
|
||||
} else {
|
||||
listRef.value = listRef.value.map((item, i) => (i === index ? entry : item))
|
||||
}
|
||||
}
|
||||
|
||||
export const useCompleteInfoStore = defineStore('missionaryCompleteInfo', () => {
|
||||
const introduction = ref('')
|
||||
const skills = ref([])
|
||||
const workExperiences = ref([])
|
||||
const educationHistory = ref([])
|
||||
const certificates = ref([])
|
||||
|
||||
const reset = () => {
|
||||
introduction.value = ''
|
||||
skills.value = []
|
||||
workExperiences.value = []
|
||||
educationHistory.value = []
|
||||
certificates.value = []
|
||||
}
|
||||
|
||||
// Keys mirror what POST /me/register-data persists — flat `{ key: value }`
|
||||
// where array values hold the section entries.
|
||||
const hydrateFromRegisterData = (data = {}) => {
|
||||
if (typeof data.introduction === 'string') introduction.value = data.introduction
|
||||
if (Array.isArray(data.skills)) skills.value = data.skills
|
||||
if (Array.isArray(data.workExperiences)) workExperiences.value = data.workExperiences
|
||||
if (Array.isArray(data.educationHistory)) educationHistory.value = data.educationHistory
|
||||
if (Array.isArray(data.certificates)) certificates.value = data.certificates
|
||||
}
|
||||
|
||||
const addSkill = (skill) => {
|
||||
if (skills.value.some((s) => s.title === skill.title)) return
|
||||
skills.value = [...skills.value, skill]
|
||||
}
|
||||
|
||||
const removeSkill = (title) => {
|
||||
skills.value = skills.value.filter((s) => s.title !== title)
|
||||
}
|
||||
|
||||
const upsertWorkExperience = upsert(workExperiences)
|
||||
const upsertEducation = upsert(educationHistory)
|
||||
const upsertCertificate = upsert(certificates)
|
||||
|
||||
const buildPayload = () => ({
|
||||
introduction: introduction.value,
|
||||
skills: skills.value,
|
||||
workExperiences: workExperiences.value,
|
||||
educationHistory: educationHistory.value,
|
||||
certificates: certificates.value,
|
||||
})
|
||||
|
||||
return {
|
||||
introduction,
|
||||
skills,
|
||||
workExperiences,
|
||||
educationHistory,
|
||||
certificates,
|
||||
reset,
|
||||
hydrateFromRegisterData,
|
||||
addSkill,
|
||||
removeSkill,
|
||||
upsertWorkExperience,
|
||||
upsertEducation,
|
||||
upsertCertificate,
|
||||
buildPayload,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user