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']
|
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
|
||||||
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
|
||||||
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.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']
|
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||||
CalendarModal: typeof import('./src/components/Modals/CalendarModal.vue')['default']
|
CalendarModal: typeof import('./src/components/Modals/CalendarModal.vue')['default']
|
||||||
CallArea: typeof import('./src/components/Activities/CallArea.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']
|
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||||
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||||
|
LucideEarth: typeof import('~icons/lucide/earth')['default']
|
||||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||||
|
LucideText: typeof import('~icons/lucide/text')['default']
|
||||||
LucideX: typeof import('~icons/lucide/x')['default']
|
LucideX: typeof import('~icons/lucide/x')['default']
|
||||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.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']
|
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
|
||||||
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
||||||
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.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']
|
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
||||||
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
||||||
UserAvatar: typeof import('./src/components/UserAvatar.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>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<div
|
<slot name="body" v-bind="{ open, placement }">
|
||||||
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"
|
<div
|
||||||
:class="{
|
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"
|
||||||
'mt-2': ['bottom', 'left', 'right'].includes(placement),
|
|
||||||
'ml-2': placement == 'right-start',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<MenuItems
|
|
||||||
class="min-w-40 divide-y divide-outline-gray-modals"
|
|
||||||
:class="{
|
:class="{
|
||||||
'left-0 origin-top-left': placement == 'left',
|
'mt-2': ['bottom', 'left', 'right'].includes(placement),
|
||||||
'right-0 origin-top-right': placement == 'right',
|
'ml-2': placement == 'right-start',
|
||||||
'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">
|
<MenuItems
|
||||||
<div
|
class="min-w-40 divide-y divide-outline-gray-modals"
|
||||||
v-if="group.group && !group.hideLabel"
|
:class="{
|
||||||
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
|
'left-0 origin-top-left': placement == 'left',
|
||||||
>
|
'right-0 origin-top-right': placement == 'right',
|
||||||
{{ group.group }}
|
'inset-x-0 origin-top': placement == 'center',
|
||||||
</div>
|
'mt-0 origin-top-right': placement == 'right-start',
|
||||||
<MenuItem
|
}"
|
||||||
v-for="item in group.items"
|
>
|
||||||
:key="item.label"
|
<div v-for="group in groups" :key="group.key" class="p-1.5">
|
||||||
v-slot="{ active }"
|
<div
|
||||||
>
|
v-if="group.group && !group.hideLabel"
|
||||||
<slot name="item" v-bind="{ item, active }">
|
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
|
||||||
<component
|
>
|
||||||
v-if="item.component"
|
{{ group.group }}
|
||||||
:is="item.component"
|
</div>
|
||||||
:active="active"
|
<MenuItem
|
||||||
/>
|
v-for="item in group.items"
|
||||||
<button
|
:key="item.label"
|
||||||
v-else
|
v-slot="{ active }"
|
||||||
:class="[
|
>
|
||||||
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
|
<slot name="item" v-bind="{ item, active }">
|
||||||
'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
|
<component
|
||||||
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
|
v-if="item.component"
|
||||||
v-else-if="item.icon"
|
:is="item.component"
|
||||||
:is="item.icon"
|
:active="active"
|
||||||
/>
|
/>
|
||||||
<span class="whitespace-nowrap text-ink-gray-7">
|
<button
|
||||||
{{ item.label }}
|
v-else
|
||||||
</span>
|
:class="[
|
||||||
</button>
|
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
|
||||||
</slot>
|
'group flex h-7 w-full items-center rounded px-2 text-base',
|
||||||
</MenuItem>
|
]"
|
||||||
|
@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>
|
</div>
|
||||||
</MenuItems>
|
|
||||||
<div v-if="slots.footer" class="border-t p-1.5">
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@ -9,9 +9,10 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</LayoutHeader>
|
</LayoutHeader>
|
||||||
<div class="flex h-screen flex-col overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<Calendar
|
<Calendar
|
||||||
v-if="events.data?.length"
|
v-if="events.data?.length"
|
||||||
|
class="flex-1 overflow-hidden"
|
||||||
ref="calendar"
|
ref="calendar"
|
||||||
:config="{
|
:config="{
|
||||||
defaultMode: 'Week',
|
defaultMode: 'Week',
|
||||||
@ -28,7 +29,7 @@
|
|||||||
@delete="(eventID) => deleteEvent(eventID)"
|
@delete="(eventID) => deleteEvent(eventID)"
|
||||||
:onClick="showDetails"
|
:onClick="showDetails"
|
||||||
:onDblClick="editDetails"
|
:onDblClick="editDetails"
|
||||||
:onCellDblClick="showNewModal"
|
:onCellDblClick="showEventPanelArea"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
#header="{
|
#header="{
|
||||||
@ -80,16 +81,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</Calendar>
|
</Calendar>
|
||||||
<CalendarModal
|
|
||||||
v-model="showModal"
|
<CalendarEventPanel
|
||||||
v-model:event="event"
|
v-if="showEventPanel"
|
||||||
|
v-model="showEventPanel"
|
||||||
|
:event="event"
|
||||||
@save="saveEvent"
|
@save="saveEvent"
|
||||||
@delete="deleteEvent"
|
@delete="deleteEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import CalendarModal from '@/components/Modals/CalendarModal.vue'
|
import CalendarEventPanel from '@/components/Calendar/CalendarEventPanel.vue'
|
||||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
@ -151,43 +154,47 @@ const events = createListResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function saveEvent(event) {
|
function saveEvent(_event) {
|
||||||
event.id ? updateEvent(event) : createEvent(event)
|
_event.id ? updateEvent(_event) : createEvent(_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEvent(event) {
|
function createEvent(_event) {
|
||||||
if (!event.title) return
|
if (!_event.title) return
|
||||||
|
|
||||||
events.insert.submit({
|
events.insert.submit(
|
||||||
subject: event.title,
|
{
|
||||||
description: event.description,
|
subject: _event.title,
|
||||||
starts_on: event.fromDateTime,
|
description: _event.description,
|
||||||
ends_on: event.toDateTime,
|
starts_on: _event.fromDateTime,
|
||||||
all_day: event.isFullDay,
|
ends_on: _event.toDateTime,
|
||||||
event_type: event.eventType,
|
all_day: _event.isFullDay,
|
||||||
color: event.color,
|
event_type: _event.eventType,
|
||||||
})
|
color: _event.color,
|
||||||
|
},
|
||||||
showModal.value = false
|
{
|
||||||
event.value = {}
|
onSuccess: (e) => {
|
||||||
|
_event.id = e.name
|
||||||
|
event.value = _event
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEvent(event) {
|
function updateEvent(_event) {
|
||||||
if (!event.id) return
|
if (!_event.id) return
|
||||||
|
|
||||||
events.setValue.submit({
|
events.setValue.submit({
|
||||||
name: event.id,
|
name: _event.id,
|
||||||
subject: event.title,
|
subject: _event.title,
|
||||||
description: event.description,
|
description: _event.description,
|
||||||
starts_on: event.fromDateTime,
|
starts_on: _event.fromDateTime,
|
||||||
ends_on: event.toDateTime,
|
ends_on: _event.toDateTime,
|
||||||
all_day: event.isFullDay,
|
all_day: _event.isFullDay,
|
||||||
event_type: event.eventType,
|
event_type: _event.eventType,
|
||||||
color: event.color,
|
color: _event.color,
|
||||||
})
|
})
|
||||||
|
|
||||||
showModal.value = false
|
event.value = _event
|
||||||
event.value = {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent(eventID) {
|
function deleteEvent(eventID) {
|
||||||
@ -205,6 +212,7 @@ function deleteEvent(eventID) {
|
|||||||
theme: 'red',
|
theme: 'red',
|
||||||
onClick: (close) => {
|
onClick: (close) => {
|
||||||
events.delete.submit(eventID)
|
events.delete.submit(eventID)
|
||||||
|
showEventPanel.value = false
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -212,21 +220,22 @@ function deleteEvent(eventID) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showEventPanel = ref(false)
|
||||||
const event = ref({})
|
const event = ref({})
|
||||||
|
|
||||||
function showDetails(e) {}
|
function showDetails(e) {}
|
||||||
|
|
||||||
function editDetails(e) {
|
function editDetails(e) {
|
||||||
showModal.value = true
|
showEventPanel.value = true
|
||||||
event.value = { ...e.calendarEvent }
|
event.value = { ...e.calendarEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNewModal(e) {
|
function showEventPanelArea(e) {
|
||||||
let [fromTime, toTime] = getFromToTime(e.time)
|
let [fromTime, toTime] = getFromToTime(e.time)
|
||||||
|
|
||||||
let fromDate = dayjs(e.date).format('YYYY-MM-DD')
|
let fromDate = dayjs(e.date).format('YYYY-MM-DD')
|
||||||
|
|
||||||
showModal.value = true
|
showEventPanel.value = true
|
||||||
event.value = {
|
event.value = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -242,8 +251,15 @@ function showNewModal(e) {
|
|||||||
|
|
||||||
// utils
|
// utils
|
||||||
function getFromToTime(time) {
|
function getFromToTime(time) {
|
||||||
let fromTime = '00:00'
|
let currentTime = dayjs().format('HH:mm') || '00:00'
|
||||||
let toTime = '01: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')) {
|
if (time.toLowerCase().includes('am') || time.toLowerCase().includes('pm')) {
|
||||||
// 12 hour format
|
// 12 hour format
|
||||||
@ -262,11 +278,10 @@ function getFromToTime(time) {
|
|||||||
toTime = `${parseInt(hour) + 1}:00`
|
toTime = `${parseInt(hour) + 1}:00`
|
||||||
} else {
|
} else {
|
||||||
// 24 hour format
|
// 24 hour format
|
||||||
time = time.split(':')
|
let [hour, minute] = time ? time.split(':') : [h, m]
|
||||||
let [hour, minute] = time
|
|
||||||
|
|
||||||
fromTime = `${hour}:${minute}`
|
fromTime = `${hour}:${minute || '00'}`
|
||||||
toTime = `${parseInt(hour) + 1}:${minute}`
|
toTime = `${parseInt(hour) + 1}:${minute || '00'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return [fromTime, toTime]
|
return [fromTime, toTime]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user