feat(certificates): admin certificate management UI
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>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user