fix: design
This commit is contained in:
@@ -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
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: 'تهران' },
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user