fix: test

This commit is contained in:
sajjadtalkhabi
2026-05-28 15:40:22 +03:30
parent fc0aa2586e
commit 3f3c66a02d
25 changed files with 406 additions and 104 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" # Use the locally-installed lint-staged so this works under environments
# where npm/npx is broken (e.g. GitHub Desktop's bundled git env).
npx lint-staged # exec ./node_modules/.bin/lint-staged
+4
View File
@@ -0,0 +1,4 @@
{
"*.{js,vue}": "eslint --fix",
"*.scss": "prettier --write"
}
+16
View File
@@ -13,6 +13,7 @@
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7", "@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"husky": "^8.0.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
@@ -4264,6 +4265,21 @@
"entities": "^4.4.0" "entities": "^4.4.0"
} }
}, },
"node_modules/husky": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/husky/-/husky-8.0.0.tgz",
"integrity": "sha512-4qbE/5dzNDNxFEkX9MNRPKl5+omTXQzdILCUWiqG/lWIAioiM5vln265/l6I2Zx8gpW8l1ukZwGQeCFbBZ6+6w==",
"license": "MIT",
"bin": {
"husky": "lib/bin.js"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+2
View File
@@ -11,6 +11,7 @@
"lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0", "lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0",
"lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0", "lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0",
"lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0", "lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0",
"prepare": "husky install",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
@@ -20,6 +21,7 @@
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7", "@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"husky": "^8.0.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
@@ -5,12 +5,12 @@
<div class="assignment-item__sub"> <div class="assignment-item__sub">
<span class="assignment-item__sub-label">دوره:</span> <span class="assignment-item__sub-label">دوره:</span>
<span class="assignment-item__sub-value"> <span class="assignment-item__sub-value">
{{ assignment.course?.title || assignment.courseTitle || '—' }} {{ assignment.session?.course?.title || '—' }}
</span> </span>
<span class="assignment-item__dot">|</span> <span class="assignment-item__dot">|</span>
<span class="assignment-item__sub-label">جلسه:</span> <span class="assignment-item__sub-label">جلسه:</span>
<span class="assignment-item__sub-value"> <span class="assignment-item__sub-value">
{{ assignment.session?.title || assignment.sessionTitle || '—' }} {{ assignment.session?.title || '—' }}
</span> </span>
</div> </div>
</div> </div>
@@ -92,12 +92,10 @@ const duration = computed(() =>
) )
const submissions = computed(() => const submissions = computed(() =>
props.assignment.submissionsCount == null ? '—' : `${props.assignment.submissionsCount} نفر` props.assignment.submittersCount == null ? '—' : `${props.assignment.submittersCount} نفر`
) )
const createdAt = computed( const createdAt = computed(() => formatJalaaliDate(props.assignment.createdAt) || '—')
() => props.assignment.faCreatedAt || formatJalaaliDate(props.assignment.createdAt) || '—'
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -7,6 +7,18 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.termId"
name="termId"
label="ترم"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
@update:modelValue="onTermChange"
/>
<SelectField
v-if="form.termId"
v-model="form.courseId" v-model="form.courseId"
name="courseId" name="courseId"
label="دوره" label="دوره"
@@ -15,8 +27,10 @@
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
@update:modelValue="onCourseChange"
/> />
<SelectField <SelectField
v-if="form.courseId"
v-model="form.sessionId" v-model="form.sessionId"
name="sessionId" name="sessionId"
label="جلسه" label="جلسه"
@@ -65,6 +79,7 @@ import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue' import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions' import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
@@ -73,6 +88,7 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ default: () => ({
title: '', title: '',
termId: '',
courseId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
@@ -85,6 +101,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ const emptyForm = () => ({
title: '', title: '',
termId: '',
courseId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
@@ -102,10 +119,21 @@ watch(
const todayIso = new Date().toISOString() const todayIso = new Date().toISOString()
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 100 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({
title: templateSearch.value,
termId: form.value.termId || undefined,
}))
const templatePagination = ref({ page: 1, perPage: 10 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination) const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
enabled: () => !!form.value.termId,
})
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const sessionSearch = ref('') const sessionSearch = ref('')
@@ -114,9 +142,14 @@ const sessionListFilters = computed(() => ({
courseId: form.value.courseId || undefined, courseId: form.value.courseId || undefined,
})) }))
const sessionPagination = ref({ page: 1, perPage: 10 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination) const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination, {
enabled: () => !!form.value.courseId,
})
const sessionOptions = computed(() => sessionsResponse.value?.data ?? []) const sessionOptions = computed(() => sessionsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
templateSearch.value = q || '' templateSearch.value = q || ''
}, 400) }, 400)
@@ -124,6 +157,15 @@ const searchSessions = useDebounce((q) => {
sessionSearch.value = q || '' sessionSearch.value = q || ''
}, 400) }, 400)
const onTermChange = () => {
form.value.courseId = ''
form.value.sessionId = ''
}
const onCourseChange = () => {
form.value.sessionId = ''
}
const hasFilters = computed(() => const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined) Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
) )
@@ -157,7 +199,7 @@ const onReset = () => {
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
} }
@@ -21,6 +21,18 @@
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField
v-model="form.termId"
name="termId"
label="ترم مرتبط"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
:error="errors.termId"
@update:model-value="onTermChange"
/>
<SelectField <SelectField
v-model="form.courseId" v-model="form.courseId"
name="courseId" name="courseId"
@@ -30,6 +42,7 @@
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
:disabled="!form.termId"
:error="errors.courseId" :error="errors.courseId"
@update:model-value="onCourseChange" @update:model-value="onCourseChange"
/> />
@@ -77,13 +90,13 @@
</div> </div>
<div class="add-assignment__files"> <div class="add-assignment__files">
<LineTitleBlock title="فایل‌های تکلیف" title-en="Assignment Files" />
<FileUploader <FileUploader
v-model="attachments" v-model="attachments"
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx" accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx"
:multiple="true" :multiple="true"
:max-files="10" :max-files="10"
context="assignment" @select="onAttachmentsSelect"
@error="onAttachmentsError"
/> />
</div> </div>
@@ -117,6 +130,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal' import useModal from '@/composables/useModal'
@@ -130,9 +144,12 @@ import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import FileUploader from '@/components/form/FileUploader.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 TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import { assignmentSchema } from '@/features/admin/assignments/schema' import { assignmentSchema } from '@/features/admin/assignments/schema'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions' import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import { import {
@@ -158,6 +175,7 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
const form = ref({ const form = ref({
title: '', title: '',
termId: '',
courseId: '', courseId: '',
sessionId: '', sessionId: '',
startDate: '', startDate: '',
@@ -172,10 +190,28 @@ const schema = assignmentSchema
const { validate, validateAt, errors, resetErrors } = useYup(schema) const { validate, validateAt, errors, resetErrors } = useYup(schema)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 100 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const selectedTerm = ref(null)
const termOptions = computed(() => {
const base = termsResponse.value?.data ?? []
if (selectedTerm.value && !base.some((t) => t.id === selectedTerm.value.id)) {
return [...base, selectedTerm.value]
}
return base
})
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({
title: templateSearch.value,
termId: form.value.termId || undefined,
}))
const templatePagination = ref({ page: 1, perPage: 10 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination) const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
enabled: () => !!form.value.termId,
})
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templateOptions = computed(() => { const templateOptions = computed(() => {
const base = templatesResponse.value?.data ?? [] const base = templatesResponse.value?.data ?? []
@@ -203,6 +239,9 @@ const sessionOptions = computed(() => {
return base return base
}) })
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
templateSearch.value = q || '' templateSearch.value = q || ''
}, 400) }, 400)
@@ -210,37 +249,78 @@ const searchSessions = useDebounce((q) => {
sessionSearch.value = q || '' sessionSearch.value = q || ''
}, 400) }, 400)
const onTermChange = (value) => {
form.value.termId = value
form.value.courseId = ''
form.value.sessionId = ''
selectedTemplate.value = null
selectedSession.value = null
}
const onCourseChange = (value) => { const onCourseChange = (value) => {
form.value.courseId = value form.value.courseId = value
form.value.sessionId = '' form.value.sessionId = ''
selectedSession.value = null selectedSession.value = null
} }
const uploadMediaMutation = useUploadMediaMutation()
const onAttachmentsSelect = async (files) => {
for (const file of files) {
try {
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
if (id == null) continue
attachments.value = [
...attachments.value,
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
]
} catch {
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
}
}
}
const onAttachmentsError = (msg) => toast.error(msg)
const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, { const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
enabled: () => !!assignmentId.value, enabled: () => !!assignmentId.value,
}) })
watch(existingAssignment, (assignment) => { watch(existingAssignment, (assignment) => {
if (!assignment) return if (!assignment) return
const tpl = assignment.course
const sessionEntity = assignment.session const sessionEntity = assignment.session
const tpl = sessionEntity?.course || assignment.course
const termEntity = tpl?.term || assignment.term
const termId = termEntity?.id || tpl?.termId || assignment.termId || ''
const priorityValue =
assignment.priority ||
(typeof assignment.isPriority === 'boolean'
? assignment.isPriority
? 'mandatory'
: 'optional'
: 'mandatory')
if (termEntity) selectedTerm.value = termEntity
if (tpl) selectedTemplate.value = tpl if (tpl) selectedTemplate.value = tpl
if (sessionEntity) selectedSession.value = sessionEntity if (sessionEntity) selectedSession.value = sessionEntity
form.value = { form.value = {
title: assignment.title || '', title: assignment.title || '',
courseId: tpl?.id || assignment.courseId || '', termId,
courseId: tpl?.id || sessionEntity?.courseId || assignment.courseId || '',
sessionId: sessionEntity?.id || assignment.sessionId || '', sessionId: sessionEntity?.id || assignment.sessionId || '',
startDate: assignment.startDate || '', startDate: assignment.startDate || '',
endDate: assignment.endDate || '', endDate: assignment.endDate || assignment.deadline || '',
priority: assignment.priority || 'mandatory', priority: priorityValue,
description: assignment.description || '', description: assignment.description || '',
} }
attachments.value = Array.isArray(assignment.attachments) attachments.value = Array.isArray(assignment.media)
? assignment.attachments.map((file) => ({ ? assignment.media.map((m) => ({
id: file.id, id: m.id,
name: file.title || `فایل ${file.id}`, name: m.fileName || `فایل ${m.id}`,
url: file.fileUrl || '', size: m.fileSize ?? 0,
type: file.type || 'other', url: m.url || m.downloadUrl || '',
})) }))
: [] : []
}) })
@@ -254,13 +334,13 @@ const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
const finalPayload = { const finalPayload = {
...payload, sessionId: payload.sessionId,
attachments: attachments.value.map((file, index) => ({ title: payload.title,
fileId: file.id, description: payload.description ?? '',
type: file.type || 'other', deadline: payload.endDate || null,
title: file.name, isActive: true,
order: index + 1, isPriority: payload.priority === 'mandatory',
})), mediaIds: attachments.value.map((file) => file.id).filter((id) => id != null),
} }
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: assignmentId.value, payload: finalPayload }) await updateMutation.mutateAsync({ id: assignmentId.value, payload: finalPayload })
@@ -14,12 +14,9 @@
<div class="assignment-details__head"> <div class="assignment-details__head">
<p class="assignment-details__title">{{ assignment.title || '—' }}</p> <p class="assignment-details__title">{{ assignment.title || '—' }}</p>
<p class="assignment-details__sub"> <p class="assignment-details__sub">
<span> <span>دوره: {{ courseTitle }}</span>
دوره:
{{ assignment.course?.title || assignment.courseTitle || '—' }}
</span>
<span class="assignment-details__sep">|</span> <span class="assignment-details__sep">|</span>
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span> <span>جلسه: {{ sessionTitle }}</span>
</p> </p>
</div> </div>
@@ -28,21 +25,10 @@
title="تاریخ شروع" title="تاریخ شروع"
:numeric-desc="formatJalaaliDate(assignment.startDate) || ''" :numeric-desc="formatJalaaliDate(assignment.startDate) || ''"
/> />
<LineInfoBlock <LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
title="تاریخ پایان" <LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
:numeric-desc="formatJalaaliDate(assignment.endDate) || ''" <LineInfoBlock title="اولویت" :desc="priorityLabel" />
/> <LineInfoBlock title="تعداد فرستندگان" :numeric-desc="submittersLabel" />
<LineInfoBlock
title="مدت زمان"
:numeric-desc="
assignment.durationDays != null ? `${assignment.durationDays} روز` : ''
"
/>
<LineInfoBlock title="اولویت" :desc="ASSIGNMENT_PRIORITY[assignment.priority] || '—'" />
<LineInfoBlock
title="تعداد فرستندگان"
:numeric-desc="assignment.submissionsCount ?? 0"
/>
<LineInfoBlock <LineInfoBlock
title="تاریخ ثبت" title="تاریخ ثبت"
:numeric-desc=" :numeric-desc="
@@ -55,18 +41,18 @@
<LineInfoBlock title="توضیحات" :desc="assignment.description" /> <LineInfoBlock title="توضیحات" :desc="assignment.description" />
</div> </div>
<div v-if="assignment.attachments?.length" class="assignment-details__attachments"> <div v-if="attachmentItems.length" class="assignment-details__attachments">
<LineTitleBlock title="فایل‌های تکلیف" title-en="Attachments" /> <LineTitleBlock title="فایل‌های تکلیف" title-en="Attachments" />
<ul class="assignment-details__attachments-list"> <ul class="assignment-details__attachments-list">
<li v-for="file in assignment.attachments" :key="file.id"> <li v-for="file in attachmentItems" :key="file.id">
<a <a
:href="file.fileUrl || '#'" :href="file.url || '#'"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="assignment-details__file" class="assignment-details__file"
> >
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" /> <SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
<span>{{ file.title || `فایل ${file.id}` }}</span> <span>{{ file.title }}</span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -112,6 +98,65 @@ const assignmentId = computed(() => modalData.value.id ?? null)
const { data: assignment, isLoading } = useAdminAssignmentQuery(assignmentId, { const { data: assignment, isLoading } = useAdminAssignmentQuery(assignmentId, {
enabled: () => !!assignmentId.value, enabled: () => !!assignmentId.value,
}) })
const courseTitle = computed(
() =>
assignment.value?.session?.course?.title ||
assignment.value?.course?.title ||
assignment.value?.courseTitle ||
'—'
)
const sessionTitle = computed(
() => assignment.value?.session?.title || assignment.value?.sessionTitle || '—'
)
const deadlineRaw = computed(() => assignment.value?.endDate || assignment.value?.deadline || '')
const deadlineLabel = computed(() => formatJalaaliDate(deadlineRaw.value) || '—')
const durationLabel = computed(() => {
if (assignment.value?.durationDays != null) return `${assignment.value.durationDays} روز`
const start = assignment.value?.startDate || assignment.value?.createdAt
const end = deadlineRaw.value
if (!start || !end) return '—'
const diff = Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000)
return diff >= 0 ? `${diff} روز` : '—'
})
const priorityLabel = computed(() => {
const priority =
assignment.value?.priority ||
(typeof assignment.value?.isPriority === 'boolean'
? assignment.value.isPriority
? 'mandatory'
: 'optional'
: null)
return ASSIGNMENT_PRIORITY[priority] || '—'
})
const submittersLabel = computed(
() => assignment.value?.submittersCount ?? assignment.value?.submissionsCount ?? 0
)
const attachmentItems = computed(() => {
const media = assignment.value?.media
if (Array.isArray(media) && media.length > 0) {
return media.map((m) => ({
id: m.id,
title: m.fileName || `فایل ${m.id}`,
url: m.url || m.downloadUrl || '',
}))
}
const legacy = assignment.value?.attachments
if (Array.isArray(legacy)) {
return legacy.map((a) => ({
id: a.id,
title: a.title || `فایل ${a.id}`,
url: a.fileUrl || '',
}))
}
return []
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1,10 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="60rem" min-width="auto" :show-close-button="true"> <BasicModal
<template #default="{ data, close }"> title="تکالیف فرستادگان"
title-en="Delegates Submissions"
width="95%"
max-width="60rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="assignment-submissions"> <div class="assignment-submissions">
<LineTitleBlock title="تکالیف فرستادگان" title-en="Delegates Submissions" />
<p v-if="data?.title" class="assignment-submissions__assignment">{{ data.title }}</p>
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
<div v-else-if="submissions.length > 0" class="assignment-submissions__list"> <div v-else-if="submissions.length > 0" class="assignment-submissions__list">
<div <div
@@ -101,15 +105,16 @@ const paginationMeta = computed(() => ({
})) }))
const userName = (user) => const userName = (user) =>
`${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.fullName || '—' user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.fullName || '—'
const locationFromAddress = (user) => { const locationFromAddress = (user) => {
const province = user?.address?.province?.name || '' const province = user?.province?.name || user?.address?.province?.name || ''
const city = user?.address?.city?.name || '' const city = user?.city?.name || user?.address?.city?.name || ''
return [province, city].filter(Boolean).join('، ') return [province, city].filter(Boolean).join('، ')
} }
const submittedAt = (sub) => sub.faSubmittedAt || formatJalaaliDate(sub.submittedAt) || '—' const submittedAt = (sub) =>
sub.faSubmittedAt || formatJalaaliDate(sub.submittedAt || sub.createdAt || sub.reviewedAt) || '—'
const onShowDetails = (submission) => { const onShowDetails = (submission) => {
openModal('AssignmentSubmissionDetailsModal', { openModal('AssignmentSubmissionDetailsModal', {
@@ -77,6 +77,7 @@ const { openModal, isModal } = useModal()
const filters = ref({ const filters = ref({
title: '', title: '',
termId: '',
courseId: '', courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
@@ -4,7 +4,7 @@ export const assignmentSchema = object().shape({
title: string().required().min(3), title: string().required().min(3),
courseId: string().required(), courseId: string().required(),
sessionId: string().required(), sessionId: string().required(),
startDate: string().required(), startDate: string().nullable().notRequired(),
endDate: string().required(), endDate: string().required(),
priority: string().required(), priority: string().required(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
@@ -35,15 +35,15 @@
/> />
</div> </div>
<div class="exam-question-builder__col exam-question-builder__col--score"> <div class="exam-question-builder__col exam-question-builder__col--score">
<label class="exam-question-builder__label">ترتیب</label> <label class="exam-question-builder__label">بارم نمره</label>
<input <input
:value="question.position" :value="question.score"
:disabled="disabled || isLocked(question)" :disabled="disabled || isLocked(question)"
type="text" type="text"
inputmode="numeric" inputmode="numeric"
placeholder="1" placeholder="1"
class="exam-question-builder__input" class="exam-question-builder__input"
@input="updateQuestion(question.id, { position: $event.target.value })" @input="updateQuestion(question.id, { score: $event.target.value })"
/> />
</div> </div>
</div> </div>
@@ -147,7 +147,7 @@ const createOption = () => ({ id: createId('option'), optionText: '', isCorrect:
const createQuestion = () => ({ const createQuestion = () => ({
id: createId('question'), id: createId('question'),
questionText: '', questionText: '',
position: questions.value.length + 1, score: '',
options: [createOption(), createOption()], options: [createOption(), createOption()],
__local: true, __local: true,
}) })
@@ -7,6 +7,18 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.termId"
name="termId"
label="ترم"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
@update:modelValue="onTermChange"
/>
<SelectField
v-if="form.termId"
v-model="form.courseId" v-model="form.courseId"
name="courseId" name="courseId"
label="دوره" label="دوره"
@@ -15,6 +27,18 @@
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
@update:modelValue="onCourseChange"
/>
<SelectField
v-if="form.courseId"
v-model="form.sessionId"
name="sessionId"
label="جلسه"
:options="sessionOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchSessions"
/> />
<DatePickerField v-model="form.fromDate" name="fromDate" label="از تاریخ" :max="todayIso" /> <DatePickerField v-model="form.fromDate" name="fromDate" label="از تاریخ" :max="todayIso" />
<DatePickerField v-model="form.toDate" name="toDate" label="تا تاریخ" :max="todayIso" /> <DatePickerField v-model="form.toDate" name="toDate" label="تا تاریخ" :max="todayIso" />
@@ -55,18 +79,34 @@ import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue' import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ title: '', courseId: '', fromDate: '', toDate: '' }), default: () => ({
title: '',
termId: '',
courseId: '',
sessionId: '',
fromDate: '',
toDate: '',
}),
}, },
}) })
const emit = defineEmits(['update:modelValue', 'apply', 'reset']) const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ title: '', courseId: '', fromDate: '', toDate: '' }) const emptyForm = () => ({
title: '',
termId: '',
courseId: '',
sessionId: '',
fromDate: '',
toDate: '',
})
const form = ref({ ...emptyForm(), ...props.modelValue }) const form = ref({ ...emptyForm(), ...props.modelValue })
watch( watch(
@@ -79,15 +119,52 @@ watch(
const todayIso = new Date().toISOString() const todayIso = new Date().toISOString()
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 100 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({
title: templateSearch.value,
termId: form.value.termId || undefined,
}))
const templatePagination = ref({ page: 1, perPage: 10 }) const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination) const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
enabled: () => !!form.value.termId,
})
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const sessionSearch = ref('')
const sessionListFilters = computed(() => ({
title: sessionSearch.value,
courseId: form.value.courseId || undefined,
}))
const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination, {
enabled: () => !!form.value.courseId,
})
const sessionOptions = computed(() => sessionsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
templateSearch.value = q || '' templateSearch.value = q || ''
}, 400) }, 400)
const searchSessions = useDebounce((q) => {
sessionSearch.value = q || ''
}, 400)
const onTermChange = () => {
form.value.courseId = ''
form.value.sessionId = ''
}
const onCourseChange = () => {
form.value.sessionId = ''
}
const hasFilters = computed(() => const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined) Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
@@ -118,7 +195,11 @@ const onReset = () => {
gap: 0.5rem; gap: 0.5rem;
@media (min-width: 768px) { @media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1280px) {
grid-template-columns: repeat(6, 1fr);
} }
} }
@@ -134,7 +134,7 @@ const blankOption = () => ({ id: createId('option'), optionText: '', isCorrect:
const blankQuestion = () => ({ const blankQuestion = () => ({
id: createId('question'), id: createId('question'),
questionText: '', questionText: '',
position: 1, score: '',
options: [blankOption(), { ...blankOption(), id: createId('option') }], options: [blankOption(), { ...blankOption(), id: createId('option') }],
__local: true, __local: true,
}) })
@@ -172,7 +172,7 @@ const normalizeExistingQuestions = (raw = []) => {
return { return {
id: q.id ?? createId(`question-${qIdx}`), id: q.id ?? createId(`question-${qIdx}`),
questionText: q.questionText || '', questionText: q.questionText || '',
position: q.position ?? qIdx + 1, score: q.score ?? '',
options: options.map((o, oIdx) => ({ options: options.map((o, oIdx) => ({
id: o.id ?? createId(`option-${qIdx}-${oIdx}`), id: o.id ?? createId(`option-${qIdx}-${oIdx}`),
optionText: o.optionText || '', optionText: o.optionText || '',
@@ -220,7 +220,8 @@ const validateLocalQuestions = () => {
questionError.value = '' questionError.value = ''
return localOnes.map((q, idx) => ({ return localOnes.map((q, idx) => ({
questionText: q.questionText.trim(), questionText: q.questionText.trim(),
position: Number(q.position) || idx + 1, position: idx + 1,
score: Number(q.score) || 0,
options: q.options options: q.options
.filter((o) => String(o.optionText || '').trim()) .filter((o) => String(o.optionText || '').trim())
.map((o) => ({ .map((o) => ({
@@ -75,7 +75,14 @@ const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const filters = ref({ title: '', courseId: '', fromDate: '', toDate: '' }) const filters = ref({
title: '',
termId: '',
courseId: '',
sessionId: '',
fromDate: '',
toDate: '',
})
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 }) const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminExamsListQuery(filters, pagination, { const { data, isLoading } = useAdminExamsListQuery(filters, pagination, {
@@ -34,9 +34,9 @@
<div class="add-term-student__text"> <div class="add-term-student__text">
<p class="add-term-student__name">{{ userLabel(user) }}</p> <p class="add-term-student__name">{{ userLabel(user) }}</p>
<p class="add-term-student__meta"> <p class="add-term-student__meta">
<span>{{ user.address?.province?.name || '—' }}</span> <span>{{ user?.province?.name || '—' }}</span>
<span class="add-term-student__sep">،</span> <span class="add-term-student__sep">،</span>
<span>{{ user.address?.city?.name || '—' }}</span> <span>{{ user?.city?.name || '—' }}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -46,11 +46,11 @@
<LineInfoBlock title="کد ملی" :numeric-desc="user.nationalCode || ''" /> <LineInfoBlock title="کد ملی" :numeric-desc="user.nationalCode || ''" />
<LineInfoBlock title="وضعیت تاهل" :desc="MARITAL_STATUS[user.maritalStatus] || '—'" /> <LineInfoBlock title="وضعیت تاهل" :desc="MARITAL_STATUS[user.maritalStatus] || '—'" />
<LineInfoBlock title="جنسیت" :desc="GENDER[user.gender] || '—'" /> <LineInfoBlock title="جنسیت" :desc="GENDER[user.gender] || '—'" />
<LineInfoBlock title="استان" :desc="user.address?.province?.name || '—'" /> <LineInfoBlock title="استان" :desc="user?.province?.name || '—'" />
<LineInfoBlock title="شهر" :desc="user.address?.city?.name || '—'" /> <LineInfoBlock title="شهر" :desc="user?.city?.name || '—'" />
</div> </div>
<LineInfoBlock title="آدرس" :desc="user.address?.address || '—'" /> <LineInfoBlock title="آدرس" :desc="user?.address || '—'" />
<LineInfoBlock title="بیوگرافی" :desc="user.bio || '—'" /> <LineInfoBlock title="بیوگرافی" :desc="user.bio || '—'" />
</div> </div>
@@ -10,9 +10,9 @@
<div class="verification-item__info"> <div class="verification-item__info">
<p class="verification-item__name">{{ fullName }}</p> <p class="verification-item__name">{{ fullName }}</p>
<p class="verification-item__location"> <p class="verification-item__location">
<span>{{ user.address?.province?.name || '—' }}</span> <span>{{ user?.province?.name || '—' }}</span>
<span class="verification-item__sep">،</span> <span class="verification-item__sep">،</span>
<span>{{ user.address?.city?.name || '—' }}</span> <span>{{ user?.city?.name || '—' }}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -26,10 +26,10 @@
:desc="profile.faMaritalStatus || MARITAL_STATUS[profile.maritalStatus] || ''" :desc="profile.faMaritalStatus || MARITAL_STATUS[profile.maritalStatus] || ''"
/> />
<LineInfoBlock title="جنسیت" :desc="profile.faGender || GENDER[profile.gender] || ''" /> <LineInfoBlock title="جنسیت" :desc="profile.faGender || GENDER[profile.gender] || ''" />
<LineInfoBlock title="استان" :desc="user.address?.province?.name || '-'" /> <LineInfoBlock title="استان" :desc="user?.province?.name || '-'" />
<LineInfoBlock title="شهر" :desc="user.address?.city?.name || '-'" /> <LineInfoBlock title="شهر" :desc="user?.city?.name || '-'" />
<div class="user-verification-details__cell user-verification-details__cell--span-3"> <div class="user-verification-details__cell user-verification-details__cell--span-3">
<LineInfoBlock title="آدرس" :desc="user.address?.address || '-'" /> <LineInfoBlock title="آدرس" :desc="user?.address || '-'" />
</div> </div>
</div> </div>
</section> </section>
@@ -231,9 +231,9 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue' import ImageCropper from '@/components/form/ImageCropper.vue'
import { GENDER, MARITAL_STATUS, ROLE_LABELS } from '@/enums' import { GENDER, MARITAL_STATUS, ROLE_LABELS } from '@/enums'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue' import PasswordField from '@/components/form/PasswordField.vue'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -337,9 +337,9 @@ watch(existingUser, (user) => {
gender: user.profile?.gender || '', gender: user.profile?.gender || '',
bio: user.profile?.bio || '', bio: user.profile?.bio || '',
phoneNumber: user.phoneNumber || '', phoneNumber: user.phoneNumber || '',
provinceId: user.address?.province?.id || '', provinceId: user?.province?.id || '',
cityId: user.address?.city?.id || '', cityId: user?.city?.id || '',
address: user.address?.address || '', address: user?.address || '',
avatarId: user.profile?.avatarId || null, avatarId: user.profile?.avatarId || null,
} }
initialNationalCode.value = user.nationalCode || '' initialNationalCode.value = user.nationalCode || ''
@@ -295,7 +295,10 @@ const { data: profile } = useGetMeQuery()
watch(profile, (user) => { watch(profile, (user) => {
if (!user) return if (!user) return
const nameParts = String(user.name || '').trim().split(/\s+/).filter(Boolean) const nameParts = String(user.name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
const firstName = user.firstName || nameParts[0] || '' const firstName = user.firstName || nameParts[0] || ''
const lastName = user.lastName || nameParts.slice(1).join(' ') || '' const lastName = user.lastName || nameParts.slice(1).join(' ') || ''
const phone = user.phone || user.phoneNumber || '' const phone = user.phone || user.phoneNumber || ''
@@ -309,9 +312,9 @@ watch(profile, (user) => {
gender: user.profile?.gender || '', gender: user.profile?.gender || '',
bio: user.profile?.bio || '', bio: user.profile?.bio || '',
phoneNumber: phone, phoneNumber: phone,
provinceId: user.address?.province?.id || '', provinceId: user?.province?.id || '',
cityId: user.address?.city?.id || '', cityId: user?.city?.id || '',
address: user.address?.address || '', address: user?.address || '',
avatarId: user.profile?.avatarId || null, avatarId: user.profile?.avatarId || null,
} }
initialNationalCode.value = user.nationalCode || '' initialNationalCode.value = user.nationalCode || ''
+2 -5
View File
@@ -84,10 +84,7 @@ export const endpoints = {
deleteSession: '/sessions/:id', deleteSession: '/sessions/:id',
// ─── Backend-aligned: Exams ──────────────────────────────────────────────── // ─── Backend-aligned: Exams ────────────────────────────────────────────────
// `getExamsList` is not yet in the backend Postman doc, but per the user getExamsList: '/admin/exams',
// the backend will add it at /exams — wired up now so the FE doesn't need
// to change when the endpoint lands.
getExamsList: '/exams',
showExam: '/exams/:id', showExam: '/exams/:id',
addNewExam: '/exams', addNewExam: '/exams',
updateExam: '/exams/:id', updateExam: '/exams/:id',
@@ -134,7 +131,7 @@ export const endpoints = {
// backend Postman doc currently exposes only create/update/delete/submit/review; // backend Postman doc currently exposes only create/update/delete/submit/review;
// list + show + submissions list + show-submission stay mock-only until backend // list + show + submissions list + show-submission stay mock-only until backend
// adds them, but URL shape is locked in. // adds them, but URL shape is locked in.
getAssignmentsList: '/homeworks', getAssignmentsList: '/admin/homeworks',
showAssignment: '/homeworks/:id', showAssignment: '/homeworks/:id',
getAssignmentSubmissions: '/homeworks/:homeworkId/submissions', getAssignmentSubmissions: '/homeworks/:homeworkId/submissions',
showAssignmentSubmission: '/homework-submissions/:submissionId', showAssignmentSubmission: '/homework-submissions/:submissionId',
@@ -20,11 +20,18 @@ const computeDurationDays = (start, end) => {
return diff >= 0 ? diff : null return diff >= 0 ? diff : null
} }
const courseTermId = (courseId) => {
if (courseId == null) return null
const course = adminCourses.find((c) => c.id === Number(courseId))
return course?.termId ?? course?.term?.id ?? null
}
register('GET', endpoints.getAssignmentsList, ({ query }) => { register('GET', endpoints.getAssignmentsList, ({ query }) => {
let list = filterItems(adminAssignments, query, { let list = filterItems(adminAssignments, query, {
title: 'includes', title: 'includes',
courseId: (item, v) => String(item.course?.id) === String(v), courseId: (item, v) => String(item.course?.id) === String(v),
sessionId: (item, v) => String(item.session?.id) === String(v), sessionId: (item, v) => String(item.session?.id) === String(v),
termId: (item, v) => String(courseTermId(item.course?.id)) === String(v),
}) })
list = filterDateRange(list, query) list = filterDateRange(list, query)
return paginate(list, query) return paginate(list, query)
+5
View File
@@ -29,6 +29,11 @@ register('GET', endpoints.getExamsList, ({ query }) => {
title: 'includes', title: 'includes',
courseId: (item, v) => String(item.course?.id) === String(v), courseId: (item, v) => String(item.course?.id) === String(v),
sessionId: (item, v) => String(item.sessionId) === String(v), sessionId: (item, v) => String(item.sessionId) === String(v),
termId: (item, v) => {
const course = adminCourses.find((c) => c.id === Number(item.course?.id))
const termId = course?.termId ?? course?.term?.id ?? null
return String(termId) === String(v)
},
}) })
list = filterDateRange(list, query) list = filterDateRange(list, query)
const { data: items, meta } = paginate(list, query) const { data: items, meta } = paginate(list, query)
+8
View File
@@ -41,6 +41,10 @@ export const useAdminAssignmentsListQuery = (filtersRef, paginationRef, options
...cleanFilters(filtersRef.value), ...cleanFilters(filtersRef.value),
...paginationRef.value, ...paginationRef.value,
}), }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options, ...options,
}) })
@@ -65,6 +69,10 @@ export const useAdminAssignmentSubmissionsQuery = (
...cleanFilters(filtersRef?.value || {}), ...cleanFilters(filtersRef?.value || {}),
...paginationRef?.value, ...paginationRef?.value,
}), }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options, ...options,
}) })