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:
parent
eada826503
commit
9976b9617f
3
frontend/components.d.ts
vendored
3
frontend/components.d.ts
vendored
@ -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']
|
||||
|
||||
318
frontend/src/components/Calendar/Attendee.vue
Normal file
318
frontend/src/components/Calendar/Attendee.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
14
frontend/src/components/Icons/PeopleIcon.vue
Normal file
14
frontend/src/components/Icons/PeopleIcon.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user