feat: add Attendee component and integrate into CalendarEventPanel

- Introduced Attendee component for managing event participants.
- Updated CalendarEventPanel to include Attendee component for adding participants.
- Enhanced event creation and updating logic to handle event participants.
- Updated Calendar.vue to ensure participant contacts are created if missing.
This commit is contained in:
Shariq Ansari 2025-08-26 17:04:46 +05:30
parent eada826503
commit 9976b9617f
5 changed files with 727 additions and 107 deletions

View File

@ -40,6 +40,7 @@ declare module 'vue' {
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
Attendee: typeof import('./src/components/Calendar/Attendee.vue')['default']
AudioPlayer: typeof import('./src/components/Activities/AudioPlayer.vue')['default']
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
@ -195,7 +196,6 @@ declare module 'vue' {
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
LucideCopy: typeof import('~icons/lucide/copy')['default']
LucideTrash2: typeof import('~icons/lucide/trash2')['default']
LucideX: typeof import('~icons/lucide/x')['default']
@ -226,6 +226,7 @@ declare module 'vue' {
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
Password: typeof import('./src/components/Controls/Password.vue')['default']
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
PeopleIcon: typeof import('./src/components/Icons/PeopleIcon.vue')['default']
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
PlaybackSpeedIcon: typeof import('./src/components/Icons/PlaybackSpeedIcon.vue')['default']

View File

@ -0,0 +1,318 @@
<template>
<div>
<div
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7 [&>div]:w-full"
>
<Combobox v-model="selectedValue" nullable class="w-full">
<Popover v-model:show="showOptions">
<template #target="{ togglePopover }">
<TextInput
ref="search"
type="text"
size="md"
class="w-full"
variant="outline"
v-model="query"
:debounce="300"
:placeholder="placeholder"
@click="togglePopover"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
name="chevron-down"
class="h-4 text-ink-gray-5"
@click.stop="togglePopover()"
/>
</template>
</TextInput>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ComboboxOptions
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchContacts"
name="search"
class="h-4"
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
<div v-if="values.length" class="flex flex-col gap-2 px-4.5 py-[7px]">
<Button
ref="emails"
v-for="att in values"
:key="att.email"
:label="att.email"
theme="gray"
class="rounded-full w-fit"
:tooltip="getTooltip(att.email)"
@keydown.delete.capture.stop="removeLastValue"
>
<template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5.5" />
</template>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(att.email)"
/>
</template>
</Button>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
</div>
</div>
</template>
<script setup>
import { Combobox, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { createResource, TextInput } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: 'Add attendee',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const metaByEmail = computed(() => {
const out = {}
const source = values.value || []
for (const a of source) {
if (a?.email) out[a.email] = a
}
return out
})
function getTooltip(email) {
const m = metaByEmail.value[email]
if (!m) return email
const parts = []
if (m.reference_doctype) parts.push(m.reference_doctype)
if (m.reference_docname) parts.push(m.reference_docname)
return parts.length ? parts.join(': ') : email
}
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
addValue(val)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val && options.value?.length) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
const filterOptions = createResource({
url: 'crm.api.contact.search_emails',
method: 'POST',
cache: [text.value, 'Contact'],
params: { txt: text.value },
transform: (data) => {
let allData = data.map((option) => {
let fullName = option[0]
let email = option[1]
let name = option[2]
return {
label: fullName || name || email,
name: name,
value: email,
}
})
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData
},
})
const options = computed(() => {
let searchedContacts = props.fetchContacts ? filterOptions.data : []
if (!searchedContacts?.length && query.value) {
searchedContacts.push({
name: 'new',
label: query.value,
value: query.value,
})
}
return searchedContacts || []
})
function reload(val) {
if (!props.fetchContacts) return
filterOptions.update({
params: { txt: val },
})
filterOptions.reload()
}
const addValue = (option) => {
// Safeguard for falsy option
if (!option || !option.value) return
error.value = null
info.value = null
const current = Array.isArray(values.value) ? values.value.slice() : []
const existing = new Set(current.map((a) => a.email))
const raw = option.value || ''
const parts = raw.split(',')
const hasMultiple = parts.length > 1
for (let p of parts) {
p = p.trim()
if (!p) continue
if (existing.has(p)) {
info.value = __('email already exists')
continue
}
if (props.validate && !props.validate(p)) {
error.value = props.errorMessage(p)
query.value = p
continue
}
existing.add(p)
const entry = { email: p }
if (option.name && !hasMultiple) {
entry.reference_docname = option.name
}
current.push(entry)
}
if (!error.value) {
values.value = current
}
}
const removeValue = (email) => {
values.value = (values.value || []).filter((a) => a.email !== email)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -1,5 +1,5 @@
<template>
<div v-if="show" class="w-[352px] border-l text-base">
<div v-if="show" class="flex flex-col w-[352px] border-l text-base">
<!-- Event Header -->
<div
class="flex items-center justify-between p-4.5 text-ink-gray-7 text-lg font-medium"
@ -41,37 +41,37 @@
</div>
<!-- Event Details -->
<div v-if="mode == 'details'">
<div v-if="mode == 'details'" class="flex flex-col overflow-y-auto">
<div class="flex items-start gap-2 px-4.5 py-3 pb-0">
<div
class="mx-0.5 my-[5px] size-2.5 rounded-full cursor-pointer"
:style="{
backgroundColor: event.color || '#30A66D',
backgroundColor: _event.color || '#30A66D',
}"
/>
<div class="flex flex-col gap-[3px]">
<div class="text-ink-gray-8 font-semibold text-xl">
{{ event.title || __('(No title)') }}
{{ _event.title || __('(No title)') }}
</div>
<div class="text-ink-gray-6 text-p-base">{{ formattedDateTime }}</div>
</div>
</div>
<div
v-if="event.referenceDocname"
v-if="_event.referenceDocname"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div
v-if="event.referenceDocname"
v-if="_event.referenceDocname"
class="flex items-center px-4.5 py-1 text-ink-gray-7"
>
<component
:is="event.referenceDoctype == 'CRM Lead' ? LeadsIcon : DealsIcon"
:is="_event.referenceDoctype == 'CRM Lead' ? LeadsIcon : DealsIcon"
class="size-4"
/>
<Link
class="[&_button]:bg-surface-white [&_button]:select-text [&_button]:text-ink-gray-7 [&_button]:cursor-text"
v-model="event.referenceDocname"
:doctype="event.referenceDoctype"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
:disabled="true"
/>
<Button variant="ghost" @click="redirect">
@ -81,45 +81,91 @@
</Button>
</div>
<div
v-if="event.description && event.description !== '<p></p>'"
v-if="peoples.length"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div v-if="event.description && event.description !== '<p></p>'">
<div v-if="peoples.length" class="px-4.5 py-2">
<div class="flex gap-3 text-ink-gray-7 mb-3">
<PeopleIcon class="size-4" />
<div>{{ __('{0} Attendees', [peoples.length + 1]) }}</div>
</div>
<div class="flex flex-col gap-1 -ml-1">
<Button
:key="_event.owner"
variant="ghost"
theme="gray"
class="rounded-full w-fit !h-8.5 !pr-3"
:tooltip="__('Owner: {0}', [_event.owner.label])"
>
<template #default>
<div class="flex flex-col justify-start items-start text-sm">
<div>{{ _event.owner.label }}</div>
<div class="text-ink-gray-5">{{ __('Organizer') }}</div>
</div>
</template>
<template #prefix>
<UserAvatar :user="_event.owner.value" class="-ml-1 !size-5" />
</template>
</Button>
<Button
v-for="att in peoples"
:key="att.email"
:label="att.email"
variant="ghost"
theme="gray"
class="rounded-full w-fit !text-sm"
:tooltip="getTooltip(att)"
>
<template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5" />
</template>
</Button>
</div>
</div>
<div
v-if="_event.description && _event.description !== '<p></p>'"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div v-if="_event.description && _event.description !== '<p></p>'">
<div class="flex gap-2 items-center text-ink-gray-7 px-4.5 py-1">
<DescriptionIcon class="size-4" />
{{ __('Description') }}
</div>
<div
class="px-4.5 py-2 text-ink-gray-7 text-p-base"
v-html="event.description"
v-html="_event.description"
/>
</div>
</div>
<!-- Event create, duplicate & edit -->
<div v-else>
<div class="px-4.5 py-3">
<div v-else class="flex flex-col overflow-y-auto">
<div class="flex gap-2 items-center px-4.5 py-3">
<Dropdown class="ml-1" :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
ref="eventTitle"
v-model="event.title"
class="w-full"
variant="outline"
v-model="_event.title"
:debounce="500"
:placeholder="__('Event title')"
>
<template #prefix>
<Dropdown class="ml-1" :options="colors">
<div
class="ml-0.5 size-2.5 rounded-full cursor-pointer"
:style="{
backgroundColor: event.color || '#30A66D',
}"
/>
</Dropdown>
</template>
</TextInput>
@change="sync"
/>
</div>
<div class="flex justify-between py-2.5 px-4.5 text-ink-gray-6">
<div class="flex items-center">
<Switch v-model="event.isFullDay" />
<Switch v-model="_event.isFullDay" @update:model-value="sync" />
<div class="ml-2">
{{ __('All day') }}
</div>
@ -137,7 +183,7 @@
<DatePicker
:class="['[&_input]:w-[216px]']"
variant="outline"
:value="event.fromDate"
:value="_event.fromDate"
:formatter="(date) => getFormat(date, 'MMM D, YYYY')"
:placeholder="__('May 1, 2025')"
@update:modelValue="(date) => updateDate(date, true)"
@ -153,16 +199,16 @@
</div>
</div>
<div
v-if="!event.isFullDay"
v-if="!_event.isFullDay"
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="w-20">{{ __('Time') }}</div>
<div class="flex items-center gap-x-1.5">
<TimePicker
v-if="!event.isFullDay"
v-if="!_event.isFullDay"
class="max-w-[105px]"
variant="outline"
:value="event.fromTime"
:value="_event.fromTime"
:placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)"
>
@ -177,7 +223,8 @@
<TimePicker
class="max-w-[105px]"
variant="outline"
:value="event.toTime"
:value="_event.toTime"
:customOptions="toOptions"
:placeholder="__('End Time')"
@update:modelValue="(time) => updateTime(time)"
>
@ -214,59 +261,86 @@
value: 'CRM Deal',
},
]"
v-model="event.referenceDoctype"
v-model="_event.referenceDoctype"
variant="outline"
:placeholder="__('Add Lead or Deal')"
@change="() => (event.referenceDocname = '')"
@change="
() => {
_event.referenceDocname = ''
sync()
}
"
/>
</div>
</div>
<div
v-if="event.referenceDoctype"
v-if="_event.referenceDoctype"
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="">
{{ event.referenceDoctype == 'CRM Lead' ? __('Lead') : __('Deal') }}
{{ _event.referenceDoctype == 'CRM Lead' ? __('Lead') : __('Deal') }}
</div>
<div class="flex items-center gap-x-1.5">
<Link
class="w-[220px]"
v-model="event.referenceDocname"
:doctype="event.referenceDoctype"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
variant="outline"
@update:model-value="sync"
/>
</div>
</div>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<Attendee
v-model="peoples"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<div class="px-4.5 py-3">
<div class="flex items-center gap-x-2 border rounded py-1">
<TextEditor
editor-class="!prose-sm overflow-auto min-h-[20px] max-h-32 px-2 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors"
editor-class="!prose-sm overflow-auto min-h-[22px] max-h-32 px-2.5 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors"
:bubbleMenu="true"
:content="event.description"
@change="(val) => (event.description = val)"
:content="_event.description"
@change="
(val) => {
_event.description = val
sync()
}
"
:placeholder="__('Add description')"
/>
</div>
<div class="my-3">
<Button variant="solid" class="w-full" @click="saveEvent">
{{
mode === 'edit'
? __('Save')
: mode === 'duplicate'
? __('Duplicate event')
: __('Create event')
}}
</Button>
</div>
</div>
</div>
<ErrorMessage :message="error" />
<div v-if="mode != 'details'" class="px-4.5 py-3">
<ErrorMessage class="my-2" :message="error" />
<div class="w-full">
<Button
variant="solid"
class="w-full"
:disabled="!dirty"
@click="saveEvent"
>
{{
mode === 'edit'
? __('Save')
: mode === 'duplicate'
? __('Duplicate event')
: __('Create event')
}}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import PeopleIcon from '@/components/Icons/PeopleIcon.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
@ -275,7 +349,9 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
import TimePicker from './TimePicker.vue'
import { globalStore } from '@/stores/global'
import { getFormat } from '@/utils'
import { usersStore } from '@/stores/users'
import { getFormat, validateEmail } from '@/utils'
import { allTimeSlots } from '@/components/Calendar/utils'
import {
TextInput,
Switch,
@ -286,12 +362,11 @@ import {
dayjs,
CalendarColorMap as colorMap,
CalendarActiveEvent as activeEvent,
createDocumentResource,
} from 'frappe-ui'
import { ref, computed, watch, h } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
event: {
type: Object,
@ -305,10 +380,36 @@ const props = defineProps({
const emit = defineEmits(['save', 'edit', 'delete', 'details', 'close'])
const router = useRouter()
const { $dialog } = globalStore()
const { getUser } = usersStore()
const show = defineModel()
const _event = ref({})
const peoples = computed({
get() {
return _event.value.event_participants || []
},
set(list) {
// Deduplicate by email while preserving first occurrence meta
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
sync()
},
})
const title = computed(() => {
if (props.mode === 'details') return __('Event details')
if (props.mode === 'edit') return __('Editing event')
@ -321,18 +422,68 @@ const error = ref(null)
const oldEvent = ref(null)
const dirty = computed(() => {
return JSON.stringify(oldEvent.value) !== JSON.stringify(props.event)
return JSON.stringify(oldEvent.value) !== JSON.stringify(_event.value)
})
watch(
[() => props.mode, () => props.event],
() => {
error.value = null
focusOnTitle()
oldEvent.value = { ...props.event }
fetchEvent()
},
{ immediate: true },
)
function fetchEvent() {
if (
props.event.id &&
props.event.id !== 'new-event' &&
props.event.id !== 'duplicate-event'
) {
let e = createDocumentResource({
doctype: 'Event',
name: props.event.id,
fields: ['*'],
onSuccess: (data) => {
_event.value = parseEvent(data)
oldEvent.value = { ..._event.value }
},
})
if (e.doc) {
_event.value = parseEvent(e.doc)
oldEvent.value = { ..._event.value }
}
} else {
_event.value = props.event
oldEvent.value = { ...props.event }
}
}
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', 'create', 'duplicate'].includes(props.mode)) {
@ -341,73 +492,113 @@ function focusOnTitle() {
}, 100)
}
function sync() {
emit('sync', _event.value.id, _event.value)
}
function updateDate(d) {
props.event.fromDate = d
props.event.toDate = d
_event.value.fromDate = d
_event.value.toDate = d
sync()
}
function updateTime(t, fromTime = false) {
error.value = null
let oldTo = props.event.toTime || props.event.fromTime
if (fromTime) {
props.event.fromTime = t
if (!props.event.toTime) {
const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1])
props.event.toTime = `${hour + 1}:${minute}`
_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()
}
} else {
props.event.toTime = t
_event.value.toTime = t
}
if (props.event.toTime && props.event.fromTime) {
const diff = dayjs(props.event.toDate + ' ' + props.event.toTime).diff(
dayjs(props.event.fromDate + ' ' + props.event.fromTime),
'minute',
)
validateFromToTime() && sync()
}
if (diff <= 0) {
props.event.toTime = oldTo
error.value = __('End time should be after start time')
return
}
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 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() {
error.value = null
if (!props.event.title) {
if (!_event.value.title) {
error.value = __('Title is required')
eventTitle.value.el.focus()
return
}
oldEvent.value = { ...props.event }
emit('save', props.event)
if (!validateFromToTime()) return
oldEvent.value = { ..._event.value }
emit('save', _event.value)
}
function editDetails() {
emit('edit', props.event)
emit('edit', _event.value)
}
function duplicateEvent() {
if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('duplicate', props.event)
emit('duplicate', _event.value)
}
}
function deleteEvent() {
emit('delete', props.event.id)
emit('delete', _event.value.id)
}
function details() {
if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('details', props.event)
emit('details', _event.value)
}
}
@ -415,23 +606,23 @@ function close() {
const _close = () => {
show.value = false
activeEvent.value = ''
emit('close', props.event)
emit('close', _event.value)
}
if (dirty.value) {
showDiscardChangesModal(() => {
reset()
if (props.event.id === 'new-event') _close()
if (_event.value.id === 'new-event') _close()
})
} else {
if (props.event.id === 'duplicate-event')
if (_event.value.id === 'duplicate-event')
showDiscardChangesModal(() => _close())
else _close()
}
}
function reset() {
Object.assign(props.event, oldEvent.value)
Object.assign(_event.value, oldEvent.value)
}
function showDiscardChangesModal(action) {
@ -460,14 +651,14 @@ function showDiscardChangesModal(action) {
}
const formattedDateTime = computed(() => {
const date = dayjs(props.event.fromDate)
const date = dayjs(_event.value.fromDate)
if (props.event.isFullDay) {
if (_event.value.isFullDay) {
return `${__('All day')} - ${date.format('ddd, D MMM YYYY')}`
}
const start = dayjs(props.event.fromDate + ' ' + props.event.fromTime)
const end = dayjs(props.event.toDate + ' ' + props.event.toTime)
const start = dayjs(_event.value.fromDate + ' ' + _event.value.fromTime)
const end = dayjs(_event.value.toDate + ' ' + _event.value.toTime)
return `${start.format('h:mm a')} - ${end.format('h:mm a')} ${date.format('ddd, D MMM YYYY')}`
})
@ -480,20 +671,60 @@ const colors = Object.keys(colorMap).map((color) => ({
style: { backgroundColor: colorMap[color].color },
}),
onClick: () => {
props.event.color = colorMap[color].color
_event.value.color = colorMap[color].color
sync()
},
}))
function redirect() {
if (props.event.referenceDocname) {
let name = props.event.referenceDoctype === 'CRM Lead' ? 'Lead' : 'Deal'
if (_event.value.referenceDocname) {
let name = _event.value.referenceDoctype === 'CRM Lead' ? 'Lead' : 'Deal'
let params =
props.event.referenceDoctype == 'CRM Lead'
? { leadId: props.event.referenceDocname }
: { dealId: props.event.referenceDocname }
_event.value.referenceDoctype == 'CRM Lead'
? { leadId: _event.value.referenceDocname }
: { dealId: _event.value.referenceDocname }
router.push({ name, params })
}
}
function getTooltip(m) {
if (!m) return email
const parts = []
if (m.reference_doctype) parts.push(m.reference_doctype)
if (m.reference_docname) parts.push(m.reference_docname)
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])
const hours = mins / 60
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)})`,
}
})
})
</script>

View File

@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1ZM10.75 9.5C10.0172 9.5 9.50422 9.65253 9.12793 9.84277C8.74778 10.035 8.48265 10.2774 8.24707 10.5049C8.08807 10.6584 8.00001 10.8573 8 11.0352V14C10.4873 14 12.6207 12.4863 13.5303 10.3301C12.9601 9.92399 12.0744 9.5 10.75 9.5ZM4.75 9.5C4.01981 9.5 3.50767 9.6516 3.13184 9.84082C2.85955 9.97794 2.64585 10.1409 2.45996 10.3057C3.24038 12.1787 4.94236 13.5697 7 13.915V11.0352C7.00001 10.7133 7.099 10.4099 7.25781 10.1514C6.69171 9.81028 5.88188 9.50007 4.75 9.5ZM8 2C4.68629 2 2 4.68629 2 8C2 8.43945 2.04801 8.8677 2.1377 9.28027C2.29548 9.1649 2.47567 9.05099 2.68164 8.94727C3.2047 8.68387 3.87233 8.5 4.75 8.5C6.21316 8.50007 7.25578 8.94284 7.96582 9.41309C8.16037 9.25535 8.39427 9.09305 8.67676 8.9502C9.20055 8.68539 9.86925 8.5 10.75 8.5C12.1371 8.5 13.144 8.89812 13.8477 9.33789C13.9457 8.90751 14 8.46009 14 8C14 4.68629 11.3137 2 8 2ZM10.5 5.5C11.1875 5.5 11.75 6.0625 11.75 6.75C11.75 7.4375 11.1875 8 10.5 8C9.8125 8 9.25 7.4375 9.25 6.75C9.25 6.0625 9.8125 5.5 10.5 5.5ZM6 4.5C6.825 4.5 7.5 5.175 7.5 6C7.5 6.825 6.825 7.5 6 7.5C5.175 7.5 4.5 6.825 4.5 6C4.5 5.175 5.175 4.5 6 4.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -4,7 +4,14 @@
<ViewBreadcrumbs routeName="Calendar" />
</template>
<template #right-header>
<Button variant="solid" :label="__('Create')" @click="newEvent">
<Button
variant="solid"
:label="__('Create')"
:disabled="
mode == 'edit' || mode == 'new-event' || mode == 'duplicate-event'
"
@click="newEvent"
>
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
</Button>
</template>
@ -93,6 +100,7 @@
@duplicate="duplicateEvent"
@details="showDetails"
@close="close"
@sync="syncEvent"
/>
</div>
</template>
@ -108,6 +116,7 @@ import {
TabButtons,
dayjs,
CalendarActiveEvent as activeEvent,
call,
} from 'frappe-ui'
import { onMounted, ref } from 'vue'
@ -186,6 +195,7 @@ function createEvent(_event) {
color: _event.color,
reference_doctype: _event.referenceDoctype,
reference_docname: _event.referenceDocname,
event_participants: _event.event_participants,
},
{
onSuccess: async (e) => {
@ -196,10 +206,19 @@ function createEvent(_event) {
)
}
function updateEvent(_event) {
async function updateEvent(_event) {
if (!_event.id) return
if (!mode.value || mode.value === 'edit' || mode.value === 'details') {
// Ensure Contacts exist for participants referencing a new/unknown Contact, if not create them
if (
Array.isArray(_event.event_participants) &&
_event.event_participants.length
) {
_event.event_participants = await ensureParticipantContacts(
_event.event_participants,
)
}
events.setValue.submit(
{
name: _event.id,
@ -212,6 +231,7 @@ function updateEvent(_event) {
color: _event.color,
reference_doctype: _event.referenceDoctype,
reference_docname: _event.referenceDocname,
event_participants: _event.event_participants,
},
{
onSuccess: async (e) => {
@ -221,8 +241,6 @@ function updateEvent(_event) {
},
)
}
event.value = _event
}
function deleteEvent(eventID) {
@ -251,6 +269,11 @@ function deleteEvent(eventID) {
})
}
function syncEvent(eventID, _event) {
if (!eventID) return
Object.assign(events.data.filter((event) => event.id === eventID)[0], _event)
}
onMounted(() => {
activeEvent.value = ''
mode.value = ''
@ -270,7 +293,7 @@ function showDetails(e) {
)
showEventPanel.value = true
event.value = events.data.find((ev) => ev.id === _e.id) || _e
event.value = { id: _e.id }
activeEvent.value = _e.id
mode.value = 'details'
}
@ -284,7 +307,7 @@ function editDetails(e) {
)
showEventPanel.value = true
event.value = events.data.find((ev) => ev.id === _e.id) || _e
event.value = { id: _e.id }
activeEvent.value = _e.id
mode.value = 'edit'
}
@ -320,6 +343,9 @@ function newEvent(e, duplicate = false) {
isFullDay: e.isFullDay || false,
eventType: e.eventType || 'Public',
color: e.color || 'green',
referenceDoctype: e.referenceDoctype,
referenceDocname: e.referenceDocname,
event_participants: e.event_participants || [],
}
events.data.push(event.value)
@ -384,4 +410,34 @@ function getFromToTime(time) {
return [fromTime, toTime]
}
// Helper: create Contact docs for participants missing reference_docname
async function ensureParticipantContacts(participants) {
if (!Array.isArray(participants) || !participants.length) return participants
const updated = []
for (const part of participants) {
const p = { ...part }
try {
if (
p.reference_doctype === 'Contact' &&
(!p.reference_docname || p.reference_docname === 'new') &&
p.email
) {
const firstName = p.email.split('@')[0] || p.email
const contactDoc = await call('frappe.client.insert', {
doc: {
doctype: 'Contact',
first_name: firstName,
email_ids: [{ email_id: p.email, is_primary: 1 }],
},
})
if (contactDoc?.name) p.reference_docname = contactDoc.name
}
} catch (e) {
console.error('Failed creating contact for participant', p.email, e)
}
updated.push(p)
}
return updated
}
</script>