fix: refactor event handling and validation logic in CalendarEventPanel and EventModal components
This commit is contained in:
parent
8031964d3d
commit
1e99192448
@ -351,9 +351,14 @@ import Link from '@/components/Controls/Link.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { validateEmail } from '@/utils'
|
||||
import { allTimeSlots } from '@/components/Calendar/utils'
|
||||
import {
|
||||
normalizeParticipants,
|
||||
buildEndTimeOptions,
|
||||
computeAutoToTime,
|
||||
validateTimeRange,
|
||||
parseEventDoc,
|
||||
} from '@/composables/event'
|
||||
import {
|
||||
TextInput,
|
||||
Switch,
|
||||
@ -389,7 +394,6 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter()
|
||||
const { $dialog } = globalStore()
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const show = defineModel()
|
||||
const event = defineModel('event')
|
||||
@ -401,18 +405,7 @@ const peoples = computed({
|
||||
return _event.value.event_participants || []
|
||||
},
|
||||
set(list) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const a of list || []) {
|
||||
if (!a?.email || seen.has(a.email)) continue
|
||||
seen.add(a.email)
|
||||
out.push({
|
||||
email: a.email,
|
||||
reference_doctype: a.reference_doctype || 'Contact',
|
||||
reference_docname: a.reference_docname || '',
|
||||
})
|
||||
}
|
||||
_event.value.event_participants = out
|
||||
_event.value.event_participants = normalizeParticipants(list)
|
||||
sync()
|
||||
},
|
||||
})
|
||||
@ -461,12 +454,12 @@ function fetchEvent() {
|
||||
name: event.value.id,
|
||||
fields: ['*'],
|
||||
onSuccess: (data) => {
|
||||
_event.value = parseEvent(data)
|
||||
_event.value = parseEventDoc(data)
|
||||
oldEvent.value = { ..._event.value }
|
||||
},
|
||||
})
|
||||
if (eventResource.value.doc && !event.value.reloadEvent) {
|
||||
_event.value = parseEvent(eventResource.value.doc)
|
||||
_event.value = parseEventDoc(eventResource.value.doc)
|
||||
oldEvent.value = { ..._event.value }
|
||||
} else {
|
||||
eventResource.value.reload()
|
||||
@ -478,30 +471,6 @@ function fetchEvent() {
|
||||
showAllParticipants.value = false
|
||||
}
|
||||
|
||||
function parseEvent(_e) {
|
||||
return {
|
||||
id: _e.name,
|
||||
title: _e.subject,
|
||||
description: _e.description,
|
||||
status: _e.status,
|
||||
fromDate: dayjs(_e.starts_on).format('YYYY-MM-DD'),
|
||||
toDate: dayjs(_e.ends_on).format('YYYY-MM-DD'),
|
||||
fromTime: dayjs(_e.starts_on).format('HH:mm'),
|
||||
toTime: dayjs(_e.ends_on).format('HH:mm'),
|
||||
isFullDay: _e.all_day,
|
||||
eventType: _e.event_type,
|
||||
color: _e.color,
|
||||
referenceDoctype: _e.reference_doctype,
|
||||
referenceDocname: _e.reference_docname,
|
||||
event_participants: _e.event_participants || [],
|
||||
owner: {
|
||||
label: getUser(_e.owner).full_name,
|
||||
image: getUser(_e.owner).user_image,
|
||||
value: _e.owner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnTitle() {
|
||||
setTimeout(() => {
|
||||
if (['edit', 'new', 'duplicate'].includes(props.mode)) {
|
||||
@ -523,63 +492,27 @@ function updateDate(d) {
|
||||
|
||||
function updateTime(t, fromTime = false) {
|
||||
error.value = null
|
||||
|
||||
const prevTo = _event.value.toTime
|
||||
if (fromTime) {
|
||||
_event.value.fromTime = t
|
||||
const hour = parseInt(t.split(':')[0])
|
||||
const minute = parseInt(t.split(':')[1])
|
||||
|
||||
const computePlusHour = () => {
|
||||
let nh = hour + 1
|
||||
let nm = minute
|
||||
if (nh >= 24) {
|
||||
nh = 23
|
||||
nm = 59
|
||||
}
|
||||
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
|
||||
}
|
||||
if (!_event.value.toTime) {
|
||||
_event.value.toTime = computePlusHour()
|
||||
} else if (_event.value.toTime <= t) {
|
||||
_event.value.toTime = computePlusHour()
|
||||
if (!_event.value.toTime || _event.value.toTime <= t) {
|
||||
_event.value.toTime = computeAutoToTime(t)
|
||||
}
|
||||
} else {
|
||||
_event.value.toTime = t
|
||||
}
|
||||
|
||||
validateFromToTime() && sync()
|
||||
}
|
||||
|
||||
function validateFromToTime() {
|
||||
// Generic validator for start/end times before saving.
|
||||
// Returns true if valid, else sets error message and returns false.
|
||||
error.value = null
|
||||
// Full day events don't require time validation
|
||||
if (_event.value.isFullDay) return true
|
||||
|
||||
// Only validate within the single start date; ignore any separate end date.
|
||||
const fromDate = _event.value.fromDate
|
||||
const fromTime = _event.value.fromTime
|
||||
const toTime = _event.value.toTime
|
||||
|
||||
if (!fromTime || !toTime) {
|
||||
error.value = __('Start and end time are required')
|
||||
return false
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
_event.value.toTime = prevTo
|
||||
} else {
|
||||
sync()
|
||||
}
|
||||
|
||||
const start = dayjs(fromDate + ' ' + fromTime)
|
||||
const end = dayjs(fromDate + ' ' + toTime)
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
error.value = __('Invalid start or end time')
|
||||
return false
|
||||
}
|
||||
|
||||
if (end.diff(start, 'minute') <= 0) {
|
||||
error.value = __('End time should be after start time')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function saveEvent() {
|
||||
@ -590,7 +523,16 @@ function saveEvent() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateFromToTime()) return
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
oldEvent.value = { ..._event.value }
|
||||
emit('save', _event.value)
|
||||
@ -716,42 +658,7 @@ function getTooltip(m) {
|
||||
return parts.length ? parts.join(': ') : email
|
||||
}
|
||||
|
||||
function formatDuration(mins) {
|
||||
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins)
|
||||
if (mins < 60) return __('{0} mins', [mins])
|
||||
let hours = mins / 60
|
||||
|
||||
// keep hours decimal to 2 only if decimal is not 0
|
||||
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
|
||||
hours = hours.toFixed(2)
|
||||
}
|
||||
|
||||
if (Number.isInteger(hours)) {
|
||||
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
||||
}
|
||||
// Keep decimal representation for > 1 hour fractional durations
|
||||
return `${hours} hrs`
|
||||
}
|
||||
|
||||
const toOptions = computed(() => {
|
||||
const fromTime = _event.value.fromTime
|
||||
const timeSlots = allTimeSlots()
|
||||
if (!fromTime) return timeSlots
|
||||
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
|
||||
const fromTotal = fh * 60 + fm
|
||||
// find first slot strictly after fromTime (even if fromTime not exactly a slot)
|
||||
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
|
||||
if (startIndex === -1) return []
|
||||
return timeSlots.slice(startIndex).map((o) => {
|
||||
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
|
||||
const toTotal = th * 60 + tm
|
||||
const duration = toTotal - fromTotal
|
||||
return {
|
||||
...o,
|
||||
label: `${o.label} (${formatDuration(duration)})`,
|
||||
}
|
||||
})
|
||||
})
|
||||
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
|
||||
|
||||
function updateEvent(_e) {
|
||||
Object.assign(_event.value, _e)
|
||||
|
||||
@ -215,8 +215,13 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { validateEmail } from '@/utils'
|
||||
import { allTimeSlots } from '@/components/Calendar/utils'
|
||||
import { useEvent } from '@/composables/event'
|
||||
import {
|
||||
useEvent,
|
||||
normalizeParticipants,
|
||||
buildEndTimeOptions,
|
||||
computeAutoToTime,
|
||||
validateTimeRange,
|
||||
} from '@/composables/event'
|
||||
import { CalendarColorMap as colorMap } from 'frappe-ui'
|
||||
import { onMounted, ref, computed, h } from 'vue'
|
||||
|
||||
@ -271,18 +276,7 @@ const peoples = computed({
|
||||
return _event.value.event_participants || []
|
||||
},
|
||||
set(list) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const a of list || []) {
|
||||
if (!a?.email || seen.has(a.email)) continue
|
||||
seen.add(a.email)
|
||||
out.push({
|
||||
email: a.email,
|
||||
reference_doctype: a.reference_doctype || 'Contact',
|
||||
reference_docname: a.reference_docname || '',
|
||||
})
|
||||
}
|
||||
_event.value.event_participants = out
|
||||
_event.value.event_participants = normalizeParticipants(list)
|
||||
},
|
||||
})
|
||||
|
||||
@ -323,42 +317,25 @@ function updateDate(d) {
|
||||
|
||||
function updateTime(t, fromTime = false) {
|
||||
error.value = null
|
||||
let oldTo = _event.value.toTime || _event.value.fromTime
|
||||
|
||||
const prevTo = _event.value.toTime
|
||||
if (fromTime) {
|
||||
_event.value.fromTime = t
|
||||
if (!_event.value.toTime) {
|
||||
const hour = parseInt(t.split(':')[0])
|
||||
const minute = parseInt(t.split(':')[1])
|
||||
let nh = hour + 1
|
||||
let nm = minute
|
||||
if (nh >= 24) {
|
||||
nh = 23
|
||||
nm = 59
|
||||
}
|
||||
_event.value.toTime = `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
|
||||
if (!_event.value.toTime || _event.value.toTime <= t) {
|
||||
_event.value.toTime = computeAutoToTime(t)
|
||||
}
|
||||
} else {
|
||||
_event.value.toTime = t
|
||||
}
|
||||
|
||||
validateFromToTime(oldTo)
|
||||
}
|
||||
|
||||
function validateFromToTime(oldTo) {
|
||||
if (_event.value.isFullDay) return true
|
||||
if (_event.value.toTime && _event.value.fromTime) {
|
||||
const diff = dayjs(_event.value.fromDate + ' ' + _event.value.toTime).diff(
|
||||
dayjs(_event.value.fromDate + ' ' + _event.value.fromTime),
|
||||
'minute',
|
||||
)
|
||||
if (diff <= 0) {
|
||||
_event.value.toTime = oldTo
|
||||
error.value = __('End time should be after start time')
|
||||
return false
|
||||
}
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
_event.value.toTime = prevTo
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function update() {
|
||||
@ -369,7 +346,16 @@ function update() {
|
||||
return
|
||||
}
|
||||
|
||||
validateFromToTime(_event.value.toTime)
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
if (_event.value.id && _event.value.id !== 'duplicate') {
|
||||
updateEvent()
|
||||
@ -467,42 +453,7 @@ function deleteEvent() {
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(mins) {
|
||||
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins)
|
||||
if (mins < 60) return __('{0} mins', [mins])
|
||||
let hours = mins / 60
|
||||
|
||||
// keep hours decimal to 2 only if decimal is not 0
|
||||
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
|
||||
hours = hours.toFixed(2)
|
||||
}
|
||||
|
||||
if (Number.isInteger(hours)) {
|
||||
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
||||
}
|
||||
// Keep decimal representation for > 1 hour fractional durations
|
||||
return `${hours} hrs`
|
||||
}
|
||||
|
||||
const toOptions = computed(() => {
|
||||
const fromTime = _event.value.fromTime
|
||||
const timeSlots = allTimeSlots()
|
||||
if (!fromTime) return timeSlots
|
||||
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
|
||||
const fromTotal = fh * 60 + fm
|
||||
// find first slot strictly after fromTime (even if fromTime not exactly a slot)
|
||||
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
|
||||
if (startIndex === -1) return []
|
||||
return timeSlots.slice(startIndex).map((o) => {
|
||||
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
|
||||
const toTotal = th * 60 + tm
|
||||
const duration = toTotal - fromTotal
|
||||
return {
|
||||
...o,
|
||||
label: `${o.label} (${formatDuration(duration)})`,
|
||||
}
|
||||
})
|
||||
})
|
||||
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
|
||||
|
||||
const linkDoctypeOptions = [
|
||||
{ label: '', value: '' },
|
||||
|
||||
@ -2,6 +2,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { dayjs, createListResource } from 'frappe-ui'
|
||||
import { sameArrayContents } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { allTimeSlots } from '@/components/Calendar/utils'
|
||||
|
||||
export const showEventModal = ref(false)
|
||||
export const activeEvent = ref(null)
|
||||
@ -136,3 +137,101 @@ export function useEvent(doctype, docname) {
|
||||
startDate,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeParticipants(list = []) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const a of list || []) {
|
||||
if (!a?.email || seen.has(a.email)) continue
|
||||
seen.add(a.email)
|
||||
out.push({
|
||||
email: a.email,
|
||||
reference_doctype: a.reference_doctype || 'Contact',
|
||||
reference_docname: a.reference_docname || '',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function formatDuration(mins) {
|
||||
if (mins < 60) return __('{0} mins', [mins])
|
||||
let hours = mins / 60
|
||||
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
|
||||
hours = hours.toFixed(2)
|
||||
}
|
||||
if (Number.isInteger(hours)) {
|
||||
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
||||
}
|
||||
return `${hours} hrs`
|
||||
}
|
||||
|
||||
export function buildEndTimeOptions(fromTime) {
|
||||
const timeSlots = allTimeSlots()
|
||||
if (!fromTime) return timeSlots
|
||||
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
|
||||
if (startIndex === -1) return []
|
||||
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
|
||||
const fromTotal = fh * 60 + fm
|
||||
return timeSlots.slice(startIndex).map((o) => {
|
||||
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
|
||||
const toTotal = th * 60 + tm
|
||||
const duration = toTotal - fromTotal
|
||||
return { ...o, label: `${o.label} (${formatDuration(duration)})` }
|
||||
})
|
||||
}
|
||||
|
||||
export function computeAutoToTime(fromTime) {
|
||||
if (!fromTime) return ''
|
||||
const [hour, minute] = fromTime.split(':').map((n) => parseInt(n))
|
||||
let nh = hour + 1
|
||||
let nm = minute
|
||||
if (nh >= 24) {
|
||||
nh = 23
|
||||
nm = 59
|
||||
}
|
||||
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function validateTimeRange({ fromDate, fromTime, toTime, isFullDay }) {
|
||||
if (isFullDay) return { valid: true, error: null }
|
||||
if (!fromTime || !toTime) {
|
||||
return { valid: false, error: __('Start and end time are required') }
|
||||
}
|
||||
const start = dayjs(fromDate + ' ' + fromTime)
|
||||
const end = dayjs(fromDate + ' ' + toTime)
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return { valid: false, error: __('Invalid start or end time') }
|
||||
}
|
||||
if (end.diff(start, 'minute') <= 0) {
|
||||
return { valid: false, error: __('End time should be after start time') }
|
||||
}
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
|
||||
export function parseEventDoc(doc) {
|
||||
if (!doc) return {}
|
||||
const { getUser } = usersStore()
|
||||
return {
|
||||
id: doc.name,
|
||||
title: doc.subject,
|
||||
description: doc.description,
|
||||
status: doc.status,
|
||||
fromDate: dayjs(doc.starts_on).format('YYYY-MM-DD'),
|
||||
toDate: dayjs(doc.ends_on).format('YYYY-MM-DD'),
|
||||
fromTime: dayjs(doc.starts_on).format('HH:mm'),
|
||||
toTime: dayjs(doc.ends_on).format('HH:mm'),
|
||||
isFullDay: doc.all_day,
|
||||
eventType: doc.event_type,
|
||||
color: doc.color,
|
||||
referenceDoctype: doc.reference_doctype,
|
||||
referenceDocname: doc.reference_docname,
|
||||
event_participants: doc.event_participants || [],
|
||||
owner: doc.owner
|
||||
? {
|
||||
label: getUser(doc.owner).full_name,
|
||||
image: getUser(doc.owner).user_image,
|
||||
value: doc.owner,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user