feat: add CalendarEventPanel and TimePicker components for event management in Calendar
This commit is contained in:
parent
e109b59a55
commit
60b5665981
4
frontend/components.d.ts
vendored
4
frontend/components.d.ts
vendored
@ -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']
|
||||
|
||||
253
frontend/src/components/Calendar/CalendarEventPanel.vue
Normal file
253
frontend/src/components/Calendar/CalendarEventPanel.vue
Normal 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>
|
||||
266
frontend/src/components/Calendar/TimePicker.vue
Normal file
266
frontend/src/components/Calendar/TimePicker.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user