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

View File

@ -58,31 +58,25 @@
<div
class="flex justify-between gap-2 items-center text-ink-gray-6"
>
<div>{{ formattedDateTime(event) }}</div>
<div>{{ formattedDate(event) }}</div>
<div>
{{
startEndTime(event.starts_on, event.ends_on, event.all_day)
}}
</div>
<div>{{ startDate(event.starts_on) }}</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>
<script setup>
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import EventModal from '@/components/Modals/EventModal.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 { Tooltip, Avatar, dayjs, createListResource } from 'frappe-ui'
import { computed, ref } from 'vue'
import { Tooltip, Avatar } from 'frappe-ui'
const props = defineProps({
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) {
showEventModal.value = true
event.value = e
activeEvent.value = e
}
const { events, startEndTime, startDate } = useEvent(
props.doctype,
props.docname,
)
</script>

View File

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