fix: added attendee, color, link in event modal and fixed existing bug

This commit is contained in:
Shariq Ansari 2025-09-02 20:39:51 +05:30
parent 52d99ebf20
commit 8031964d3d
5 changed files with 345 additions and 167 deletions

View File

@ -25,8 +25,7 @@
<EventModal <EventModal
v-if="showEventModal" v-if="showEventModal"
v-model="showEventModal" v-model="showEventModal"
v-model:events="events" :event="activeEvent"
:event="event"
:doctype="doctype" :doctype="doctype"
:docname="doc?.name" :docname="doc?.name"
/> />
@ -36,6 +35,7 @@ import TaskModal from '@/components/Modals/TaskModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue' import NoteModal from '@/components/Modals/NoteModal.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue' import CallLogModal from '@/components/Modals/CallLogModal.vue'
import EventModal from '@/components/Modals/EventModal.vue' import EventModal from '@/components/Modals/EventModal.vue'
import { showEventModal, activeEvent } from '@/composables/event'
import { call } from 'frappe-ui' import { call } from 'frappe-ui'
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@ -46,22 +46,11 @@ const props = defineProps({
}) })
const activities = defineModel() const activities = defineModel()
const events = defineModel('events')
const showEventModal = ref(false)
const event = ref({})
// Event
function showEvent(e) { function showEvent(e) {
event.value = e || {
subject: '',
description: '',
starts_on: '',
ends_on: '',
all_day: false,
event_type: 'Public',
color: 'green',
}
showEventModal.value = true showEventModal.value = true
activeEvent.value = e
} }
// Tasks // Tasks

View File

@ -58,31 +58,25 @@
<div <div
class="flex justify-between gap-2 items-center text-ink-gray-6" class="flex justify-between gap-2 items-center text-ink-gray-6"
> >
<div>{{ formattedDateTime(event) }}</div> <div>
<div>{{ formattedDate(event) }}</div> {{
startEndTime(event.starts_on, event.ends_on, event.all_day)
}}
</div>
<div>{{ startDate(event.starts_on) }}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<EventModal
v-if="showEventModal"
v-model="showEventModal"
v-model:events="eventsResource"
:event="event"
:doctype="doctype"
:docname="docname"
/>
</template> </template>
<script setup> <script setup>
import CalendarIcon from '@/components/Icons/CalendarIcon.vue' import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import EventModal from '@/components/Modals/EventModal.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue' import MultipleAvatar from '@/components/MultipleAvatar.vue'
import { usersStore } from '@/stores/users' import { useEvent, showEventModal, activeEvent } from '@/composables/event'
import { formatDate, timeAgo } from '@/utils' import { formatDate, timeAgo } from '@/utils'
import { Tooltip, Avatar, dayjs, createListResource } from 'frappe-ui' import { Tooltip, Avatar } from 'frappe-ui'
import { computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -95,103 +89,13 @@ const props = defineProps({
}, },
}) })
const { getUser } = usersStore()
const eventsResource = createListResource({
doctype: 'Event',
cache: ['calendar', props.docname],
fields: [
'name',
'status',
'subject',
'description',
'starts_on',
'ends_on',
'all_day',
'event_type',
'color',
'owner',
'reference_doctype',
'reference_docname',
'creation',
],
filters: {
reference_doctype: props.doctype,
reference_docname: props.docname,
},
auto: true,
orderBy: 'creation desc',
onSuccess: (d) => {
console.log(d)
},
})
const eventParticipants = createListResource({
doctype: 'Event Participants',
cache: ['Event Participants', props.docname],
fields: ['*'],
parent: 'Event',
})
const events = computed(() => {
if (!eventsResource.data) return []
if (!eventParticipants.data?.length) {
eventParticipants.update({
filters: {
parenttype: 'Event',
parentfield: 'event_participants',
parent: ['in', eventsResource.data.map((e) => e.name)],
},
})
!eventParticipants.list.loading && eventParticipants.reload()
} else {
eventsResource.data.forEach((event) => {
if (typeof event.owner !== 'object') {
event.owner = {
label: getUser(event.owner).full_name,
image: getUser(event.owner).user_image,
name: event.owner,
}
}
event.participants = [
event.owner,
...eventParticipants.data
.filter((participant) => participant.parent === event.name)
.map((participant) => ({
label: getUser(participant.email).full_name || participant.email,
image: getUser(participant.email).user_image || '',
name: participant.email,
})),
]
})
}
return eventsResource.data
})
const formattedDateTime = (e) => {
const start = dayjs(e.starts_on)
const end = dayjs(e.ends_on)
if (e.all_day) {
return __('All day')
}
return `${start.format('h:mm a')} - ${end.format('h:mm a')}`
}
const formattedDate = (e) => {
const start = dayjs(e.starts_on)
return start.format('ddd, D MMM YYYY')
}
const showEventModal = ref(false)
const event = ref(null)
function showEvent(e) { function showEvent(e) {
showEventModal.value = true showEventModal.value = true
event.value = e activeEvent.value = e
} }
const { events, startEndTime, startDate } = useEvent(
props.doctype,
props.docname,
)
</script> </script>

View File

@ -42,15 +42,29 @@
<div class="text-base text-ink-gray-7 w-3/12"> <div class="text-base text-ink-gray-7 w-3/12">
{{ __('Title') }} {{ __('Title') }}
</div> </div>
<TextInput <div class="flex gap-1 w-9/12">
ref="title" <Dropdown class="" :options="colors">
class="w-9/12" <div
size="md" class="flex items-center justify-center size-7 shrink-0 border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm rounded cursor-pointer"
v-model="_event.title" >
:placeholder="__('Call with John Doe')" <div
variant="outline" class="size-2.5 rounded-full cursor-pointer"
required :style="{
/> backgroundColor: _event.color || '#30A66D',
}"
/>
</div>
</Dropdown>
<TextInput
class="w-full"
ref="title"
size="sm"
v-model="_event.title"
:placeholder="__('Call with John Doe')"
variant="outline"
required
/>
</div>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12"> <div class="text-base text-ink-gray-7 w-3/12">
@ -83,7 +97,7 @@
</DatePicker> </DatePicker>
<TimePicker <TimePicker
v-if="!_event.isFullDay" v-if="!_event.isFullDay"
class="max-w-[105px]" class="max-w-[112px]"
variant="outline" variant="outline"
:modelValue="_event.fromTime" :modelValue="_event.fromTime"
:placeholder="__('Start Time')" :placeholder="__('Start Time')"
@ -91,7 +105,7 @@
/> />
<TimePicker <TimePicker
v-if="!_event.isFullDay" v-if="!_event.isFullDay"
class="max-w-[105px]" class="max-w-[112px]"
variant="outline" variant="outline"
:modelValue="_event.toTime" :modelValue="_event.toTime"
:options="toOptions" :options="toOptions"
@ -101,6 +115,50 @@
/> />
</div> </div>
</div> </div>
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('Link') }}
</div>
<div class="flex gap-2 w-9/12">
<FormControl
:class="_event.referenceDoctype ? 'w-20' : 'w-full'"
type="select"
:options="linkDoctypeOptions"
v-model="_event.referenceDoctype"
variant="outline"
:placeholder="__('Add Lead or Deal')"
@change="() => (_event.referenceDocname = '')"
/>
<Link
v-if="_event.referenceDoctype"
class="w-full"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
variant="outline"
:placeholder="
__('Select {0}', [
_event.referenceDoctype == 'CRM Lead'
? __('Lead')
: __('Deal'),
])
"
/>
</div>
</div>
<div class="flex items-start">
<div class="text-base text-ink-gray-7 mt-1.5 w-3/12">
{{ __('Attendees') }}
</div>
<div class="w-9/12">
<Attendee
v-model="peoples"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
</div>
<div class="flex"> <div class="flex">
<div class="mt-2 text-base text-ink-gray-7 w-3/12"> <div class="mt-2 text-base text-ink-gray-7 w-3/12">
{{ __('Description') }} {{ __('Description') }}
@ -119,7 +177,7 @@
</div> </div>
</template> </template>
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div v-if="eventsResource" class="flex gap-2 justify-end">
<Button :label="__('Cancel')" @click="show = false" /> <Button :label="__('Cancel')" @click="show = false" />
<Button <Button
variant="solid" variant="solid"
@ -131,7 +189,9 @@
: __('Create') : __('Create')
" "
:loading=" :loading="
mode === 'edit' ? events.setValue.loading : events.insert.loading mode === 'edit'
? eventsResource.setValue.loading
: eventsResource.insert.loading
" "
@click="update" @click="update"
/> />
@ -140,6 +200,8 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import Link from '@/components/Controls/Link.vue'
import Attendee from '@/components/Calendar/Attendee.vue'
import { import {
Switch, Switch,
TextEditor, TextEditor,
@ -148,9 +210,15 @@ import {
DatePicker, DatePicker,
TimePicker, TimePicker,
dayjs, dayjs,
Dropdown,
FormControl,
} from 'frappe-ui' } from 'frappe-ui'
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { onMounted, ref, computed } from 'vue' import { validateEmail } from '@/utils'
import { allTimeSlots } from '@/components/Calendar/utils'
import { useEvent } from '@/composables/event'
import { CalendarColorMap as colorMap } from 'frappe-ui'
import { onMounted, ref, computed, h } from 'vue'
const props = defineProps({ const props = defineProps({
event: { event: {
@ -170,7 +238,8 @@ const props = defineProps({
const { $dialog } = globalStore() const { $dialog } = globalStore()
const show = defineModel() const show = defineModel()
const events = defineModel('events')
const { eventsResource } = useEvent(props.doctype, props.docname)
const title = ref(null) const title = ref(null)
const error = ref(null) const error = ref(null)
@ -192,6 +261,29 @@ const _event = ref({
isFullDay: false, isFullDay: false,
eventType: 'Public', eventType: 'Public',
color: 'green', color: 'green',
referenceDoctype: '',
referenceDocname: '',
event_participants: [],
})
const peoples = computed({
get() {
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
},
}) })
onMounted(() => { onMounted(() => {
@ -215,6 +307,9 @@ onMounted(() => {
isFullDay: props.event.all_day, isFullDay: props.event.all_day,
eventType: props.event.event_type, eventType: props.event.event_type,
color: props.event.color, color: props.event.color,
referenceDoctype: props.event.reference_doctype,
referenceDocname: props.event.reference_docname,
event_participants: props.event.event_participants || [],
} }
setTimeout(() => title.value?.el?.focus(), 100) setTimeout(() => title.value?.el?.focus(), 100)
@ -235,24 +330,35 @@ function updateTime(t, fromTime = false) {
if (!_event.value.toTime) { if (!_event.value.toTime) {
const hour = parseInt(t.split(':')[0]) const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1]) const minute = parseInt(t.split(':')[1])
_event.value.toTime = `${hour + 1}:${minute}` 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')}`
} }
} else { } else {
_event.value.toTime = t _event.value.toTime = t
} }
validateFromToTime(oldTo)
}
function validateFromToTime(oldTo) {
if (_event.value.isFullDay) return true
if (_event.value.toTime && _event.value.fromTime) { if (_event.value.toTime && _event.value.fromTime) {
const diff = dayjs(_event.value.toDate + ' ' + _event.value.toTime).diff( const diff = dayjs(_event.value.fromDate + ' ' + _event.value.toTime).diff(
dayjs(_event.value.fromDate + ' ' + _event.value.fromTime), dayjs(_event.value.fromDate + ' ' + _event.value.fromTime),
'minute', 'minute',
) )
if (diff <= 0) { if (diff <= 0) {
_event.value.toTime = oldTo _event.value.toTime = oldTo
error.value = __('End time should be after start time') error.value = __('End time should be after start time')
return return false
} }
} }
return true
} }
function update() { function update() {
@ -263,11 +369,17 @@ function update() {
return return
} }
_event.value.id ? updateEvent() : createEvent() validateFromToTime(_event.value.toTime)
if (_event.value.id && _event.value.id !== 'duplicate') {
updateEvent()
} else {
createEvent()
}
} }
function createEvent() { function createEvent() {
events.value.insert.submit( eventsResource.insert.submit(
{ {
subject: _event.value.title, subject: _event.value.title,
description: _event.value.description, description: _event.value.description,
@ -278,10 +390,13 @@ function createEvent() {
color: _event.value.color, color: _event.value.color,
reference_doctype: props.doctype, reference_doctype: props.doctype,
reference_docname: props.docname, reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
await events.value.reload() await eventsResource.reload()
show.value = false show.value = false
}, },
}, },
@ -294,7 +409,7 @@ function updateEvent() {
return return
} }
events.value.setValue.submit( eventsResource.setValue.submit(
{ {
name: _event.value.id, name: _event.value.id,
subject: _event.value.title, subject: _event.value.title,
@ -306,10 +421,13 @@ function updateEvent() {
color: _event.value.color, color: _event.value.color,
reference_doctype: props.doctype, reference_doctype: props.doctype,
reference_docname: props.docname, reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
await events.value.reload() await eventsResource.reload()
show.value = false show.value = false
}, },
}, },
@ -336,9 +454,9 @@ function deleteEvent() {
variant: 'solid', variant: 'solid',
theme: 'red', theme: 'red',
onClick: (close) => { onClick: (close) => {
events.value.delete.submit(_event.value.id, { eventsResource.delete.submit(_event.value.id, {
onSuccess: async () => { onSuccess: async () => {
await events.value.reload() await eventsResource.reload()
show.value = false show.value = false
close() close()
}, },
@ -386,20 +504,19 @@ const toOptions = computed(() => {
}) })
}) })
function allTimeSlots() { const linkDoctypeOptions = [
const out = [] { label: '', value: '' },
for (let h = 0; h < 24; h++) { { label: __('Lead'), value: 'CRM Lead' },
for (const m of [0, 15, 30, 45]) { { label: __('Deal'), value: 'CRM Deal' },
const hh = String(h).padStart(2, '0') ]
const mm = String(m).padStart(2, '0')
const ampm = h >= 12 ? 'pm' : 'am' const colors = Object.keys(colorMap).map((c) => ({
const hour12 = h % 12 === 0 ? 12 : h % 12 label: c.charAt(0).toUpperCase() + c.slice(1),
out.push({ value: colorMap[c].color,
value: `${hh}:${mm}`, icon: h('div', {
label: `${hour12}:${mm} ${ampm}`, class: '!size-2.5 rounded-full',
}) style: { backgroundColor: colorMap[c].color },
} }),
} onClick: () => (_event.value.color = colorMap[c].color),
return out }))
}
</script> </script>

View File

@ -0,0 +1,138 @@
import { usersStore } from '@/stores/users'
import { dayjs, createListResource } from 'frappe-ui'
import { sameArrayContents } from '@/utils'
import { computed, ref } from 'vue'
export const showEventModal = ref(false)
export const activeEvent = ref(null)
export function useEvent(doctype, docname) {
const { getUser } = usersStore()
const eventsResource = createListResource({
doctype: 'Event',
cache: ['calendar', docname],
fields: [
'name',
'status',
'subject',
'description',
'starts_on',
'ends_on',
'all_day',
'event_type',
'color',
'owner',
'reference_doctype',
'reference_docname',
'creation',
],
filters: {
reference_doctype: doctype,
reference_docname: docname,
},
auto: true,
orderBy: 'creation desc',
onSuccess: (d) => {
console.log(d)
},
})
const eventParticipantsResource = createListResource({
doctype: 'Event Participants',
fields: ['*'],
parent: 'Event',
})
const events = computed(() => {
if (!eventsResource.data) return []
const eventNames = eventsResource.data.map((e) => e.name)
if (
!eventParticipantsResource.data?.length ||
eventsParticipantIsUpdated(eventNames)
) {
eventParticipantsResource.update({
filters: {
parenttype: 'Event',
parentfield: 'event_participants',
parent: ['in', eventNames],
},
})
!eventParticipantsResource.list.loading &&
eventParticipantsResource.reload()
} else {
eventsResource.data.forEach((event) => {
if (typeof event.owner !== 'object') {
event.owner = {
label: getUser(event.owner).full_name,
image: getUser(event.owner).user_image,
name: event.owner,
}
}
event.event_participants = [
...eventParticipantsResource.data.filter(
(participant) => participant.parent === event.name,
),
]
event.participants = [
event.owner,
...eventParticipantsResource.data
.filter((participant) => participant.parent === event.name)
.map((participant) => ({
label: getUser(participant.email).full_name || participant.email,
image: getUser(participant.email).user_image || '',
name: participant.email,
})),
]
})
}
return eventsResource.data
})
function eventsParticipantIsUpdated(eventNames) {
const parentFilter = eventParticipantsResource.filters?.parent?.[1]
if (eventNames.length && !sameArrayContents(parentFilter, eventNames))
return true
let d = eventsResource.setValue.data
if (!d) return false
let newParticipants = d.event_participants.map((p) => p.name)
let oldParticipants = eventParticipantsResource.data
.filter((p) => p.parent === d.name)
.map((p) => p.name)
return !sameArrayContents(newParticipants, oldParticipants)
}
const startEndTime = (
startTime,
endTime,
isFullDay = false,
format = 'h:mm a',
) => {
const start = dayjs(startTime)
const end = dayjs(endTime)
if (isFullDay) return __('All day')
return `${start.format(format)} - ${end.format(format)}`
}
const startDate = (startTime, format = 'ddd, D MMM YYYY') => {
const start = dayjs(startTime)
return start.format(format)
}
return {
eventsResource,
eventParticipantsResource,
events,
startEndTime,
startDate,
}
}

View File

@ -689,3 +689,33 @@ export function validateConditions(conditions) {
return conditions.length > 0 return conditions.length > 0
} }
// sameArrayContents: returns true if both arrays have exactly the same elements
// (including duplicate counts) irrespective of order.
// Non-arrays or arrays of different length return false.
export function sameArrayContents(a, b) {
if (a === b) return true
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
if (a.length === 0) return true
const counts = new Map()
for (const v of a) {
counts.set(v, (counts.get(v) || 0) + 1)
}
for (const v of b) {
const c = counts.get(v)
if (!c) return false
if (c === 1) counts.delete(v)
else counts.set(v, c - 1)
}
return counts.size === 0
}
// orderSensitiveEqual: returns true only if arrays are strictly equal index-wise
export function orderSensitiveEqual(a, b) {
if (a === b) return true
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}