761 lines
21 KiB
Vue
761 lines
21 KiB
Vue
<template>
|
|
<div v-if="show" class="flex flex-col w-[352px] text-base">
|
|
<!-- Event Header -->
|
|
<div
|
|
class="flex items-center justify-between p-4.5 text-ink-gray-7 text-lg font-medium"
|
|
>
|
|
<div
|
|
class="flex items-center gap-x-2"
|
|
:class="mode == 'edit' && 'cursor-pointer hover:text-ink-gray-8'"
|
|
@click="mode == 'edit' && details()"
|
|
>
|
|
<LucideChevronLeft v-if="mode == 'edit'" class="size-4" />
|
|
{{ __(title) }}
|
|
</div>
|
|
<div class="flex items-center gap-x-1">
|
|
<Button v-if="mode == 'details'" variant="ghost" @click="editDetails">
|
|
<template #icon>
|
|
<EditIcon class="size-4" />
|
|
</template>
|
|
</Button>
|
|
<Button
|
|
v-if="mode === 'edit' || mode === 'details'"
|
|
icon="trash-2"
|
|
variant="ghost"
|
|
@click="deleteEvent"
|
|
/>
|
|
<Dropdown
|
|
v-if="mode === 'edit' || mode === 'details'"
|
|
:options="[
|
|
{
|
|
label: __('Duplicate'),
|
|
icon: 'copy',
|
|
onClick: duplicateEvent,
|
|
},
|
|
]"
|
|
>
|
|
<Button variant="ghost" icon="more-vertical" />
|
|
</Dropdown>
|
|
<Button icon="x" variant="ghost" @click="close" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event 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',
|
|
}"
|
|
/>
|
|
<div class="flex flex-col gap-[3px]">
|
|
<div class="text-ink-gray-8 font-semibold text-xl">
|
|
{{ _event.title || __('(No title)') }}
|
|
</div>
|
|
<div class="text-ink-gray-6 text-p-base">{{ formattedDateTime }}</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="_event.referenceDocname"
|
|
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
|
|
/>
|
|
<div
|
|
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"
|
|
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"
|
|
:disabled="true"
|
|
/>
|
|
<Button variant="ghost" @click="redirect">
|
|
<template #icon>
|
|
<ArrowUpRightIcon class="size-4 text-ink-gray-7" />
|
|
</template>
|
|
</Button>
|
|
</div>
|
|
<div
|
|
v-if="peoples.length"
|
|
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
|
|
/>
|
|
<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-2 -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 displayedPeoples"
|
|
: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>
|
|
<Button
|
|
v-if="!showAllParticipants && peoples.length > 2"
|
|
variant="ghost"
|
|
:label="__('See all participants')"
|
|
iconLeft="more-horizontal"
|
|
class="!justify-start w-fit"
|
|
@click="showAllParticipants = true"
|
|
/>
|
|
<Button
|
|
v-else-if="showAllParticipants"
|
|
variant="ghost"
|
|
:label="__('Show less')"
|
|
iconLeft="chevron-up"
|
|
class="!justify-start w-fit"
|
|
@click="showAllParticipants = false"
|
|
/>
|
|
</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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event new, duplicate & edit -->
|
|
<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"
|
|
class="w-full"
|
|
variant="outline"
|
|
v-model="_event.title"
|
|
:debounce="500"
|
|
:placeholder="__('Event title')"
|
|
@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" @update:model-value="sync" />
|
|
<div class="ml-2">
|
|
{{ __('All day') }}
|
|
</div>
|
|
</div>
|
|
<!-- <div class="flex items-center gap-1.5 text-ink-gray-5">
|
|
<LucideEarth class="size-4" />
|
|
{{ __('GMT+5:30') }}
|
|
</div> -->
|
|
</div>
|
|
<div
|
|
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
|
>
|
|
<div class="">{{ __('Date') }}</div>
|
|
<div class="flex items-center gap-x-1.5">
|
|
<DatePicker
|
|
:class="['[&_input]:w-[216px]']"
|
|
variant="outline"
|
|
:value="_event.fromDate"
|
|
:format="'MMM D, YYYY'"
|
|
:placeholder="__('May 1, 2025')"
|
|
:clearable="false"
|
|
@update:modelValue="(date) => updateDate(date, true)"
|
|
>
|
|
<template #suffix="{ togglePopover }">
|
|
<FeatherIcon
|
|
name="chevron-down"
|
|
class="h-4 w-4 cursor-pointer"
|
|
@click="togglePopover"
|
|
/>
|
|
</template>
|
|
</DatePicker>
|
|
</div>
|
|
</div>
|
|
<div
|
|
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"
|
|
class="max-w-[105px]"
|
|
variant="outline"
|
|
:modelValue="_event.fromTime"
|
|
:placeholder="__('Start Time')"
|
|
@update:modelValue="(time) => updateTime(time, true)"
|
|
/>
|
|
<TimePicker
|
|
class="max-w-[105px]"
|
|
variant="outline"
|
|
:modelValue="_event.toTime"
|
|
:options="toOptions"
|
|
:placeholder="__('End Time')"
|
|
placement="bottom-end"
|
|
@update:modelValue="(time) => updateTime(time)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
|
|
<div
|
|
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
|
>
|
|
<div class="">{{ __('Link') }}</div>
|
|
<div class="flex items-center gap-x-1.5">
|
|
<FormControl
|
|
class="w-[216px]"
|
|
type="select"
|
|
:options="[
|
|
{
|
|
label: '',
|
|
value: '',
|
|
},
|
|
{
|
|
label: __('Lead'),
|
|
value: 'CRM Lead',
|
|
},
|
|
{
|
|
label: __('Deal'),
|
|
value: 'CRM Deal',
|
|
},
|
|
]"
|
|
v-model="_event.referenceDoctype"
|
|
variant="outline"
|
|
:placeholder="__('Add Lead or Deal')"
|
|
@change="
|
|
() => {
|
|
_event.referenceDocname = ''
|
|
sync()
|
|
}
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
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') }}
|
|
</div>
|
|
<div class="flex items-center gap-x-1.5">
|
|
<Link
|
|
class="w-[220px]"
|
|
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-[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
|
|
sync()
|
|
}
|
|
"
|
|
:placeholder="__('Add description')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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'
|
|
import Link from '@/components/Controls/Link.vue'
|
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
|
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
|
|
import TimePicker from '@/components/Calendar/TimePicker.vue'
|
|
import { globalStore } from '@/stores/global'
|
|
import { usersStore } from '@/stores/users'
|
|
import { validateEmail } from '@/utils'
|
|
import { allTimeSlots } from '@/components/Calendar/utils'
|
|
import {
|
|
TextInput,
|
|
Switch,
|
|
DatePicker,
|
|
TextEditor,
|
|
ErrorMessage,
|
|
Dropdown,
|
|
dayjs,
|
|
CalendarColorMap as colorMap,
|
|
CalendarActiveEvent as activeEvent,
|
|
createDocumentResource,
|
|
} from 'frappe-ui'
|
|
import { ref, computed, watch, h } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
const props = defineProps({
|
|
mode: {
|
|
type: String,
|
|
default: 'details',
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'save',
|
|
'edit',
|
|
'delete',
|
|
'details',
|
|
'close',
|
|
'sync',
|
|
'duplicate',
|
|
])
|
|
|
|
const router = useRouter()
|
|
const { $dialog } = globalStore()
|
|
const { getUser } = usersStore()
|
|
|
|
const show = defineModel()
|
|
const event = defineModel('event')
|
|
|
|
const _event = ref({})
|
|
|
|
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
|
|
sync()
|
|
},
|
|
})
|
|
|
|
const title = computed(() => {
|
|
if (props.mode === 'details') return __('Event details')
|
|
if (props.mode === 'edit') return __('Editing event')
|
|
if (props.mode === 'new') return __('New event')
|
|
return __('Duplicate event')
|
|
})
|
|
|
|
const eventTitle = ref(null)
|
|
const error = ref(null)
|
|
const showAllParticipants = ref(false)
|
|
|
|
const eventResource = ref({})
|
|
|
|
const oldEvent = ref(null)
|
|
const dirty = computed(() => {
|
|
return JSON.stringify(oldEvent.value) !== JSON.stringify(_event.value)
|
|
})
|
|
|
|
const displayedPeoples = computed(() => {
|
|
if (showAllParticipants.value) return peoples.value
|
|
return peoples.value.slice(0, 2)
|
|
})
|
|
|
|
watch(
|
|
[() => props.mode, () => event.value],
|
|
() => {
|
|
error.value = null
|
|
focusOnTitle()
|
|
fetchEvent()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
function fetchEvent() {
|
|
if (
|
|
event.value.id &&
|
|
event.value.id !== 'new-event' &&
|
|
event.value.id !== 'duplicate-event'
|
|
) {
|
|
eventResource.value = createDocumentResource({
|
|
doctype: 'Event',
|
|
name: event.value.id,
|
|
fields: ['*'],
|
|
onSuccess: (data) => {
|
|
_event.value = parseEvent(data)
|
|
oldEvent.value = { ..._event.value }
|
|
},
|
|
})
|
|
if (eventResource.value.doc && !event.value.reloadEvent) {
|
|
_event.value = parseEvent(eventResource.value.doc)
|
|
oldEvent.value = { ..._event.value }
|
|
} else {
|
|
eventResource.value.reload()
|
|
}
|
|
} else {
|
|
_event.value = event.value
|
|
oldEvent.value = { ...event.value }
|
|
}
|
|
showAllParticipants.value = false
|
|
}
|
|
|
|
function parseEvent(_e) {
|
|
return {
|
|
id: _e.name,
|
|
title: _e.subject,
|
|
description: _e.description,
|
|
status: _e.status,
|
|
fromDate: dayjs(_e.starts_on).format('YYYY-MM-DD'),
|
|
toDate: dayjs(_e.ends_on).format('YYYY-MM-DD'),
|
|
fromTime: dayjs(_e.starts_on).format('HH:mm'),
|
|
toTime: dayjs(_e.ends_on).format('HH:mm'),
|
|
isFullDay: _e.all_day,
|
|
eventType: _e.event_type,
|
|
color: _e.color,
|
|
referenceDoctype: _e.reference_doctype,
|
|
referenceDocname: _e.reference_docname,
|
|
event_participants: _e.event_participants || [],
|
|
owner: {
|
|
label: getUser(_e.owner).full_name,
|
|
image: getUser(_e.owner).user_image,
|
|
value: _e.owner,
|
|
},
|
|
}
|
|
}
|
|
|
|
function focusOnTitle() {
|
|
setTimeout(() => {
|
|
if (['edit', 'new', 'duplicate'].includes(props.mode)) {
|
|
eventTitle.value?.el?.focus()
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
function sync() {
|
|
emit('sync', _event.value.id, _event.value)
|
|
}
|
|
|
|
function updateDate(d) {
|
|
_event.value.fromDate = d
|
|
_event.value.toDate = d
|
|
|
|
sync()
|
|
}
|
|
|
|
function updateTime(t, fromTime = false) {
|
|
error.value = null
|
|
|
|
if (fromTime) {
|
|
_event.value.fromTime = t
|
|
const hour = parseInt(t.split(':')[0])
|
|
const minute = parseInt(t.split(':')[1])
|
|
|
|
const computePlusHour = () => {
|
|
let nh = hour + 1
|
|
let nm = minute
|
|
if (nh >= 24) {
|
|
nh = 23
|
|
nm = 59
|
|
}
|
|
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
|
|
}
|
|
if (!_event.value.toTime) {
|
|
_event.value.toTime = computePlusHour()
|
|
} else if (_event.value.toTime <= t) {
|
|
_event.value.toTime = computePlusHour()
|
|
}
|
|
} else {
|
|
_event.value.toTime = t
|
|
}
|
|
|
|
validateFromToTime() && sync()
|
|
}
|
|
|
|
function validateFromToTime() {
|
|
// Generic validator for start/end times before saving.
|
|
// Returns true if valid, else sets error message and returns false.
|
|
error.value = null
|
|
// Full day events don't require time validation
|
|
if (_event.value.isFullDay) return true
|
|
|
|
// Only validate within the single start date; ignore any separate end date.
|
|
const fromDate = _event.value.fromDate
|
|
const fromTime = _event.value.fromTime
|
|
const toTime = _event.value.toTime
|
|
|
|
if (!fromTime || !toTime) {
|
|
error.value = __('Start and end time are required')
|
|
return false
|
|
}
|
|
|
|
const 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 (!_event.value.title) {
|
|
error.value = __('Title is required')
|
|
eventTitle.value.el.focus()
|
|
return
|
|
}
|
|
|
|
if (!validateFromToTime()) return
|
|
|
|
oldEvent.value = { ..._event.value }
|
|
emit('save', _event.value)
|
|
}
|
|
|
|
function editDetails() {
|
|
emit('edit', _event.value)
|
|
}
|
|
|
|
function duplicateEvent() {
|
|
if (dirty.value) {
|
|
showDiscardChangesModal(() => reset())
|
|
} else {
|
|
emit('duplicate', _event.value)
|
|
}
|
|
}
|
|
|
|
function deleteEvent() {
|
|
emit('delete', _event.value.id)
|
|
}
|
|
|
|
function details() {
|
|
if (dirty.value) {
|
|
showDiscardChangesModal(() => reset())
|
|
} else {
|
|
emit('details', _event.value)
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
const _close = () => {
|
|
show.value = false
|
|
activeEvent.value = ''
|
|
emit('close', _event.value)
|
|
}
|
|
|
|
if (dirty.value) {
|
|
showDiscardChangesModal(() => {
|
|
reset()
|
|
if (_event.value.id === 'new-event') _close()
|
|
})
|
|
} else {
|
|
if (_event.value.id === 'duplicate-event')
|
|
showDiscardChangesModal(() => _close())
|
|
else _close()
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
Object.assign(_event.value, oldEvent.value)
|
|
sync()
|
|
}
|
|
|
|
function showDiscardChangesModal(action) {
|
|
$dialog({
|
|
title: __('Discard unsaved changes?'),
|
|
message: __(
|
|
'Are you sure you want to discard unsaved changes to this event?',
|
|
),
|
|
actions: [
|
|
{
|
|
label: __('Cancel'),
|
|
onClick: (close) => {
|
|
close()
|
|
},
|
|
},
|
|
{
|
|
label: __('Discard'),
|
|
variant: 'solid',
|
|
onClick: (close) => {
|
|
action()
|
|
close()
|
|
},
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
const formattedDateTime = computed(() => {
|
|
const date = dayjs(_event.value.fromDate)
|
|
|
|
if (_event.value.isFullDay) {
|
|
return `${__('All day')} - ${date.format('ddd, D MMM YYYY')}`
|
|
}
|
|
|
|
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')}`
|
|
})
|
|
|
|
const colors = Object.keys(colorMap).map((color) => ({
|
|
label: color.charAt(0).toUpperCase() + color.slice(1),
|
|
value: colorMap[color].color,
|
|
icon: h('div', {
|
|
class: '!size-2.5 rounded-full',
|
|
style: { backgroundColor: colorMap[color].color },
|
|
}),
|
|
onClick: () => {
|
|
_event.value.color = colorMap[color].color
|
|
sync()
|
|
},
|
|
}))
|
|
|
|
function redirect() {
|
|
if (_event.value.referenceDocname) {
|
|
let name = _event.value.referenceDoctype === 'CRM Lead' ? 'Lead' : 'Deal'
|
|
|
|
let params =
|
|
_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])
|
|
let hours = mins / 60
|
|
|
|
// keep hours decimal to 2 only if decimal is not 0
|
|
if (hours % 1 !== 0) {
|
|
hours = hours.toFixed(2)
|
|
}
|
|
|
|
if (Number.isInteger(hours)) {
|
|
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
|
}
|
|
// Keep decimal representation for > 1 hour fractional durations
|
|
return `${hours} hrs`
|
|
}
|
|
|
|
const toOptions = computed(() => {
|
|
const fromTime = _event.value.fromTime
|
|
const timeSlots = allTimeSlots()
|
|
if (!fromTime) return timeSlots
|
|
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
|
|
const fromTotal = fh * 60 + fm
|
|
// find first slot strictly after fromTime (even if fromTime not exactly a slot)
|
|
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
|
|
if (startIndex === -1) return []
|
|
return timeSlots.slice(startIndex).map((o) => {
|
|
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
|
|
const toTotal = th * 60 + tm
|
|
const duration = toTotal - fromTotal
|
|
return {
|
|
...o,
|
|
label: `${o.label} (${formatDuration(duration)})`,
|
|
}
|
|
})
|
|
})
|
|
|
|
function updateEvent(_e) {
|
|
Object.assign(_event.value, _e)
|
|
}
|
|
|
|
defineExpose({ updateEvent })
|
|
</script>
|