fix: design

This commit is contained in:
sajjadtalkhabi
2026-05-19 22:53:05 +03:30
parent c82701888a
commit 8990ceaddd
44 changed files with 2678 additions and 435 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_API_BASE_URL=
VITE_USE_MOCKS=true
VITE_API_BASE_URL=https://tripwisedaily.ir/api
VITE_USE_MOCKS=false
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
"@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1",
"pinia": "^3.0.3",
"tinymce": "^8.5.0",
+1
View File
@@ -20,6 +20,7 @@
"@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1",
"pinia": "^3.0.3",
"tinymce": "^8.5.0",
+468 -119
View File
@@ -4,7 +4,7 @@
<div
:id="inputId"
ref="referenceEl"
ref="referenceRef"
tabindex="0"
class="datepicker-field__trigger"
:class="{
@@ -22,10 +22,10 @@
</div>
<Teleport to="body">
<Transition name="fade">
<transition name="fade">
<div
v-show="isOpen"
ref="floatingEl"
ref="floatingRef"
class="date-picker-dropdown datepicker-field__dropdown"
:style="floatingStyles"
>
@@ -34,145 +34,354 @@
locale="fa"
inline
editable
:min="min"
:max="max"
compact-time
:min="jalaliMin"
:max="jalaliMax"
:type="type"
:simple="simple"
:auto-submit="type === 'date'"
:range="range"
:auto-submit="false"
:time-picker="type === 'datetime'"
:compact-time="type === 'datetime'"
:format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:confirm="type === 'datetime'"
@update:model-value="onSelect"
/>
</div>
</Transition>
</transition>
</Teleport>
</div>
</template>
<script setup>
<script>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'
import { computePosition, autoUpdate, offset, shift, flip, size } from '@floating-ui/dom'
import {
formatJalaaliDate,
formatJalaaliDateTime,
jalaaliStringToIsoDate,
jalaaliStringToIsoDateTime,
} from '@/utils/date-utils'
JalaliToGregorianString,
gregorianToJalaliString,
gregorianToJalaliStringLong,
JalaliToGregorianStringWithTime,
} from '@/utils/date-convertor'
let uid = 0
const nextUid = () => `datepicker-field-${++uid}`
const props = defineProps({
modelValue: { type: String, default: '' },
name: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
type: { type: String, default: 'date' },
simple: { type: Boolean, default: false },
})
export default {
name: 'DatePickerField',
components: { DatePicker, SvgIcon },
const emit = defineEmits(['update:modelValue', 'change'])
props: {
modelValue: { type: String, default: '' },
name: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
type: { type: String, default: 'date' },
simple: { type: Boolean, default: false },
range: { type: [Boolean, Array], default: false },
autoSubmit: { type: Boolean, default: true },
},
const inputId = computed(() => props.name || nextUid())
const referenceEl = ref(null)
const floatingEl = ref(null)
const isOpen = ref(false)
const internalValue = ref(null)
const floatingStyles = ref({ position: 'absolute', top: '0px', left: '0px' })
let cleanup = null
emits: ['update:modelValue', 'change'],
const displayValue = computed(() => internalValue.value || props.placeholder)
data() {
return {
inputId: this.name || nextUid(),
isOpen: false,
internalValue: null,
cleanup: null,
floatingStyles: {
position: 'absolute',
top: '0px',
left: '0px',
},
confirmHandler: null,
mutationObserver: null,
}
},
const syncFromProp = () => {
const v = props.modelValue
if (v && typeof v === 'string') {
internalValue.value =
props.type === 'datetime' ? formatJalaaliDateTime(v) : formatJalaaliDate(v)
} else {
internalValue.value = null
}
computed: {
displayValue() {
return this.internalValue || this.placeholder
},
jalaliMin() {
if (!this.min) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.min, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.min, '/', false)
},
jalaliMax() {
if (!this.max) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.max, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.max, '/', false)
},
},
watch: {
modelValue: {
immediate: true,
handler(val) {
this.syncFromModel(val)
},
},
},
beforeUnmount() {
this.cleanup?.()
this.removeConfirmButtonListener()
document.removeEventListener('mousedown', this.handleOutside)
},
methods: {
syncFromModel(val) {
if (val && typeof val === 'string') {
this.internalValue =
this.type === 'datetime'
? gregorianToJalaliStringLong(val, '/', false)
: gregorianToJalaliString(val, '/', false)
} else {
this.internalValue = null
}
},
toggle() {
if (this.disabled) return
this.isOpen ? this.close() : this.open()
},
open() {
this.isOpen = true
this.$nextTick(() => {
this.initFloating()
if (this.type === 'datetime') {
this.$nextTick(() => {
this.attachConfirmButtonListener()
})
}
})
},
close() {
this.isOpen = false
this.cleanup?.()
this.cleanup = null
this.removeConfirmButtonListener()
},
removeConfirmButtonListener() {
if (this.confirmHandler) {
this.confirmHandler.element.removeEventListener('click', this.confirmHandler.handler)
delete this.confirmHandler.element.dataset.confirmListener
this.confirmHandler = null
}
if (this.mutationObserver) {
this.mutationObserver.disconnect()
this.mutationObserver = null
}
},
initFloating() {
const reference = this.$refs.referenceRef
const floating = this.$refs.floatingRef
if (!reference || !floating) return
this.cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, {
placement: 'bottom-start',
middleware: [
offset(6),
flip(),
shift({ padding: 8 }),
size({
apply({ rects }) {
Object.assign(floating.style, {
minWidth: `${rects.reference.width}px`,
})
},
}),
],
}).then(({ x, y }) => {
Object.assign(this.floatingStyles, {
left: `${x}px`,
top: `${y}px`,
})
})
})
document.addEventListener('mousedown', this.handleOutside)
},
handleOutside(e) {
const r = this.$refs.referenceRef
const f = this.$refs.floatingRef
if (r?.contains(e.target) || f?.contains(e.target)) return
this.close()
document.removeEventListener('mousedown', this.handleOutside)
},
onSelect(jalaliValue) {
if (!jalaliValue) return
if (this.type === 'datetime') {
const gregorian = JalaliToGregorianStringWithTime(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
} else {
const gregorian = JalaliToGregorianString(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
this.close()
}
},
attachConfirmButtonListener() {
const findAndAttach = () => {
const dropdown = this.$refs.floatingRef
if (!dropdown) return false
let confirmBtn = dropdown.querySelector('.vpd-actions')
if (confirmBtn) {
const allButtons = confirmBtn.querySelectorAll('button')
for (const btn of allButtons) {
if (btn.innerHTML.includes('svg') || btn.innerText.includes('تایید')) {
confirmBtn = btn
break
}
}
}
if (confirmBtn && !Object.hasOwn(confirmBtn.dataset, 'confirmListener')) {
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
confirmBtn.addEventListener('click', handler, true)
confirmBtn.dataset.confirmListener = 'true'
this.confirmHandler = { element: confirmBtn, handler }
return true
}
return false
}
if (!findAndAttach()) {
const timeouts = [100, 300, 500, 1000]
timeouts.forEach((delay) => {
setTimeout(() => {
if (this.isOpen) {
findAndAttach()
}
}, delay)
})
}
this.setupMutationObserver()
},
setupMutationObserver() {
const dropdown = this.$refs.floatingRef
if (!dropdown) return
this.mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
setTimeout(() => {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (this.isConfirmButton(node)) {
this.attachListenerToButton(node)
}
const btns = node.querySelectorAll ? node.querySelectorAll('button') : []
for (const btn of btns) {
if (this.isConfirmButton(btn)) {
this.attachListenerToButton(btn)
}
}
}
}
}, 50)
}
}
})
this.mutationObserver.observe(dropdown, {
childList: true,
subtree: true,
})
},
isConfirmButton(element) {
if (!element || !element.tagName) return false
if (
element.classList?.contains('vpd-confirm-btn') ||
element.classList?.contains('vpd-action-btn')
) {
return true
}
if (element.innerText?.includes('تایید')) {
return true
}
if (element.innerHTML?.includes('svg')) {
const svgPaths = element.innerHTML.match(/d="[^"]*"/g) || []
const hasCheckIcon = svgPaths.some(
(path) =>
path.includes('M20 6L9 17l-5-5') ||
path.includes('M9 16.17L4.83 12') ||
path.includes('M5 13l4 4L19 7')
)
return hasCheckIcon
}
return false
},
attachListenerToButton(btn) {
if (Object.hasOwn(btn.dataset, 'confirmListener')) return
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
btn.addEventListener('click', handler, true)
btn.dataset.confirmListener = 'true'
this.confirmHandler = { element: btn, handler }
},
handleConfirm() {
if (!this.internalValue) return
try {
const gregorian = JalaliToGregorianStringWithTime(this.internalValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.close()
} catch (error) {
console.error('Error in handleConfirm:', error)
}
},
},
}
watch(() => props.modelValue, syncFromProp, { immediate: true })
const onSelect = (jalaliValue) => {
if (!jalaliValue) return
internalValue.value = jalaliValue
const gregorian =
props.type === 'datetime'
? jalaaliStringToIsoDateTime(jalaliValue)
: jalaaliStringToIsoDate(jalaliValue)
emit('update:modelValue', gregorian)
emit('change', gregorian)
if (props.type === 'date') close()
}
const mountFloating = () => {
const reference = referenceEl.value
const floating = floatingEl.value
if (!reference || !floating) return
cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, {
placement: 'bottom-start',
middleware: [
offset(6),
flip(),
shift({ padding: 8 }),
size({
apply({ rects }) {
Object.assign(floating.style, { minWidth: `${rects.reference.width}px` })
},
}),
],
}).then(({ x, y }) => {
floatingStyles.value = { position: 'absolute', left: `${x}px`, top: `${y}px` }
})
})
document.addEventListener('mousedown', handleOutsideClick)
}
const unmountFloating = () => {
cleanup?.()
cleanup = null
document.removeEventListener('mousedown', handleOutsideClick)
}
const handleOutsideClick = (event) => {
const reference = referenceEl.value
const floating = floatingEl.value
if (
reference &&
!reference.contains(event.target) &&
floating &&
!floating.contains(event.target)
) {
close()
}
}
const open = () => {
isOpen.value = true
setTimeout(mountFloating, 0)
}
const close = () => {
isOpen.value = false
unmountFloating()
}
const toggle = () => {
if (props.disabled) return
isOpen.value ? close() : open()
}
onBeforeUnmount(() => unmountFloating())
</script>
<style lang="scss" scoped>
@@ -254,3 +463,143 @@ onBeforeUnmount(() => unmountFloating())
}
}
</style>
<style>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.date-picker-dropdown .vpd-main .vpd-input-group {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-container {
margin: 0 !important;
font-family: iran-yekan;
width: 100%;
}
.date-picker-dropdown .vpd-wrapper .vpd-content {
width: auto;
border-radius: 8px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-date {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-header {
background-color: transparent !important;
padding: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-year-label {
color: #5f5f5f;
text-align: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body {
overflow-x: hidden;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-controls button {
display: flex;
align-items: center;
justify-content: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-clearfix.vpd-week
.vpd-weekday {
width: auto;
float: none;
clear: both;
padding: 0 12px;
}
@media screen and (max-width: 400px) {
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week,
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
padding: 0 5px;
}
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.direction-prev
.vpd-clearfix {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
height: fit-content !important;
padding: 0 12px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day {
width: 35px;
height: 35px;
float: right;
line-height: 35px;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']).vpd-selected
.vpd-day-effect,
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']):hover
.vpd-day-effect {
background-color: #f36675 !important;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day
.vpd-day-effect {
width: 35px;
height: 35px;
top: -2px;
left: 0;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-close-addon {
background-color: #f5f5f5;
border-radius: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-month-label {
width: auto;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@
class="image-cropper__upload-btn"
@click="triggerUpload"
>
<SvgIcon name="upload" :size="64" />
<SvgIcon name="upload" color="" :size="64" />
</button>
<div v-if="isCropping && image" class="image-cropper__crop-stage">
+12
View File
@@ -133,6 +133,18 @@ export const VERIFICATION_MEDIA_TYPE = Object.freeze({
LEADER_MESSAGE: 'leader_message',
})
export const COURSE_CONTENT_TYPE = Object.freeze({
video: 'ویدئو',
voice: 'صوت',
text: 'متن',
})
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*',
voice: 'audio/*',
text: '.pdf,.doc,.docx,.txt',
})
export const SESSION_TYPE = Object.freeze({
in_person: 'حضوری',
online: 'آنلاین',
@@ -119,7 +119,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
@@ -213,11 +213,11 @@ watch(existingCourse, (course) => {
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
@@ -48,13 +48,35 @@
name="defaultTeacherId"
label="استاد"
:options="teacherOptions"
option-label="label"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchTeachers"
:error="errors.defaultTeacherId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.sessionsCount"
name="sessionsCount"
label="تعداد جلسه"
inputmode="numeric"
:convert-digits="true"
:error="errors.sessionsCount"
@blur="validateAt('sessionsCount', form.sessionsCount)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisites"
@@ -69,25 +91,36 @@
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
<SelectField
v-model="form.contentType"
name="contentType"
label="نوع فایل دوره"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<div class="course-form__cell course-form__cell--third course-form__toggle-cell">
<ToggleSwitch v-model="form.isActiveByDefault" label="دوره فعال باشد" />
</div>
<div class="course-form__cell course-form__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
label="توضیحات دوره"
:row="5"
:error="errors.description"
/>
</div>
<div class="course-form__cell course-form__cell--full">
<label class="course-form__uploader-label">فایل دوره</label>
<FileUploader
v-model="contentFiles"
:accept="contentAccept"
:multiple="false"
:max-files="1"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/>
</div>
</div>
@@ -134,13 +167,14 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { courseTemplateSchema } from '@/features/admin/courses/schema'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCourseTemplatesKeys,
@@ -155,23 +189,33 @@ const router = useRouter()
const queryClient = useQueryClient()
const courseId = computed(() => (route.params.id ? Number(route.params.id) : null))
const termId = computed(() => (route.query.termId ? Number(route.query.termId) : null))
const isEditMode = computed(() => !!courseId.value)
const form = ref({
title: '',
defaultTeacherId: '',
prerequisites: [],
defaultCapacity: '',
sessionsCount: '',
prerequisites: [],
contentType: '',
contentMediaId: null,
description: '',
imageId: null,
isActiveByDefault: false,
coverMediaId: null,
termId: termId.value,
})
const image = ref(null)
const contentFiles = ref([])
const schema = courseTemplateSchema
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(schema)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
@@ -222,24 +266,37 @@ watch(existingCourse, (course) => {
form.value = {
title: course.title || '',
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
sessionsCount: course.sessionsCount ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
contentMediaId: course.contentMediaId || null,
description: course.description || '',
imageId: course.imageId || null,
isActiveByDefault: course.isActiveByDefault ?? course.isActive ?? false,
coverMediaId: course.coverMediaId || null,
termId: termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
if (course.contentMedia) {
contentFiles.value = [
{
id: course.contentMedia.id,
name: course.contentMedia.name || course.contentMedia.fileName || 'file',
size: course.contentMedia.size ?? 0,
url: course.contentMedia.url,
},
]
}
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -247,6 +304,38 @@ const onImageCropped = async (file) => {
const onImageError = (msg) => toast.error(msg)
const onContentTypeChange = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
try {
const formData = objectToFormData({
file,
purpose: 'content',
context: 'course',
type: form.value.contentType,
})
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
form.value.contentMediaId = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseTemplateMutation()
const updateMutation = useUpdateAdminCourseTemplateMutation()
@@ -345,9 +434,14 @@ const onCancel = () => router.push({ name: 'admin-courses' })
}
}
&__toggle-cell {
display: flex;
align-items: center;
&__uploader-label {
display: block;
margin-bottom: 0.5rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__divider {
@@ -164,7 +164,7 @@ const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuer
}
)
const templates = computed(() => templatesData.value?.data ?? [])
const templates = computed(() => templatesData.value?.data?.items ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
+7 -3
View File
@@ -1,11 +1,15 @@
import { array, boolean, object, string } from 'yup'
import { array, boolean, mixed, number, object, string } from 'yup'
export const courseTemplateSchema = object().shape({
title: string().required().min(3).max(255),
defaultTeacherId: string().required(),
defaultCapacity: string().required(),
defaultTeacherId: mixed().required(),
defaultCapacity: number().required().min(1),
sessionsCount: number().required().min(1),
prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(),
termId: string().nullable(),
})
export const offeredCourseSchema = object().shape({
@@ -236,7 +236,7 @@ import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
@@ -341,7 +341,7 @@ watch(existingSession, (session) => {
}
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
@@ -1,8 +1,8 @@
<template>
<div class="term-item">
<div class="term-item__main">
<div v-if="term.image" class="term-item__image">
<img :src="term.image" :alt="term.title" />
<div v-if="term.coverUrl" class="term-item__image">
<img :src="term.coverUrl" :alt="term.title" />
</div>
<div v-else class="term-item__image term-item__image--placeholder">
<SvgIcon name="book" :size="24" color="#bcbcbc" />
+22 -22
View File
@@ -42,20 +42,20 @@
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.startDate"
name="startDate"
v-model="form.startsAt"
name="startsAt"
label="تاریخ شروع"
:min="todayIso"
:error="errors.startDate"
:error="errors.startsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.endDate"
name="endDate"
v-model="form.endsAt"
name="endsAt"
label="تاریخ پایان"
:min="todayIso"
:error="errors.endDate"
:error="errors.endsAt"
/>
</div>
<div class="term-form__cell term-form__cell--full">
@@ -112,8 +112,8 @@ import { termSchema } from '@/features/admin/terms/schema'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
@@ -134,17 +134,16 @@ const todayIso = new Date().toISOString()
const form = ref({
title: '',
startDate: '',
endDate: '',
description: '',
imageId: null,
isActive: true,
startsAt: '',
endsAt: '',
coverMediaId: null,
})
const image = ref(null)
const schema = termSchema
const { validate, validateAt, errors } = useYup(schema)
const { validate, validateAt, errors } = useYup(termSchema)
const { data: existingTerm } = useAdminTermQuery(termId, {
enabled: () => !!termId.value,
@@ -154,23 +153,24 @@ watch(existingTerm, (term) => {
if (!term) return
form.value = {
title: term.title || '',
startDate: term.startDate || '',
endDate: term.endDate || '',
description: term.description || '',
imageId: term.imageId || null,
isActive: term.isActive ?? true,
startsAt: term.startsAt || '',
endsAt: term.endsAt || '',
coverMediaId: term.coverMediaId || null,
}
if (term.image) image.value = { url: term.image }
if (term.coverUrl) image.value = { url: term.coverUrl }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'term' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'term' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -191,7 +191,7 @@ const onSubmit = async () => {
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
await queryClient.resetQueries({ queryKey: adminTermsKeys.all })
router.push({ name: 'admin-terms' })
}
+5 -3
View File
@@ -1,10 +1,12 @@
import { array, mixed, object, string } from 'yup'
import { array, boolean, mixed, number, object, string } from 'yup'
export const termSchema = object().shape({
title: string().required().min(3),
startDate: string().required(),
endDate: string().required(),
description: string().nullable().notRequired(),
isActive: boolean().default(true),
startsAt: string().required(),
endsAt: string().required(),
coverMediaId: number().nullable().notRequired(),
})
export const addTermStudentSchema = object().shape({
@@ -72,11 +72,7 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import { CHANGEABLE_ROLES, ROLE_LABELS } from '@/enums'
import DropdownMenu from '@/components/DropdownMenu.vue'
import {
adminUsersKeys,
useAdminRolesListQuery,
useUpdateAdminUserRoleMutation,
} from '@/services/query/admin-users'
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
defineProps({
users: { type: Array, required: true },
@@ -124,15 +120,6 @@ const onMoreClick = (event, user) => {
const roleMenuOpen = ref(false)
const roleMenuTrigger = ref(null)
const { data: rolesList = ref([]) } = useAdminRolesListQuery()
const roleIdByName = computed(() => {
const map = {}
;(rolesList.value ?? []).forEach((r) => {
if (r?.name) map[r.name] = r.id
})
return map
})
const updateRoleMutation = useUpdateAdminUserRoleMutation()
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: adminUsersKeys.all })
@@ -150,10 +137,11 @@ const onRoleClick = (event, user) => {
const onChangeRole = (targetRoleName) => {
const user = activeUser.value
if (!user) return
const roleId = roleIdByName.value[targetRoleName]
const payload = roleId ? { roleId } : { role: targetRoleName }
updateRoleMutation.mutate({ id: user.id, payload }, { onSuccess: invalidateUsers })
if (!user || !targetRoleName) return
updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } },
{ onSuccess: invalidateUsers }
)
}
const roleMenuItems = computed(() => {
@@ -233,7 +233,7 @@ import { GENDER, MARITAL_STATUS, ROLE_LABELS } from '@/enums'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -348,7 +348,7 @@ watch(existingUser, (user) => {
}
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -79,7 +79,7 @@ import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
@@ -123,7 +123,7 @@ const formattedTime = computed(() => {
return `${mm}:${ss}`
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const stopTracks = () => {
if (mediaStream) {
@@ -167,7 +167,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
@@ -242,7 +242,7 @@ watch(
}
)
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -223,9 +223,9 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import {
authKeys,
useGetProfileQuery,
useGetMeQuery,
useUpdateProfileMutation,
useUploadTemporaryMutation,
useUploadMediaMutation,
} from '@/services/query/auth'
const router = useRouter()
@@ -291,7 +291,7 @@ watch(
}
)
const { data: profile } = useGetProfileQuery()
const { data: profile } = useGetMeQuery()
watch(profile, (user) => {
if (!user) return
@@ -315,7 +315,7 @@ watch(profile, (user) => {
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -341,7 +341,7 @@ const onSubmit = async () => {
delete payload.passwordConfirmation
}
await updateMutation.mutateAsync(payload)
await queryClient.invalidateQueries({ queryKey: authKeys.profile() })
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
toast.success('پروفایل با موفقیت به‌روز شد')
router.push({ name: 'student-dashboard' })
}
+3 -6
View File
@@ -1,14 +1,11 @@
import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetAdminCourseTemplates = (params) =>
http.get(endpoints.getCourseTemplatesList, { params })
export const apiGetAdminCourseTemplates = (params) => http.get(endpoints.getCoursesList, { params })
export const apiShowAdminCourseTemplate = (id) =>
http.get(buildUrl(endpoints.showCourseTemplate, { id }))
export const apiShowAdminCourseTemplate = (id) => http.get(buildUrl(endpoints.showCourse, { id }))
export const apiAddAdminCourseTemplate = (payload) =>
http.post(endpoints.addNewCourseTemplate, payload)
export const apiAddAdminCourseTemplate = (payload) => http.post(endpoints.addNewCourse, payload)
export const apiUpdateAdminCourseTemplate = (id, payload) =>
http.put(buildUrl(endpoints.updateCourseTemplate, { id }), payload)
+1 -1
View File
@@ -8,7 +8,7 @@ export const apiShowAdminCourse = (id) => http.get(buildUrl(endpoints.showCourse
export const apiAddAdminCourse = (payload) => http.post(endpoints.addNewCourse, payload)
export const apiUpdateAdminCourse = (id, payload) =>
http.put(buildUrl(endpoints.updateCourse, { id }), payload)
http.patch(buildUrl(endpoints.updateCourse, { id }), payload)
export const apiDeleteAdminCourse = (id) => http.delete(buildUrl(endpoints.deleteCourse, { id }))
+1 -1
View File
@@ -8,7 +8,7 @@ export const apiShowAdminSession = (id) => http.get(buildUrl(endpoints.showSessi
export const apiAddAdminSession = (payload) => http.post(endpoints.addNewSession, payload)
export const apiUpdateAdminSession = (id, payload) =>
http.put(buildUrl(endpoints.updateSession, { id }), payload)
http.patch(buildUrl(endpoints.updateSession, { id }), payload)
export const apiDeleteAdminSession = (id) => http.delete(buildUrl(endpoints.deleteSession, { id }))
+1 -1
View File
@@ -8,7 +8,7 @@ export const apiShowAdminTerm = (id) => http.get(buildUrl(endpoints.showTerm, {
export const apiAddAdminTerm = (payload) => http.post(endpoints.addNewTerm, payload)
export const apiUpdateAdminTerm = (id, payload) =>
http.put(buildUrl(endpoints.updateTerm, { id }), payload)
http.patch(buildUrl(endpoints.updateTerm, { id }), payload)
export const apiDeleteAdminTerm = (id) => http.delete(buildUrl(endpoints.deleteTerm, { id }))
+2 -2
View File
@@ -8,10 +8,10 @@ export const apiShowAdminUser = (id) => http.get(buildUrl(endpoints.showUserDeta
export const apiAddAdminUser = (payload) => http.post(endpoints.addNewUser, payload)
export const apiUpdateAdminUser = (id, payload) =>
http.put(buildUrl(endpoints.updateUser, { id }), payload)
http.patch(buildUrl(endpoints.updateUser, { id }), payload)
export const apiUpdateAdminUserRole = (id, payload) =>
http.post(buildUrl(endpoints.updateUserRole, { id }), payload)
http.patch(buildUrl(endpoints.updateUserRole, { id }), payload)
export const apiChangeAdminUserStatus = (id, payload) =>
http.post(buildUrl(endpoints.changeUserStatus, { id }), payload)
+2 -2
View File
@@ -21,7 +21,7 @@ export const apiResetPassword = (payload) => http.post(endpoints.resetPassword,
export const apiLogout = () => http.post(endpoints.logout)
export const apiGetProfile = () => http.get(endpoints.getProfile)
export const apiGetMe = () => http.get(endpoints.me)
export const apiCompleteProfile = (payload) => http.post(endpoints.completeProfile, payload)
@@ -30,4 +30,4 @@ export const apiUpdateProfile = (payload) => http.put(endpoints.updateProfile, p
export const apiGetRegistrationQuestionVideo = () =>
http.get(endpoints.getRegistrationQuestionVideo)
export const apiUploadTemporary = (formData) => http.post(endpoints.uploadMediaTemporary, formData)
export const apiUploadMedia = (formData) => http.post(endpoints.uploadMedia, formData)
+22 -22
View File
@@ -10,12 +10,12 @@ export const endpoints = {
verifyForgotPasswordCode: '/verify-forgot-password-code',
resetPassword: '/reset-password',
logout: '/logout',
uploadMediaTemporary: '/upload-temp',
uploadMedia: '/media',
provinceList: '/provinces',
citiesList: '/provinces/:provinceId/cities',
getProfile: '/profile',
me: '/auth/me',
updateProfile: '/profile',
getStudentCourses: '/student/courses',
showStudentCourse: '/student/courses/:id',
@@ -59,11 +59,11 @@ export const endpoints = {
changeUserStatus: '/admin/users/:id/status',
deleteUser: '/admin/users/:id',
getTermsList: '/admin/terms',
addNewTerm: '/admin/terms',
showTerm: '/admin/terms/:id',
updateTerm: '/admin/terms/:id',
deleteTerm: '/admin/terms/:id',
getTermsList: '/terms',
addNewTerm: '/terms',
showTerm: '/terms/:id',
updateTerm: '/terms/:id',
deleteTerm: '/terms/:id',
cloneTerm: '/admin/terms/:id/clone',
changeStatusTerm: '/admin/terms/:id/status',
@@ -76,18 +76,18 @@ export const endpoints = {
addCourseTerm: '/admin/terms/:termId/courses',
removeCourseTerm: '/admin/terms/:termId/courses/:courseId',
getCoursesList: '/admin/courses',
addNewCourse: '/admin/courses',
showCourse: '/admin/courses/:id',
updateCourse: '/admin/courses/:id',
deleteCourse: '/admin/courses/:id',
getCoursesList: '/courses',
addNewCourse: '/courses',
showCourse: '/courses/:id',
updateCourse: '/courses/:id',
deleteCourse: '/courses/:id',
changeStatusCourse: '/admin/courses/:id/toggle-status',
getCourseTemplatesList: '/admin/course-templates',
addNewCourseTemplate: '/admin/course-templates',
showCourseTemplate: '/admin/course-templates/:id',
updateCourseTemplate: '/admin/course-templates/:id',
deleteCourseTemplate: '/admin/course-templates/:id',
getCourseTemplatesList: '/admin/courses',
addNewCourseTemplate: '/admin/courses',
showCourseTemplate: '/admin/courses/:id',
updateCourseTemplate: '/admin/courses/:id',
deleteCourseTemplate: '/admin/courses/:id',
changeStatusCourseTemplate: '/admin/course-templates/:id/status',
listTemplateStudents: '/admin/course-templates/:templateId/students',
@@ -98,11 +98,11 @@ export const endpoints = {
attachTemplateSession: '/admin/course-templates/:templateId/sessions',
detachTemplateSession: '/admin/course-templates/:templateId/sessions/:sessionId',
getSessionsList: '/admin/sessions',
addNewSession: '/admin/sessions',
showSession: '/admin/sessions/:id',
updateSession: '/admin/sessions/:id',
deleteSession: '/admin/sessions/:id',
getSessionsList: '/sessions',
addNewSession: '/sessions',
showSession: '/sessions/:id',
updateSession: '/sessions/:id',
deleteSession: '/sessions/:id',
changeStatusSession: '/admin/sessions/:id/toggle-status',
getSessionsAttendance: '/admin/sessions/:sessionId/attendances',
+3 -3
View File
@@ -1,6 +1,6 @@
const TOKEN = 'user_token'
const REFRESH_TOKEN = 'refresh_user_token'
const USER_INFO = 'user_info'
const TOKEN = 'accessToken'
const REFRESH_TOKEN = 'refreshToken'
const USER_INFO = 'userInfo'
const STUDENT_REGISTRATION_INFO = 'StudentRegistration'
export const tokenService = {
+54 -12
View File
@@ -37,53 +37,95 @@ export const adminCourseTemplates = [
},
]
// Offered courses carry both the spec keys (term_id/teacher_id/
// description/capacity/is_active/cover_url) and the UI-only keys the
// current frontend reads (image, template/templateId, term/teacher
// nested objects, prerequisitesCount, startDate, endDate). See
// docs/backend-api-todo.md.
const makeTeacherSnapshot = (overrides) => ({
id: overrides.id,
// --- spec ---
name: overrides.name ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
email: overrides.email ?? '',
phone: overrides.phone ?? null,
roles: overrides.roles ?? ['teacher'],
avatarUrl: overrides.avatarUrl ?? null,
avatarDownloadUrl: overrides.avatarDownloadUrl ?? null,
createdAt: overrides.createdAt ?? '',
// --- ui-only ---
firstName: overrides.firstName ?? '',
lastName: overrides.lastName ?? '',
fullName:
overrides.fullName ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
})
export const adminOfferedCourses = [
{
// --- spec ---
id: 11,
termId: 1,
teacherId: 5,
title: 'اصول اخلاق اسلامی - پاییز',
description: 'دوره مقدماتی اخلاق اسلامی برای ترم پاییز.',
capacity: 30,
isActive: true,
coverUrl: 'https://picsum.photos/seed/offered1/200/200',
// --- ui-only ---
image: 'https://picsum.photos/seed/offered1/200/200',
template: { id: 1, title: 'اصول اخلاق اسلامی' },
templateId: 1,
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
termId: 1,
teacher: { id: 5, firstName: 'علی', lastName: 'حسنی', fullName: 'علی حسنی' },
capacity: 30,
isActive: true,
teacher: makeTeacherSnapshot({ id: 5, firstName: 'علی', lastName: 'حسنی' }),
prerequisitesCount: 0,
startDate: '2025-09-23T00:00:00.000Z',
endDate: '2025-11-20T00:00:00.000Z',
createdAt: '2025-09-01T08:00:00.000Z',
},
{
// --- spec ---
id: 12,
termId: 1,
teacherId: 6,
title: 'مفاهیم قرآنی - پاییز',
description: 'مرور مفاهیم قرآنی به همراه تفسیر مختصر.',
capacity: 25,
isActive: true,
coverUrl: 'https://picsum.photos/seed/offered2/200/200',
// --- ui-only ---
image: 'https://picsum.photos/seed/offered2/200/200',
template: { id: 2, title: 'مفاهیم قرآنی' },
templateId: 2,
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
termId: 1,
teacher: { id: 6, firstName: 'حسین', lastName: 'مرادی', fullName: 'حسین مرادی' },
capacity: 25,
isActive: true,
teacher: makeTeacherSnapshot({ id: 6, firstName: 'حسین', lastName: 'مرادی' }),
prerequisitesCount: 1,
startDate: '2025-10-01T00:00:00.000Z',
endDate: '2025-12-15T00:00:00.000Z',
createdAt: '2025-09-10T08:00:00.000Z',
},
{
// --- spec ---
id: 13,
termId: 2,
teacherId: 7,
title: 'فقه عبادات - زمستان',
description: 'مرور احکام عملی نماز و روزه برای ترم زمستان.',
capacity: 20,
isActive: false,
coverUrl: '',
// --- ui-only ---
image: '',
template: { id: 3, title: 'فقه عبادات' },
templateId: 3,
term: { id: 2, title: 'ترم زمستان ۱۴۰۴' },
termId: 2,
teacher: { id: 7, firstName: 'مهدی', lastName: 'سهرابی', fullName: 'مهدی سهرابی' },
capacity: 20,
isActive: false,
teacher: makeTeacherSnapshot({ id: 7, firstName: 'مهدی', lastName: 'سهرابی' }),
prerequisitesCount: 0,
startDate: '2026-01-22T00:00:00.000Z',
endDate: '2026-03-15T00:00:00.000Z',
createdAt: '2025-12-10T08:00:00.000Z',
},
]
export { makeTeacherSnapshot }
+54 -12
View File
@@ -1,6 +1,50 @@
// Maps the FE's 7-value `sessionType` to the spec's 3-value `type`.
const SESSION_TYPE_TO_SPEC = {
in_person: 'offline',
online: 'online',
video: 'content',
audio: 'content',
text: 'content',
slide: 'content',
pdf: 'content',
}
const makeSession = (overrides) => {
const sessionType = overrides.sessionType ?? ''
const sessionConfig = overrides.sessionConfig ?? {}
const startsAt = overrides.startsAt ?? sessionConfig.startTime ?? null
const location = overrides.location ?? sessionConfig.location ?? null
const link = overrides.link ?? sessionConfig.meetingLink ?? null
return {
// --- spec ---
id: overrides.id,
courseId: overrides.courseId ?? null,
title: overrides.title ?? '',
description: overrides.description ?? '',
type: overrides.type ?? SESSION_TYPE_TO_SPEC[sessionType] ?? null,
startsAt,
location,
link,
media: overrides.media ?? [],
// --- ui-only ---
image: overrides.image ?? '',
courseTemplate: overrides.courseTemplate ?? null,
sessionType,
sessionTypeFa: overrides.sessionTypeFa ?? '',
durationMinutes: overrides.durationMinutes ?? 0,
order: overrides.order ?? 1,
sessionConfig,
materials: overrides.materials ?? [],
usedInTerms: overrides.usedInTerms ?? [],
createdAt: overrides.createdAt ?? '',
}
}
export const adminSessions = [
{
makeSession({
id: 101,
courseId: 11,
title: 'مقدمه‌ای بر اخلاق اسلامی',
description: 'جلسه نخست؛ تعاریف و چارچوب دوره.',
image: 'https://picsum.photos/seed/session1/200/200',
@@ -13,15 +57,14 @@ export const adminSessions = [
startTime: '2025-09-25T16:00:00.000Z',
location: 'سالن آمفی‌تئاتر ۲ - مدرسه قم',
},
materials: [],
usedInTerms: [{ termId: 1 }],
createdAt: '2025-09-10T08:00:00.000Z',
},
{
}),
makeSession({
id: 102,
courseId: 12,
title: 'تفسیر سوره حمد',
description: 'تحلیل آیات سوره حمد.',
image: '',
courseTemplate: { id: 2, title: 'مفاهیم قرآنی' },
sessionType: 'online',
sessionTypeFa: 'آنلاین',
@@ -32,15 +75,14 @@ export const adminSessions = [
platform: 'google_meet',
startTime: '2025-10-04T19:00:00.000Z',
},
materials: [],
usedInTerms: [{ termId: 1 }],
createdAt: '2025-09-25T08:00:00.000Z',
},
{
}),
makeSession({
id: 103,
courseId: 13,
title: 'احکام نماز جماعت',
description: 'مرور احکام و شرایط نماز جماعت.',
image: '',
courseTemplate: { id: 3, title: 'فقه عبادات' },
sessionType: 'video',
sessionTypeFa: 'ویدئو',
@@ -50,12 +92,12 @@ export const adminSessions = [
minWatchedPercent: 80,
mustCompleteBeforeNext: true,
},
materials: [],
usedInTerms: [],
createdAt: '2026-01-12T08:00:00.000Z',
},
}),
]
export { makeSession, SESSION_TYPE_TO_SPEC }
export const sessionAttendance = new Map([
[
101,
+45 -15
View File
@@ -1,42 +1,72 @@
// Each term carries both the spec keys (title/description/is_active/
// starts_at/ends_at/cover_url/created_at — camelized) and the UI-only
// keys the current frontend reads (image, startDate, endDate,
// studentsCount, coursesCount). See docs/backend-api-todo.md.
const makeTerm = (overrides) => {
const startsAt = overrides.startsAt ?? overrides.startDate ?? ''
const endsAt = overrides.endsAt ?? overrides.endDate ?? ''
const coverUrl = overrides.coverUrl ?? overrides.image ?? ''
return {
// --- spec ---
id: overrides.id,
title: overrides.title ?? '',
description: overrides.description ?? '',
isActive: overrides.isActive ?? true,
startsAt,
endsAt,
coverUrl,
createdAt: overrides.createdAt ?? '',
// --- ui-only ---
image: coverUrl,
startDate: startsAt,
endDate: endsAt,
studentsCount: overrides.studentsCount ?? 0,
coursesCount: overrides.coursesCount ?? 0,
}
}
export const adminTerms = [
{
makeTerm({
id: 1,
title: 'ترم پاییز ۱۴۰۴',
description: 'ترم پاییز با محوریت آموزش معارف اسلامی.',
image: 'https://picsum.photos/seed/term1/200/200',
startDate: '2025-09-23T00:00:00.000Z',
endDate: '2026-01-20T00:00:00.000Z',
coverUrl: 'https://picsum.photos/seed/term1/200/200',
startsAt: '2025-09-23T00:00:00.000Z',
endsAt: '2026-01-20T00:00:00.000Z',
isActive: true,
studentsCount: 18,
coursesCount: 4,
createdAt: '2025-08-01T08:00:00.000Z',
},
{
}),
makeTerm({
id: 2,
title: 'ترم زمستان ۱۴۰۴',
description: 'ترم زمستان با تمرکز بر فقه و اصول.',
image: 'https://picsum.photos/seed/term2/200/200',
startDate: '2026-01-21T00:00:00.000Z',
endDate: '2026-04-20T00:00:00.000Z',
coverUrl: 'https://picsum.photos/seed/term2/200/200',
startsAt: '2026-01-21T00:00:00.000Z',
endsAt: '2026-04-20T00:00:00.000Z',
isActive: true,
studentsCount: 12,
coursesCount: 3,
createdAt: '2025-11-10T08:00:00.000Z',
},
{
}),
makeTerm({
id: 3,
title: 'ترم بهار ۱۴۰۵',
description: 'ترم بهار ویژه دوره‌های تخصصی.',
image: '',
startDate: '2026-04-21T00:00:00.000Z',
endDate: '2026-08-21T00:00:00.000Z',
coverUrl: '',
startsAt: '2026-04-21T00:00:00.000Z',
endsAt: '2026-08-21T00:00:00.000Z',
isActive: false,
studentsCount: 0,
coursesCount: 2,
createdAt: '2026-02-15T08:00:00.000Z',
},
}),
]
export { makeTerm }
export const termStudentLinks = new Map([
[
1,
+14 -5
View File
@@ -66,16 +66,26 @@ const makeUser = (i, overrides = {}) => {
const province = provinceById((i % 4) + 1)
const city = cityForProvince(province.id)
const role = roles[(i % 4) + 1]?.name || 'student'
const id = 100 + i
const phoneNumber = `0912${String(1_000_000 + i).padStart(7, '0')}`
return {
id: 100 + i,
// --- spec (GET /admin/users) ---
id,
name: `${firstName} ${lastName}`,
email: `user${id}@example.com`,
phone: phoneNumber.replace(/^0/, '+98'),
roles: [role],
avatarUrl: '',
avatarDownloadUrl: '',
createdAt: new Date(Date.now() - i * 86_400_000).toISOString(),
// --- ui-only (pending backend) ---
firstName,
lastName,
fullName: `${firstName} ${lastName}`,
phoneNumber: `0912${String(1_000_000 + i).padStart(7, '0')}`,
phoneNumber,
nationalCode: String(1_000_000_000 + i * 7).padStart(10, '0'),
avatarUrl: '',
status: i % 5 === 0 ? 'pending' : 'approved',
roles: [role],
roleId: roles.find((r) => r.name === role)?.id,
address: {
address: 'آدرس نمونه',
@@ -88,7 +98,6 @@ const makeUser = (i, overrides = {}) => {
maritalStatus: i % 2 ? 'single' : 'married',
gender: i % 2 ? 'female' : 'male',
},
createdAt: new Date(Date.now() - i * 86_400_000).toISOString(),
...overrides,
}
}
@@ -1,13 +1,25 @@
export const currentProfile = {
id: 1,
// Backend `/auth/me` currently returns the keys marked "spec" below.
// The "ui-only" keys are extras the UI already depends on (edit-profile form,
// avatars, address, etc.) — they must be preserved in the mock until the
// backend completes the response model. See docs/backend-api-todo.md.
export const currentMe = {
// --- spec (GET /auth/me) ---
id: 5,
name: 'سجاد محمدی',
email: 'sajjad@example.com',
phone: '+989121234567',
roles: ['student'],
avatarUrl: 'http://localhost:8080/storage/users/5/avatar.jpg',
avatarDownloadUrl: 'http://localhost:8080/api/media/9/download',
createdAt: '2026-02-01T10:00:00+00:00',
// --- ui-only (pending backend) ---
firstName: 'سجاد',
lastName: 'محمدی',
fullName: 'سجاد محمدی',
phoneNumber: '09123456789',
nationalCode: '0079827498',
avatarUrl: '',
status: 'approved',
roles: ['user'],
address: {
address: 'خیابان آزادی، پلاک ۱۲',
province: { id: 1, name: 'تهران' },
+98 -32
View File
@@ -2,7 +2,11 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminTerms } from '@/services/mock/fixtures/admin-terms'
import { adminUsers } from '@/services/mock/fixtures/admin-users'
import { adminCourseTemplates, adminOfferedCourses } from '@/services/mock/fixtures/admin-courses'
import {
adminCourseTemplates,
adminOfferedCourses,
makeTeacherSnapshot,
} from '@/services/mock/fixtures/admin-courses'
import {
filterDateRange,
filterItems,
@@ -99,66 +103,128 @@ register('GET', endpoints.getCoursesList, ({ query }) => {
status: (item, v) => String(item.isActive ? 1 : 0) === String(v),
})
list = filterDateRange(list, query, 'startDate')
return paginate(list, query)
const { data: items, meta } = paginate(list, query)
return {
success: true,
message: 'OK',
data: { items, meta },
}
})
register('GET', endpoints.showCourse, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(adminOfferedCourses, params.id),
}))
register('POST', endpoints.addNewCourse, ({ data }) => {
const template = adminCourseTemplates.find((t) => t.id === Number(data.templateId))
const term = adminTerms.find((t) => t.id === Number(data.termId))
const teacher = adminUsers.find((u) => u.id === Number(data.teacherId))
const teacherSource = adminUsers.find((u) => u.id === Number(data.teacherId))
const teacher = teacherSource
? makeTeacherSnapshot({
id: teacherSource.id,
firstName: teacherSource.firstName,
lastName: teacherSource.lastName,
name: teacherSource.name,
email: teacherSource.email,
phone: teacherSource.phone,
roles: teacherSource.roles,
avatarUrl: teacherSource.avatarUrl,
avatarDownloadUrl: teacherSource.avatarDownloadUrl,
createdAt: teacherSource.createdAt,
})
: null
const coverFromUpload = data.imageId
? `https://picsum.photos/seed/offered-${data.imageId}/200/200`
: ''
const coverUrl = data.coverUrl ?? coverFromUpload
const item = {
// --- spec ---
id: makeId(),
termId: term?.id ?? Number(data.termId) ?? null,
teacherId: teacher?.id ?? (Number(data.teacherId) || null),
title: data.title || template?.title || '',
image: data.imageId ? `https://picsum.photos/seed/offered-${data.imageId}/200/200` : '',
description: data.description || '',
capacity: Number(data.capacity) || 0,
isActive: data.isActive !== undefined ? !!data.isActive : true,
coverUrl,
// --- ui-only ---
image: coverUrl,
template: template ? { id: template.id, title: template.title } : null,
templateId: template?.id,
term: term ? { id: term.id, title: term.title } : null,
termId: term?.id,
teacher: teacher
? {
id: teacher.id,
firstName: teacher.firstName,
lastName: teacher.lastName,
fullName: `${teacher.firstName} ${teacher.lastName}`,
}
: null,
capacity: Number(data.capacity) || 0,
isActive: !!data.isActive,
teacher,
prerequisitesCount: 0,
startDate: term?.startDate || '',
endDate: term?.endDate || '',
createdAt: isoNow(),
}
adminOfferedCourses.unshift(item)
return { data: item }
return {
success: true,
message: 'Course created.',
data: item,
}
})
register('PUT', endpoints.updateCourse, ({ params, data }) => {
const template = adminCourseTemplates.find((t) => t.id === Number(data.templateId))
const term = adminTerms.find((t) => t.id === Number(data.termId))
register('PATCH', endpoints.updateCourse, ({ params, data }) => {
const patch = {}
if (data.title !== undefined) patch.title = data.title
if (data.description !== undefined) patch.description = data.description
if (data.capacity !== undefined) patch.capacity = Number(data.capacity) || 0
if (data.isActive !== undefined) patch.isActive = !!data.isActive
if (data.termId !== undefined) {
patch.termId = Number(data.termId) || null
const term = adminTerms.find((t) => t.id === Number(data.termId))
if (term) patch.term = { id: term.id, title: term.title }
}
if (data.teacherId !== undefined) {
patch.teacherId = Number(data.teacherId) || null
const t = adminUsers.find((u) => u.id === Number(data.teacherId))
if (t) {
patch.teacher = makeTeacherSnapshot({
id: t.id,
firstName: t.firstName,
lastName: t.lastName,
name: t.name,
email: t.email,
phone: t.phone,
roles: t.roles,
avatarUrl: t.avatarUrl,
avatarDownloadUrl: t.avatarDownloadUrl,
createdAt: t.createdAt,
})
}
}
if (data.templateId !== undefined) {
const template = adminCourseTemplates.find((t) => t.id === Number(data.templateId))
patch.templateId = template?.id
if (template) patch.template = { id: template.id, title: template.title }
}
if (data.coverUrl !== undefined || data.imageId !== undefined) {
const url =
data.coverUrl ??
(data.imageId ? `https://picsum.photos/seed/offered-${data.imageId}/200/200` : '')
patch.coverUrl = url
patch.image = url
}
const updated = updateById(adminOfferedCourses, params.id, patch)
return {
data: updateById(adminOfferedCourses, params.id, {
title: data.title,
image: data.imageId
? `https://picsum.photos/seed/offered-${data.imageId}/200/200`
: undefined,
template: template ? { id: template.id, title: template.title } : undefined,
templateId: template?.id,
term: term ? { id: term.id, title: term.title } : undefined,
termId: term?.id,
capacity: Number(data.capacity) || 0,
isActive: !!data.isActive,
}),
success: true,
message: 'Course updated.',
data: updated,
}
})
register('DELETE', endpoints.deleteCourse, ({ params }) => {
removeById(adminOfferedCourses, params.id)
return { data: { message: 'حذف موفق' } }
return {
success: true,
message: 'Course deleted.',
data: null,
}
})
register('POST', endpoints.changeStatusCourse, ({ params, data }) => ({
+97 -25
View File
@@ -1,8 +1,12 @@
import { SESSION_TYPE } from '@/enums'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminCourseTemplates } from '@/services/mock/fixtures/admin-courses'
import { adminSessions, sessionAttendance } from '@/services/mock/fixtures/admin-sessions'
import { adminCourseTemplates, adminOfferedCourses } from '@/services/mock/fixtures/admin-courses'
import {
adminSessions,
makeSession,
sessionAttendance,
} from '@/services/mock/fixtures/admin-sessions'
import {
filterDateRange,
filterItems,
@@ -19,27 +23,62 @@ const enrich = (session) => ({
sessionTypeFa: SESSION_TYPE[session.sessionType] || session.sessionTypeFa || '',
})
const courseSnapshot = (offered) =>
offered
? {
id: offered.id,
termId: offered.termId,
teacherId: offered.teacherId,
title: offered.title,
description: offered.description,
capacity: offered.capacity,
isActive: offered.isActive,
coverUrl: offered.coverUrl,
}
: null
register('GET', endpoints.getSessionsList, ({ query }) => {
let list = filterItems(adminSessions, query, {
title: 'includes',
courseTemplateId: (item, v) => String(item.courseTemplate?.id) === String(v),
courseId: 'eq',
sessionType: 'eq',
type: 'eq',
})
list = filterDateRange(list, query)
// eslint-disable-next-line unicorn/no-array-callback-reference
return paginate(list.map(enrich), query)
const { data: items, meta } = paginate(list.map(enrich), query)
return {
success: true,
message: 'OK',
data: { items, meta },
}
})
register('GET', endpoints.showSession, ({ params }) => ({
data: enrich(findOrThrow(adminSessions, params.id)),
}))
register('GET', endpoints.showSession, ({ params }) => {
const session = enrich(findOrThrow(adminSessions, params.id))
const offered = adminOfferedCourses.find((c) => c.id === session.courseId)
return {
success: true,
message: 'OK',
data: { ...session, course: courseSnapshot(offered) },
}
})
register('POST', endpoints.addNewSession, ({ data }) => {
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
const item = {
const offered =
adminOfferedCourses.find((c) => c.id === Number(data.courseId)) ||
(tpl ? adminOfferedCourses.find((c) => c.templateId === tpl.id) : null)
const item = makeSession({
id: makeId(),
courseId: data.courseId ?? offered?.id ?? null,
title: data.title || '',
description: data.description || '',
type: data.type,
startsAt: data.startsAt ?? data.sessionConfig?.startTime ?? null,
location: data.location ?? data.sessionConfig?.location ?? null,
link: data.link ?? data.sessionConfig?.meetingLink ?? null,
image: data.imageId ? `https://picsum.photos/seed/session-${data.imageId}/200/200` : '',
courseTemplate: tpl ? { id: tpl.id, title: tpl.title } : null,
sessionType: data.sessionType || 'in_person',
@@ -47,32 +86,65 @@ register('POST', endpoints.addNewSession, ({ data }) => {
order: Number(data.order) || 1,
sessionConfig: data.sessionConfig || {},
materials: data.materials || [],
usedInTerms: [],
createdAt: isoNow(),
}
})
adminSessions.unshift(item)
return { data: enrich(item) }
return {
success: true,
message: 'Session created.',
data: enrich(item),
}
})
register('PUT', endpoints.updateSession, ({ params, data }) => {
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
const updated = updateById(adminSessions, params.id, {
title: data.title,
description: data.description,
image: data.imageId ? `https://picsum.photos/seed/session-${data.imageId}/200/200` : undefined,
courseTemplate: tpl ? { id: tpl.id, title: tpl.title } : undefined,
sessionType: data.sessionType,
durationMinutes: Number(data.durationMinutes) || 0,
order: Number(data.order) || 1,
sessionConfig: data.sessionConfig ?? undefined,
materials: data.materials ?? undefined,
})
return { data: enrich(updated) }
register('PATCH', endpoints.updateSession, ({ params, data }) => {
const patch = {}
if (data.title !== undefined) patch.title = data.title
if (data.description !== undefined) patch.description = data.description
if (data.type !== undefined) patch.type = data.type
if (data.startsAt !== undefined) patch.startsAt = data.startsAt
if (data.location !== undefined) patch.location = data.location
if (data.link !== undefined) patch.link = data.link
if (data.courseId !== undefined) patch.courseId = Number(data.courseId) || null
if (data.courseTemplateId !== undefined) {
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
if (tpl) patch.courseTemplate = { id: tpl.id, title: tpl.title }
}
if (data.sessionType !== undefined) patch.sessionType = data.sessionType
if (data.durationMinutes !== undefined) patch.durationMinutes = Number(data.durationMinutes) || 0
if (data.order !== undefined) patch.order = Number(data.order) || 1
if (data.sessionConfig !== undefined) {
patch.sessionConfig = data.sessionConfig
if (data.startsAt === undefined && data.sessionConfig.startTime !== undefined) {
patch.startsAt = data.sessionConfig.startTime
}
if (data.location === undefined && data.sessionConfig.location !== undefined) {
patch.location = data.sessionConfig.location
}
if (data.link === undefined && data.sessionConfig.meetingLink !== undefined) {
patch.link = data.sessionConfig.meetingLink
}
}
if (data.materials !== undefined) patch.materials = data.materials
if (data.imageId !== undefined) {
patch.image = data.imageId
? `https://picsum.photos/seed/session-${data.imageId}/200/200`
: ''
}
const updated = updateById(adminSessions, params.id, patch)
return {
success: true,
message: 'Session updated.',
data: enrich(updated),
}
})
register('DELETE', endpoints.deleteSession, ({ params }) => {
removeById(adminSessions, params.id)
return { data: { message: 'حذف موفق' } }
return {
success: true,
message: 'Session deleted.',
data: null,
}
})
register('POST', endpoints.changeStatusSession, ({ params, data }) => {
+59 -19
View File
@@ -2,7 +2,7 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminUsers } from '@/services/mock/fixtures/admin-users'
import { adminOfferedCourses } from '@/services/mock/fixtures/admin-courses'
import { adminTerms, termStudentLinks } from '@/services/mock/fixtures/admin-terms'
import { adminTerms, makeTerm, termStudentLinks } from '@/services/mock/fixtures/admin-terms'
import {
filterDateRange,
filterItems,
@@ -18,45 +18,85 @@ register('GET', endpoints.getTermsList, ({ query }) => {
let list = filterItems(adminTerms, query, {
title: 'includes',
status: (item, v) => String(item.isActive ? 1 : 0) === String(v),
activeOnly: (item, v) => (String(v) === '1' ? !!item.isActive : true),
})
list = filterDateRange(list, query, 'startDate')
return paginate(list, query)
const { data: items, meta } = paginate(list, query)
return {
success: true,
message: 'OK',
data: { items, meta },
}
})
register('GET', endpoints.showTerm, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(adminTerms, params.id),
}))
register('POST', endpoints.addNewTerm, ({ data }) => {
const term = {
const coverFromUpload = data.imageId
? `https://picsum.photos/seed/term-${data.imageId}/200/200`
: ''
const term = makeTerm({
id: makeId(),
title: data.title || '',
description: data.description || '',
image: data.imageId ? `https://picsum.photos/seed/term-${data.imageId}/200/200` : '',
startDate: data.startDate || '',
endDate: data.endDate || '',
isActive: true,
isActive: data.isActive ?? true,
startsAt: data.startsAt ?? data.startDate ?? '',
endsAt: data.endsAt ?? data.endDate ?? '',
coverUrl: data.coverUrl ?? coverFromUpload,
studentsCount: 0,
coursesCount: 0,
createdAt: isoNow(),
}
})
adminTerms.unshift(term)
return { data: term }
return {
success: true,
message: 'Term created.',
data: term,
}
})
register('PUT', endpoints.updateTerm, ({ params, data }) => ({
data: updateById(adminTerms, params.id, {
title: data.title,
description: data.description,
startDate: data.startDate,
endDate: data.endDate,
image: data.imageId ? `https://picsum.photos/seed/term-${data.imageId}/200/200` : undefined,
}),
}))
register('PATCH', endpoints.updateTerm, ({ params, data }) => {
const coverFromUpload = data.imageId
? `https://picsum.photos/seed/term-${data.imageId}/200/200`
: undefined
const startsAt = data.startsAt ?? data.startDate
const endsAt = data.endsAt ?? data.endDate
const coverUrl = data.coverUrl ?? coverFromUpload
const patch = {}
if (data.title !== undefined) patch.title = data.title
if (data.description !== undefined) patch.description = data.description
if (data.isActive !== undefined) patch.isActive = data.isActive
if (startsAt !== undefined) {
patch.startsAt = startsAt
patch.startDate = startsAt
}
if (endsAt !== undefined) {
patch.endsAt = endsAt
patch.endDate = endsAt
}
if (coverUrl !== undefined) {
patch.coverUrl = coverUrl
patch.image = coverUrl
}
const updated = updateById(adminTerms, params.id, patch)
return {
success: true,
message: 'Term updated.',
data: updated,
}
})
register('DELETE', endpoints.deleteTerm, ({ params }) => {
removeById(adminTerms, params.id)
return { data: { message: 'حذف موفق' } }
return {
success: true,
message: 'Term deleted.',
data: null,
}
})
register('POST', endpoints.cloneTerm, ({ params }) => {
+86 -23
View File
@@ -21,28 +21,51 @@ register('GET', endpoints.getApprovedUsers, ({ query }) => {
roleId: (item, v) => String(item.roleId) === String(v),
})
list = filterDateRange(list, query)
return paginate(list, query)
const { data: items, meta } = paginate(list, query)
return {
success: true,
message: 'OK',
data: { items, meta },
}
})
register('GET', endpoints.showUserDetails, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(adminUsers, params.id),
}))
register('POST', endpoints.addNewUser, ({ data }) => {
const id = makeId()
const role = roles.find((r) => r.id === Number(data.roleId)) || null
const roleByName = (n) => roles.find((r) => r.name === n)
const role =
roles.find((r) => r.id === Number(data.roleId)) ||
(Array.isArray(data.roles) && data.roles[0] ? roleByName(data.roles[0]) : null) ||
null
const province = data.provinceId ? { id: data.provinceId, name: '' } : null
const city = data.cityId ? { id: data.cityId, name: '' } : null
const firstName = data.firstName || ''
const lastName = data.lastName || ''
const fullName = `${firstName} ${lastName}`.trim()
const phoneNumber = data.phoneNumber || ''
const user = {
// --- spec ---
id,
firstName: data.firstName || '',
lastName: data.lastName || '',
fullName: `${data.firstName || ''} ${data.lastName || ''}`.trim(),
phoneNumber: data.phoneNumber || '',
nationalCode: data.nationalCode || '',
name: data.name || fullName,
email: data.email || '',
phone: data.phone || (phoneNumber ? phoneNumber.replace(/^0/, '+98') : null),
roles: Array.isArray(data.roles) ? data.roles : role ? [role.name] : [],
avatarUrl: '',
avatarDownloadUrl: null,
createdAt: isoNow(),
// --- ui-only ---
firstName,
lastName,
fullName,
phoneNumber,
nationalCode: data.nationalCode || '',
status: 'approved',
roles: role ? [role.name] : [],
roleId: role?.id,
address: { address: data.address || '', province, city },
profile: {
@@ -52,22 +75,39 @@ register('POST', endpoints.addNewUser, ({ data }) => {
gender: data.gender || '',
avatarId: data.avatarId || null,
},
createdAt: isoNow(),
}
adminUsers.unshift(user)
return { data: user }
return {
success: true,
message: 'User created.',
data: user,
}
})
register('PUT', endpoints.updateUser, ({ params, data }) => {
const role = roles.find((r) => r.id === Number(data.roleId))
register('PATCH', endpoints.updateUser, ({ params, data }) => {
const roleByName = (n) => roles.find((r) => r.name === n)
const role =
roles.find((r) => r.id === Number(data.roleId)) ||
(Array.isArray(data.roles) && data.roles[0] ? roleByName(data.roles[0]) : null) ||
null
const firstName = data.firstName ?? ''
const lastName = data.lastName ?? ''
const fullName = `${firstName} ${lastName}`.trim()
const phoneNumber = data.phoneNumber ?? ''
const patch = {
firstName: data.firstName,
lastName: data.lastName,
fullName: `${data.firstName || ''} ${data.lastName || ''}`.trim(),
phoneNumber: data.phoneNumber,
// --- spec ---
name: data.name ?? fullName,
email: data.email,
phone: data.phone ?? (phoneNumber ? phoneNumber.replace(/^0/, '+98') : undefined),
roles: Array.isArray(data.roles) ? data.roles : role ? [role.name] : [],
// --- ui-only ---
firstName,
lastName,
fullName,
phoneNumber,
nationalCode: data.nationalCode,
roleId: role?.id,
roles: role ? [role.name] : [],
address: {
address: data.address || '',
province: data.provinceId ? { id: data.provinceId, name: '' } : null,
@@ -82,13 +122,32 @@ register('PUT', endpoints.updateUser, ({ params, data }) => {
},
}
const updated = updateById(adminUsers, params.id, patch)
return { data: updated }
return {
success: true,
message: 'User updated.',
data: updated,
}
})
register('POST', endpoints.updateUserRole, ({ params, data }) => {
const role = roles.find((r) => r.id === Number(data.roleId))
const patch = role ? { roleId: role.id, roles: [role.name] } : {}
return { data: updateById(adminUsers, params.id, patch) }
register('PATCH', endpoints.updateUserRole, ({ params, data }) => {
const byName = (n) => roles.find((r) => r.name === n)
const nextRoleNames = Array.isArray(data.roles)
? data.roles.filter(Boolean)
: data.role
? [data.role]
: data.roleId
? [roles.find((r) => r.id === Number(data.roleId))?.name].filter(Boolean)
: []
const primary = nextRoleNames[0] ? byName(nextRoleNames[0]) : null
const patch = nextRoleNames.length
? { roles: nextRoleNames, roleId: primary?.id }
: {}
const updated = updateById(adminUsers, params.id, patch)
return {
success: true,
message: 'Roles updated.',
data: updated,
}
})
register('POST', endpoints.changeUserStatus, ({ params, data }) => ({
@@ -97,7 +156,11 @@ register('POST', endpoints.changeUserStatus, ({ params, data }) => ({
register('DELETE', endpoints.deleteUser, ({ params }) => {
removeById(adminUsers, params.id)
return { data: { message: 'حذف موفق' } }
return {
success: true,
message: 'User deleted.',
data: null,
}
})
register('GET', endpoints.getRolesList, () => ({ data: roles }))
+34 -25
View File
@@ -1,24 +1,24 @@
import { makeId } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { currentProfile } from '@/services/mock/fixtures/profile'
import { currentMe } from '@/services/mock/fixtures/me'
const fakeToken = 'mock-token-1234567890'
register('POST', endpoints.login, () => ({
data: { user: currentProfile, token: fakeToken },
data: { user: currentMe, token: fakeToken },
}))
register('POST', endpoints.loginWithCode2Step, () => ({
data: { user: currentProfile, token: fakeToken },
data: { user: currentMe, token: fakeToken },
}))
register('POST', endpoints.verifyCode, () => ({
data: { user: currentProfile, token: fakeToken },
data: { user: currentMe, token: fakeToken },
}))
register('POST', endpoints.register, ({ data }) => ({
data: { user: { ...currentProfile, ...data }, token: fakeToken },
data: { user: { ...currentMe, ...data }, token: fakeToken },
}))
register('POST', endpoints.resendVerificationCodeForRegister, () => ({
@@ -39,47 +39,56 @@ register('POST', endpoints.resetPassword, () => ({
register('POST', endpoints.logout, () => ({ data: { message: 'خروج موفق' } }))
register('GET', endpoints.getProfile, () => ({ data: currentProfile }))
register('GET', endpoints.me, () => ({
success: true,
message: 'OK',
data: currentMe,
}))
register('PUT', endpoints.updateProfile, ({ data }) => {
Object.assign(currentProfile, {
firstName: data.firstName ?? currentProfile.firstName,
lastName: data.lastName ?? currentProfile.lastName,
phoneNumber: data.phoneNumber ?? currentProfile.phoneNumber,
nationalCode: data.nationalCode ?? currentProfile.nationalCode,
Object.assign(currentMe, {
name: data.name ?? currentMe.name,
email: data.email ?? currentMe.email,
phone: data.phone ?? currentMe.phone,
firstName: data.firstName ?? currentMe.firstName,
lastName: data.lastName ?? currentMe.lastName,
phoneNumber: data.phoneNumber ?? currentMe.phoneNumber,
nationalCode: data.nationalCode ?? currentMe.nationalCode,
})
if (data.provinceId) {
currentProfile.address = currentProfile.address || {}
currentProfile.address.province = { id: data.provinceId, name: '' }
currentMe.address = currentMe.address || {}
currentMe.address.province = { id: data.provinceId, name: '' }
}
if (data.cityId) {
currentProfile.address = currentProfile.address || {}
currentProfile.address.city = { id: data.cityId, name: '' }
currentMe.address = currentMe.address || {}
currentMe.address.city = { id: data.cityId, name: '' }
}
if (data.address) {
currentProfile.address = { ...currentProfile.address, address: data.address }
currentMe.address = { ...currentMe.address, address: data.address }
}
currentProfile.profile = {
...currentProfile.profile,
bio: data.bio ?? currentProfile.profile?.bio,
birthDate: data.birthDate ?? currentProfile.profile?.birthDate,
maritalStatus: data.maritalStatus ?? currentProfile.profile?.maritalStatus,
gender: data.gender ?? currentProfile.profile?.gender,
currentMe.profile = {
...currentMe.profile,
bio: data.bio ?? currentMe.profile?.bio,
birthDate: data.birthDate ?? currentMe.profile?.birthDate,
maritalStatus: data.maritalStatus ?? currentMe.profile?.maritalStatus,
gender: data.gender ?? currentMe.profile?.gender,
}
return { data: currentProfile }
return { data: currentMe }
})
register('POST', endpoints.completeProfile, ({ data }) => ({
data: { user: { ...currentProfile, ...data } },
data: { user: { ...currentMe, ...data } },
}))
register('GET', endpoints.getRegistrationQuestionVideo, () => ({
data: { url: '', id: 0 },
}))
register('POST', endpoints.uploadMediaTemporary, () => {
register('POST', endpoints.uploadMedia, () => {
const id = makeId()
return {
success: true,
message: 'Media uploaded.',
data: {
id,
uploadId: id,
+4
View File
@@ -20,6 +20,10 @@ export const useAdminCoursesListQuery = (filtersRef, paginationRef, options = {}
queryKey: ['admin', 'courses', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetAdminCourses({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
+4
View File
@@ -29,6 +29,10 @@ export const useAdminSessionsListQuery = (filtersRef, paginationRef, options = {
queryKey: ['admin', 'sessions', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetAdminSessions({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
+4
View File
@@ -43,6 +43,10 @@ export const useAdminTermsListQuery = (filtersRef, paginationRef, options = {})
useQuery({
queryKey: ['admin', 'terms', 'list', filtersRef, paginationRef],
queryFn: () => apiGetAdminTerms({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
+4
View File
@@ -22,6 +22,10 @@ export const useAdminUsersListQuery = (filtersRef, paginationRef, options = {})
useQuery({
queryKey: ['admin', 'users', 'list', filtersRef, paginationRef],
queryFn: () => apiGetAdminUsers({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
+7 -7
View File
@@ -3,7 +3,7 @@ import { useMutation, useQuery } from '@tanstack/vue-query'
import {
apiCompleteProfile,
apiForgotPassword,
apiGetProfile,
apiGetMe,
apiGetRegistrationQuestionVideo,
apiLogin,
apiLoginWithCode,
@@ -12,13 +12,13 @@ import {
apiResendVerificationCode,
apiResetPassword,
apiUpdateProfile,
apiUploadTemporary,
apiUploadMedia,
apiVerifyCode,
apiVerifyForgotPasswordCode,
} from '@/services/api/auth'
export const authKeys = {
profile: () => ['auth', 'profile'],
me: () => ['auth', 'me'],
registrationVideo: () => ['auth', 'registration-video'],
}
@@ -42,10 +42,10 @@ export const useResetPasswordMutation = () => useMutation({ mutationFn: apiReset
export const useLogoutMutation = () => useMutation({ mutationFn: apiLogout })
export const useGetProfileQuery = (options = {}) =>
export const useGetMeQuery = (options = {}) =>
useQuery({
queryKey: authKeys.profile(),
queryFn: () => apiGetProfile(),
queryKey: authKeys.me(),
queryFn: () => apiGetMe(),
select: (response) => response?.data?.user ?? response?.data ?? response,
...options,
})
@@ -62,4 +62,4 @@ export const useGetRegistrationVideoQuery = (options = {}) =>
...options,
})
export const useUploadTemporaryMutation = () => useMutation({ mutationFn: apiUploadTemporary })
export const useUploadMediaMutation = () => useMutation({ mutationFn: apiUploadMedia })
+126
View File
@@ -0,0 +1,126 @@
import jalaali from 'jalaali-js'
export function gregorianToJalaliString(isoString, separator = '/', persianDigits = false) {
if (!isoString) return ''
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!match) return ''
const gy = Number.parseInt(match[1], 10)
const gm = Number.parseInt(match[2], 10)
const gd = Number.parseInt(match[3], 10)
try {
const jalali = jalaali.toJalaali(gy, gm, gd)
let jy = String(jalali.jy)
let jm = String(jalali.jm).padStart(2, '0')
let jd = String(jalali.jd).padStart(2, '0')
if (persianDigits) {
const enToFa = {
0: '۰',
1: '۱',
2: '۲',
3: '۳',
4: '۴',
5: '۵',
6: '۶',
7: '۷',
8: '۸',
9: '۹',
}
jy = jy.replace(/\d/g, (d) => enToFa[d])
jm = jm.replace(/\d/g, (d) => enToFa[d])
jd = jd.replace(/\d/g, (d) => enToFa[d])
}
return `${jy}${separator}${jm}${separator}${jd}`
} catch (error) {
console.error('Date conversion error:', error)
return ''
}
}
export function gregorianToJalaliStringLong(isoString, separator = '/', persianDigits = false) {
if (!isoString) return ''
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/)
if (!match) return ''
const gy = Number.parseInt(match[1], 10)
const gm = Number.parseInt(match[2], 10)
const gd = Number.parseInt(match[3], 10)
const hour = match[4]
const minute = match[5]
try {
const jalali = jalaali.toJalaali(gy, gm, gd)
let jy = String(jalali.jy)
let jm = String(jalali.jm).padStart(2, '0')
let jd = String(jalali.jd).padStart(2, '0')
let hh = hour
let mm = minute
if (persianDigits) {
const enToFa = {
0: '۰',
1: '۱',
2: '۲',
3: '۳',
4: '۴',
5: '۵',
6: '۶',
7: '۷',
8: '۸',
9: '۹',
}
jy = jy.replace(/\d/g, (d) => enToFa[d])
jm = jm.replace(/\d/g, (d) => enToFa[d])
jd = jd.replace(/\d/g, (d) => enToFa[d])
hh = hh.replace(/\d/g, (d) => enToFa[d])
mm = mm.replace(/\d/g, (d) => enToFa[d])
}
return ` ${jy}${separator}${jm}${separator}${jd}، ${hh}:${mm}`
} catch (error) {
console.error('Date conversion error:', error)
return ''
}
}
export function JalaliToGregorianString(jalaliStr) {
try {
if (!jalaliStr) return null
const [jy, jm, jd] = jalaliStr.split('/').map(Number)
const greg = jalaali.toGregorian(jy, jm, jd)
return `${greg.gy}-${String(greg.gm).padStart(2, '0')}-${String(greg.gd).padStart(
2,
'0'
)}T00:00:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian conversion error:', error)
return ''
}
}
export function JalaliToGregorianStringWithTime(jalaliDateTimeStr) {
try {
if (!jalaliDateTimeStr) return null
const parts = jalaliDateTimeStr.split(' ')
const datePart = parts[0]
const timePart = parts[1] || '00:00'
const [jy, jm, jd] = datePart.split('/').map(Number)
const greg = jalaali.toGregorian(jy, jm, jd)
return `${greg.gy}-${String(greg.gm).padStart(2, '0')}-${String(greg.gd).padStart(
2,
'0'
)}T${timePart}:00.000Z`
} catch (error) {
console.log('Jalali to Gregorian with time conversion error:', error)
return ''
}
}