feat: add CalendarEventPanel and TimePicker components for event management in Calendar

This commit is contained in:
Shariq Ansari 2025-08-05 09:55:16 +05:30
parent e109b59a55
commit 60b5665981
5 changed files with 642 additions and 102 deletions

View File

@ -46,6 +46,7 @@ declare module 'vue' {
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarEventPanel: typeof import('./src/components/Calendar/CalendarEventPanel.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CalendarModal: typeof import('./src/components/Modals/CalendarModal.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
@ -189,7 +190,9 @@ declare module 'vue' {
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
LucideEarth: typeof import('~icons/lucide/earth')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideText: typeof import('~icons/lucide/text')['default']
LucideX: typeof import('~icons/lucide/x')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
@ -266,6 +269,7 @@ declare module 'vue' {
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
TimePicker: typeof import('./src/components/Calendar/TimePicker.vue')['default']
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']

View File

@ -0,0 +1,253 @@
<template>
<div v-if="show" class="w-[352px] border-l">
<div
class="flex items-center justify-between p-4.5 text-ink-gray-7 text-lg font-medium"
>
<div>{{ __(title) }}</div>
<div class="flex items-center gap-x-2">
<Dropdown
v-if="event.id"
:options="[
{
label: __('Delete'),
value: 'delete',
icon: 'trash-2',
onClick: deleteEvent,
},
]"
>
<Button variant="ghost" icon="more-horizontal" />
</Dropdown>
<Button icon="x" variant="ghost" @click="show = false" />
</div>
</div>
<div class="text-base">
<div>
<div class="px-4.5 py-3">
<TextInput
ref="eventTitle"
v-model="_event.title"
:debounce="500"
:placeholder="__('Event title')"
>
<template #prefix>
<div
class="ml-0.5 bg-surface-blue-3 size-2 rounded-full cursor-pointer"
/>
</template>
</TextInput>
</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" />
<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="">{{ __('From') }}</div>
<div class="flex items-center gap-x-2">
<DatePicker
:class="[_event.isFullDay ? '[&_input]:w-[216px]' : 'max-w-28']"
variant="outline"
:value="_event.fromDate"
:formatter="(date) => getFormat(date, 'MMM D, YYYY')"
:placeholder="__('Start Date')"
@update:modelValue="(date) => updateDate(date, true)"
/>
<TimePicker
v-if="!_event.isFullDay"
class="max-w-24"
variant="outline"
:value="_event.fromTime"
:placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)"
/>
</div>
</div>
<div
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="w-20">{{ __('To') }}</div>
<div class="flex items-center gap-x-2">
<DatePicker
:class="[_event.isFullDay ? '[&_input]:w-[216px]' : 'max-w-28']"
variant="outline"
:value="_event.toDate"
:formatter="(date) => getFormat(date, 'MMM D, YYYY')"
:placeholder="__('End Date')"
@update:modelValue="(date) => updateDate(date)"
/>
<TimePicker
v-if="!_event.isFullDay"
class="max-w-24"
variant="outline"
:value="_event.toTime"
:placeholder="__('End Time')"
@update:modelValue="(time) => updateTime(time)"
/>
</div>
</div>
<div class="mx-4.5 my-2.5 border-t" />
<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"
:bubbleMenu="true"
:content="_event.description"
@change="(val) => (_event.description = val)"
:placeholder="__('Add description')"
/>
</div>
<div class="my-3">
<Button
variant="solid"
class="w-full"
:disabled="!dirty"
@click="saveEvent"
>
{{ _event.id ? __('Save') : __('Create event') }}
</Button>
</div>
<ErrorMessage :message="error" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import TimePicker from './TimePicker.vue'
import { getFormat } from '@/utils'
import {
TextInput,
Switch,
DatePicker,
TextEditor,
ErrorMessage,
Dropdown,
dayjs,
} from 'frappe-ui'
import { ref, computed, watch } from 'vue'
const props = defineProps({
event: {
type: Object,
default: null,
},
})
const emit = defineEmits(['save', 'delete'])
const show = defineModel()
const title = ref('New event')
const _event = ref({})
const eventTitle = ref(null)
const error = ref(null)
const dirty = computed(() => {
return JSON.stringify(_event.value) !== JSON.stringify(props.event)
})
watch(
() => props.event,
(newEvent) => {
error.value = null
_event.value = { ...newEvent }
if (newEvent && newEvent.id) {
title.value = 'Event details'
} else {
title.value = 'New event'
}
setTimeout(() => eventTitle.value?.el?.focus(), 100)
},
{ immediate: true },
)
function updateDate(d, fromDate = false) {
error.value = null
let oldTo = _event.value.toDate || _event.value.fromDate
if (fromDate) {
_event.value.fromDate = d
if (!_event.value.toDate) {
_event.value.toDate = d
}
} else {
_event.value.toDate = d
}
if (_event.value.toDate && _event.value.fromDate) {
const diff = dayjs(_event.value.toDate).diff(
dayjs(_event.value.fromDate),
'day',
)
if (diff < 0) {
_event.value.toDate = oldTo
error.value = __('End date should be after start date')
return
}
}
}
function updateTime(t, fromTime = false) {
error.value = null
let oldTo = _event.value.toTime || _event.value.fromTime
if (fromTime) {
_event.value.fromTime = t
if (!_event.value.toTime) {
const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1])
_event.value.toTime = `${hour + 1}:${minute}`
}
} else {
_event.value.toTime = t
}
if (_event.value.toTime && _event.value.fromTime) {
const diff = dayjs(_event.value.toDate + ' ' + _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
}
}
}
function saveEvent() {
error.value = null
if (!_event.value.title) {
error.value = __('Title is required')
eventTitle.value.el.focus()
return
}
_event.value.fromDateTime =
_event.value.fromDate + ' ' + _event.value.fromTime
_event.value.toDateTime = _event.value.toDate + ' ' + _event.value.toTime
emit('save', _event.value)
}
function deleteEvent() {
emit('delete', _event.value.id)
}
</script>

View File

@ -0,0 +1,266 @@
<template>
<Dropdown :options="options()">
<template #default="{ open }">
<slot
v-bind="{
emitUpdate,
timeValue,
isSelectedOrNearestOption,
updateScroll,
}"
>
<TextInput
:variant="variant"
class="text-sm"
v-bind="$attrs"
type="text"
:value="timeValue"
:placeholder="placeholder"
@change="(e) => emitUpdate(e.target.value)"
/>
</slot>
</template>
<template #body>
<div
class="mt-2 min-w-40 max-h-72 overflow-hidden overflow-y-auto divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none [&>div]:focus-visible:ring-0"
:class="{
'mt-2': ['bottom', 'left', 'right'].includes(placement),
'ml-2': placement == 'right-start',
}"
>
<MenuItems class="p-1" ref="menu">
<MenuItem
v-for="option in options()"
:key="option.value"
:data-value="option.value"
>
<slot
name="menu-item"
v-bind="{ option, isSelectedOrNearestOption, updateScroll }"
>
<button
:class="[
option.isSelected()
? 'bg-surface-gray-3 text-ink-gray-8'
: 'text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8',
'group flex h-7 w-full items-center rounded px-2 text-base ',
]"
@click="option.onClick"
>
{{ option.label }}
</button>
</slot>
</MenuItem>
</MenuItems>
</div>
</template>
</Dropdown>
</template>
<script setup>
import { TextInput } from 'frappe-ui'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import { MenuItems, MenuItem } from '@headlessui/vue'
import { ref, computed, watch } from 'vue'
const props = defineProps({
value: {
type: String,
default: '',
},
modelValue: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: 'Select Time',
},
placement: {
type: String,
default: 'bottom',
},
})
const emit = defineEmits(['update:modelValue'])
const emitUpdate = (value) => {
emit('update:modelValue', convertTo24HourFormat(value))
}
const timeValue = computed(() => {
let time = props.value ? props.value : props.modelValue
if (!time) return ''
if (time && time.length > 5) {
time = time.substring(0, 5)
}
if (timeMap[time]) {
time = timeMap[time]
} else {
const [hour, minute] = time.split(':')
const ampm = hour >= 12 ? 'pm' : 'am'
const formattedHour = hour % 12 || 12
time = `${formattedHour}:${minute} ${ampm}`
}
return time
})
const options = () => {
let timeOptions = []
for (const [key, value] of Object.entries(timeMap)) {
timeOptions.push({
label: value,
value: key,
onClick: () => emitUpdate(key),
isSelected: () => {
let isSelected = isSelectedOrNearestOption()
return isSelected?.value === key && !isSelected?.isNearest
},
})
}
return timeOptions
}
const menu = ref(null)
watch(
() => menu.value?.el,
(newValue) => {
if (newValue) {
updateScroll(newValue)
}
},
)
function convertTo24HourFormat(time) {
if (time && time.length > 5) {
time = time.trim().replace(' ', '')
const ampm = time.slice(-2)
time = time.slice(0, -2)
let [hour, minute] = time.split(':')
if (ampm === 'pm' && parseInt(hour) < 12) {
hour = parseInt(hour) + 12
} else if (ampm === 'am' && hour == 12) {
hour = 0
}
time = `${hour.toString().padStart(2, '0')}:${minute}`
}
return time
}
function isSelectedOrNearestOption() {
const selectedTime = timeValue.value
const selectedOption = options().find(
(option) => option.label === selectedTime,
)
if (selectedOption) {
return {
...selectedOption,
isNearest: false,
}
}
// remove hour from timeValue
let time = convertTo24HourFormat(timeValue.value)
const [hour, minute] = time.split(':')
// find nearest option where hour is same
const nearestOption = options().find((option) => {
const [optionHour] = option.value.split(':')
return optionHour === hour
})
if (nearestOption) {
return {
...nearestOption,
isNearest: true,
}
}
return null
}
function updateScroll(el) {
const selectedOption = options().find(
(option) => option.label === timeValue.value,
)
let selectedTimeObj = selectedOption ? { ...selectedOption } : null
if (!selectedTimeObj) {
selectedTimeObj = isSelectedOrNearestOption()
}
if (selectedTimeObj) {
const selectedElement = el.querySelector(
`[data-value="${selectedTimeObj.value}"]`,
)
if (selectedElement) {
selectedElement.scrollIntoView({
inline: 'start',
block: 'start',
})
}
}
}
const timeMap = {
'00:00': '12:00 am',
'00:30': '12:30 am',
'01:00': '1:00 am',
'01:30': '1:30 am',
'02:00': '2:00 am',
'02:30': '2:30 am',
'03:00': '3:00 am',
'03:30': '3:30 am',
'04:00': '4:00 am',
'04:30': '4:30 am',
'05:00': '5:00 am',
'05:30': '5:30 am',
'06:00': '6:00 am',
'06:30': '6:30 am',
'07:00': '7:00 am',
'07:30': '7:30 am',
'08:00': '8:00 am',
'08:30': '8:30 am',
'09:00': '9:00 am',
'09:30': '9:30 am',
'10:00': '10:00 am',
'10:30': '10:30 am',
'11:00': '11:00 am',
'11:30': '11:30 am',
'12:00': '12:00 pm',
'12:30': '12:30 pm',
'13:00': '1:00 pm',
'13:30': '1:30 pm',
'14:00': '2:00 pm',
'14:30': '2:30 pm',
'15:00': '3:00 pm',
'15:30': '3:30 pm',
'16:00': '4:00 pm',
'16:30': '4:30 pm',
'17:00': '5:00 pm',
'17:30': '5:30 pm',
'18:00': '6:00 pm',
'18:30': '6:30 pm',
'19:00': '7:00 pm',
'19:30': '7:30 pm',
'20:00': '8:00 pm',
'20:30': '8:30 pm',
'21:00': '9:00 pm',
'21:30': '9:30 pm',
'22:00': '10:00 pm',
'22:30': '10:30 pm',
'23:00': '11:00 pm',
'23:30': '11:30 pm',
}
</script>

View File

@ -15,71 +15,73 @@
</template>
<template #body>
<div
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
:class="{
'mt-2': ['bottom', 'left', 'right'].includes(placement),
'ml-2': placement == 'right-start',
}"
>
<MenuItems
class="min-w-40 divide-y divide-outline-gray-modals"
<slot name="body" v-bind="{ open, placement }">
<div
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
:class="{
'left-0 origin-top-left': placement == 'left',
'right-0 origin-top-right': placement == 'right',
'inset-x-0 origin-top': placement == 'center',
'mt-0 origin-top-right': placement == 'right-start',
'mt-2': ['bottom', 'left', 'right'].includes(placement),
'ml-2': placement == 'right-start',
}"
>
<div v-for="group in groups" :key="group.key" class="p-1.5">
<div
v-if="group.group && !group.hideLabel"
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<MenuItem
v-for="item in group.items"
:key="item.label"
v-slot="{ active }"
>
<slot name="item" v-bind="{ item, active }">
<component
v-if="item.component"
:is="item.component"
:active="active"
/>
<button
v-else
:class="[
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
'group flex h-7 w-full items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
aria-hidden="true"
/>
<MenuItems
class="min-w-40 divide-y divide-outline-gray-modals"
:class="{
'left-0 origin-top-left': placement == 'left',
'right-0 origin-top-right': placement == 'right',
'inset-x-0 origin-top': placement == 'center',
'mt-0 origin-top-right': placement == 'right-start',
}"
>
<div v-for="group in groups" :key="group.key" class="p-1.5">
<div
v-if="group.group && !group.hideLabel"
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<MenuItem
v-for="item in group.items"
:key="item.label"
v-slot="{ active }"
>
<slot name="item" v-bind="{ item, active }">
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
v-else-if="item.icon"
:is="item.icon"
v-if="item.component"
:is="item.component"
:active="active"
/>
<span class="whitespace-nowrap text-ink-gray-7">
{{ item.label }}
</span>
</button>
</slot>
</MenuItem>
<button
v-else
:class="[
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
'group flex h-7 w-full items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
aria-hidden="true"
/>
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
v-else-if="item.icon"
:is="item.icon"
/>
<span class="whitespace-nowrap text-ink-gray-7">
{{ item.label }}
</span>
</button>
</slot>
</MenuItem>
</div>
</MenuItems>
<div v-if="slots.footer" class="border-t p-1.5">
<slot name="footer"></slot>
</div>
</MenuItems>
<div v-if="slots.footer" class="border-t p-1.5">
<slot name="footer"></slot>
</div>
</div>
</slot>
</template>
</Popover>
</Menu>

View File

@ -9,9 +9,10 @@
</Button>
</template>
</LayoutHeader>
<div class="flex h-screen flex-col overflow-hidden">
<div class="flex h-screen overflow-hidden">
<Calendar
v-if="events.data?.length"
class="flex-1 overflow-hidden"
ref="calendar"
:config="{
defaultMode: 'Week',
@ -28,7 +29,7 @@
@delete="(eventID) => deleteEvent(eventID)"
:onClick="showDetails"
:onDblClick="editDetails"
:onCellDblClick="showNewModal"
:onCellDblClick="showEventPanelArea"
>
<template
#header="{
@ -80,16 +81,18 @@
</p>
</template>
</Calendar>
<CalendarModal
v-model="showModal"
v-model:event="event"
<CalendarEventPanel
v-if="showEventPanel"
v-model="showEventPanel"
:event="event"
@save="saveEvent"
@delete="deleteEvent"
/>
</div>
</template>
<script setup>
import CalendarModal from '@/components/Modals/CalendarModal.vue'
import CalendarEventPanel from '@/components/Calendar/CalendarEventPanel.vue'
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import { sessionStore } from '@/stores/session'
@ -151,43 +154,47 @@ const events = createListResource({
},
})
function saveEvent(event) {
event.id ? updateEvent(event) : createEvent(event)
function saveEvent(_event) {
_event.id ? updateEvent(_event) : createEvent(_event)
}
function createEvent(event) {
if (!event.title) return
function createEvent(_event) {
if (!_event.title) return
events.insert.submit({
subject: event.title,
description: event.description,
starts_on: event.fromDateTime,
ends_on: event.toDateTime,
all_day: event.isFullDay,
event_type: event.eventType,
color: event.color,
})
showModal.value = false
event.value = {}
events.insert.submit(
{
subject: _event.title,
description: _event.description,
starts_on: _event.fromDateTime,
ends_on: _event.toDateTime,
all_day: _event.isFullDay,
event_type: _event.eventType,
color: _event.color,
},
{
onSuccess: (e) => {
_event.id = e.name
event.value = _event
},
},
)
}
function updateEvent(event) {
if (!event.id) return
function updateEvent(_event) {
if (!_event.id) return
events.setValue.submit({
name: event.id,
subject: event.title,
description: event.description,
starts_on: event.fromDateTime,
ends_on: event.toDateTime,
all_day: event.isFullDay,
event_type: event.eventType,
color: event.color,
name: _event.id,
subject: _event.title,
description: _event.description,
starts_on: _event.fromDateTime,
ends_on: _event.toDateTime,
all_day: _event.isFullDay,
event_type: _event.eventType,
color: _event.color,
})
showModal.value = false
event.value = {}
event.value = _event
}
function deleteEvent(eventID) {
@ -205,6 +212,7 @@ function deleteEvent(eventID) {
theme: 'red',
onClick: (close) => {
events.delete.submit(eventID)
showEventPanel.value = false
close()
},
},
@ -212,21 +220,22 @@ function deleteEvent(eventID) {
})
}
const showModal = ref(false)
const showEventPanel = ref(false)
const event = ref({})
function showDetails(e) {}
function editDetails(e) {
showModal.value = true
showEventPanel.value = true
event.value = { ...e.calendarEvent }
}
function showNewModal(e) {
function showEventPanelArea(e) {
let [fromTime, toTime] = getFromToTime(e.time)
let fromDate = dayjs(e.date).format('YYYY-MM-DD')
showModal.value = true
showEventPanel.value = true
event.value = {
title: '',
description: '',
@ -242,8 +251,15 @@ function showNewModal(e) {
// utils
function getFromToTime(time) {
let fromTime = '00:00'
let toTime = '01:00'
let currentTime = dayjs().format('HH:mm') || '00:00'
let h = currentTime.split(':')[0]
let m = parseInt(currentTime.split(':')[1])
m = Math.floor(m / 15) * 15
m = m < 10 ? '0' + m : String(m)
let fromTime = `${h}:${m}`
let toTime = `${parseInt(h) + 1}:${m}`
if (time.toLowerCase().includes('am') || time.toLowerCase().includes('pm')) {
// 12 hour format
@ -262,11 +278,10 @@ function getFromToTime(time) {
toTime = `${parseInt(hour) + 1}:00`
} else {
// 24 hour format
time = time.split(':')
let [hour, minute] = time
let [hour, minute] = time ? time.split(':') : [h, m]
fromTime = `${hour}:${minute}`
toTime = `${parseInt(hour) + 1}:${minute}`
fromTime = `${hour}:${minute || '00'}`
toTime = `${parseInt(hour) + 1}:${minute || '00'}`
}
return [fromTime, toTime]