43ed9740f2
Admin can view, issue, and delete certificates. Student certificate query now normalises the response shape (works with both paginated and non-paginated API responses). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
9.0 KiB
Vue
293 lines
9.0 KiB
Vue
<template>
|
|
<BasicModal
|
|
title="صدور گواهینامه"
|
|
title-en="Issue Certificate"
|
|
width="95%"
|
|
max-width="56rem"
|
|
min-width="auto"
|
|
:show-close-button="true"
|
|
>
|
|
<template #default="{ close }">
|
|
<form class="issue-certificate" @submit.prevent="onSubmit(close)">
|
|
<div class="issue-certificate__grid">
|
|
<SelectField
|
|
v-model="form.userId"
|
|
name="userId"
|
|
label="طلبه"
|
|
placeholder="نام طلبه را جستجو کنید"
|
|
:options="userOptions"
|
|
option-label="name"
|
|
option-value="id"
|
|
:searchable="true"
|
|
:on-search="searchUsers"
|
|
:error="errors.userId"
|
|
/>
|
|
<SelectField
|
|
v-model="form.termId"
|
|
name="termId"
|
|
label="ترم گذراندهشده"
|
|
placeholder="ترم را انتخاب کنید"
|
|
:options="termOptions"
|
|
option-label="title"
|
|
option-value="id"
|
|
:searchable="true"
|
|
:on-search="searchTerms"
|
|
:error="errors.termId"
|
|
/>
|
|
<SelectField
|
|
v-model="form.courseId"
|
|
name="courseId"
|
|
label="دوره گذراندهشده"
|
|
placeholder="دوره را انتخاب کنید"
|
|
:options="courseOptions"
|
|
option-label="title"
|
|
option-value="id"
|
|
:searchable="true"
|
|
:on-search="searchCourses"
|
|
/>
|
|
<DatePickerField v-model="form.issuedAt" name="issuedAt" label="تاریخ صدور" />
|
|
<DatePickerField v-model="form.startedAt" name="startedAt" label="تاریخ شروع" />
|
|
<DatePickerField v-model="form.endedAt" name="endedAt" label="تاریخ پایان" />
|
|
</div>
|
|
|
|
<p v-if="errors.termId" class="issue-certificate__hint">{{ errors.termId }}</p>
|
|
|
|
<div class="issue-certificate__files">
|
|
<div class="issue-certificate__file-col">
|
|
<label class="issue-certificate__file-label">تصویر گواهینامه (JPG/PNG)</label>
|
|
<FileUploader
|
|
v-model="imageFiles"
|
|
accept="image/*"
|
|
:multiple="false"
|
|
:max-files="1"
|
|
@select="onImageSelect"
|
|
@error="onFileError"
|
|
/>
|
|
</div>
|
|
<div class="issue-certificate__file-col">
|
|
<label class="issue-certificate__file-label">فایل PDF گواهینامه</label>
|
|
<FileUploader
|
|
v-model="pdfFiles"
|
|
accept="application/pdf"
|
|
:multiple="false"
|
|
:max-files="1"
|
|
@select="onPdfSelect"
|
|
@error="onFileError"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p v-if="errors.imageMediaId" class="issue-certificate__hint">{{ errors.imageMediaId }}</p>
|
|
|
|
<div class="issue-certificate__divider" />
|
|
|
|
<div class="issue-certificate__actions">
|
|
<BaseButton
|
|
variant="transparent"
|
|
text="منصرف شدم"
|
|
custom-class="issue-certificate__btn-cancel"
|
|
@click="close"
|
|
>
|
|
<template #prependIcon>
|
|
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
|
|
</template>
|
|
</BaseButton>
|
|
<BaseButton
|
|
type="submit"
|
|
text="صدور گواهینامه"
|
|
:loading="submitting"
|
|
custom-class="issue-certificate__btn-submit"
|
|
>
|
|
<template #appendIcon>
|
|
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
|
</template>
|
|
</BaseButton>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
</BasicModal>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref } from 'vue'
|
|
import { toast } from 'vue3-toastify'
|
|
import useYup from '@/composables/useYup'
|
|
import useDebounce from '@/composables/useDebounce'
|
|
import { useQueryClient } from '@tanstack/vue-query'
|
|
import BasicModal from '@/components/BasicModal.vue'
|
|
import BaseButton from '@/components/BaseButton.vue'
|
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
import SelectField from '@/components/form/SelectField.vue'
|
|
import FileUploader from '@/components/form/FileUploader.vue'
|
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
|
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
|
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
|
import { certificateSchema } from '@/features/admin/certificates/schema'
|
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
|
import {
|
|
adminCertificatesKeys,
|
|
useAddAdminCertificateMutation,
|
|
} from '@/services/query/admin-certificates'
|
|
|
|
defineOptions({ name: 'IssueCertificateModal' })
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
const form = ref({
|
|
userId: '',
|
|
termId: '',
|
|
courseId: '',
|
|
issuedAt: '',
|
|
startedAt: '',
|
|
endedAt: '',
|
|
})
|
|
|
|
const { validate, errors, resetErrors } = useYup(certificateSchema)
|
|
|
|
const userSearch = ref('')
|
|
const userFilters = computed(() => ({ search: userSearch.value, roles: 'student' }))
|
|
const userPagination = ref({ page: 1, perPage: 10 })
|
|
const { data: usersResponse } = useAdminUsersListQuery(userFilters, userPagination)
|
|
const userOptions = computed(() => usersResponse.value?.data ?? [])
|
|
const searchUsers = useDebounce((q) => {
|
|
userSearch.value = q || ''
|
|
}, 400)
|
|
|
|
const termSearch = ref('')
|
|
const termFilters = computed(() => ({ title: termSearch.value }))
|
|
const termPagination = ref({ page: 1, perPage: 10 })
|
|
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
|
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
|
const searchTerms = useDebounce((q) => {
|
|
termSearch.value = q || ''
|
|
}, 400)
|
|
|
|
const courseSearch = ref('')
|
|
const courseFilters = computed(() => ({ title: courseSearch.value }))
|
|
const coursePagination = ref({ page: 1, perPage: 10 })
|
|
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
|
|
const courseOptions = computed(() => coursesResponse.value?.data ?? [])
|
|
const searchCourses = useDebounce((q) => {
|
|
courseSearch.value = q || ''
|
|
}, 400)
|
|
|
|
const imageFiles = ref([])
|
|
const pdfFiles = ref([])
|
|
const uploadMediaMutation = useUploadMediaMutation()
|
|
|
|
const onImageSelect = async (files) => {
|
|
const file = files[0]
|
|
if (!file) return
|
|
try {
|
|
const fd = objectToFormData({ file, purpose: 'certificate_image' })
|
|
const response = await uploadMediaMutation.mutateAsync(fd)
|
|
const payload = response?.data ?? response
|
|
imageFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
|
|
} catch {
|
|
toast.error('بارگذاری تصویر گواهینامه با خطا مواجه شد.')
|
|
}
|
|
}
|
|
|
|
const onPdfSelect = async (files) => {
|
|
const file = files[0]
|
|
if (!file) return
|
|
try {
|
|
const fd = objectToFormData({ file, purpose: 'certificate_pdf' })
|
|
const response = await uploadMediaMutation.mutateAsync(fd)
|
|
const payload = response?.data ?? response
|
|
pdfFiles.value = [{ id: payload?.id, name: file.name, size: file.size, url: payload?.url }]
|
|
} catch {
|
|
toast.error('بارگذاری فایل PDF گواهینامه با خطا مواجه شد.')
|
|
}
|
|
}
|
|
|
|
const onFileError = (msg) => toast.error(msg)
|
|
|
|
const addMutation = useAddAdminCertificateMutation()
|
|
const submitting = computed(() => addMutation.isPending.value)
|
|
|
|
const onSubmit = async (close) => {
|
|
const payload = {
|
|
...form.value,
|
|
imageMediaId: imageFiles.value[0]?.id ?? null,
|
|
pdfMediaId: pdfFiles.value[0]?.id ?? null,
|
|
}
|
|
const { isValid } = await validate(payload)
|
|
if (!isValid) return
|
|
|
|
await addMutation.mutateAsync(payload)
|
|
await queryClient.invalidateQueries({ queryKey: adminCertificatesKeys.all })
|
|
resetErrors()
|
|
close()
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.issue-certificate {
|
|
width: 100%;
|
|
text-align: start;
|
|
padding: 0.5rem 0;
|
|
|
|
&__grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.75rem;
|
|
margin-block: 0.75rem;
|
|
|
|
@media (min-width: 768px) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
@media (min-width: 1280px) {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
}
|
|
|
|
&__hint {
|
|
color: var(--color-error);
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.75rem;
|
|
margin: -0.25rem 0 0.5rem;
|
|
}
|
|
|
|
&__files {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 1rem;
|
|
margin-top: 0.5rem;
|
|
|
|
@media (min-width: 768px) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
&__file-label {
|
|
display: block;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.8125rem;
|
|
color: #4b5563;
|
|
margin-bottom: 0.375rem;
|
|
}
|
|
|
|
&__divider {
|
|
border-block-end: 1px solid var(--color-thd-gray);
|
|
margin-block: 1.5rem;
|
|
}
|
|
|
|
&__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
&__btn-cancel {
|
|
min-width: 9rem;
|
|
}
|
|
|
|
&__btn-submit {
|
|
min-width: 12rem;
|
|
}
|
|
}
|
|
</style>
|