619 lines
16 KiB
Vue
619 lines
16 KiB
Vue
<template>
|
|
<div class="flex h-full flex-col overflow-hidden">
|
|
<slot
|
|
name="header"
|
|
v-bind="{
|
|
currentMonthYear,
|
|
currentYear,
|
|
currentMonth,
|
|
enabledModes,
|
|
activeView,
|
|
decrement,
|
|
increment,
|
|
updateActiveView,
|
|
setCalendarDate,
|
|
onMonthYearChange,
|
|
selectedMonthDate,
|
|
}"
|
|
>
|
|
<div class="mb-2 flex justify-between">
|
|
<!-- left side -->
|
|
<!-- Year, Month -->
|
|
<div class="flex items-center">
|
|
<DatePicker
|
|
:modelValue="selectedMonthDate"
|
|
@update:modelValue="(val) => onMonthYearChange(val)"
|
|
:clearable="false"
|
|
>
|
|
<template #target="{ togglePopover }">
|
|
<Button
|
|
variant="ghost"
|
|
class="text-lg font-medium text-ink-gray-7"
|
|
:label="currentMonthYear"
|
|
iconRight="chevron-down"
|
|
@click="togglePopover"
|
|
/>
|
|
</template>
|
|
</DatePicker>
|
|
</div>
|
|
<!-- right side -->
|
|
<!-- actions buttons for calendar -->
|
|
<div class="flex gap-x-1">
|
|
<!-- Increment and Decrement Button-->
|
|
|
|
<Button @click="decrement" variant="ghost" icon="chevron-left" />
|
|
<Button label="Today" @click="setCalendarDate()" variant="ghost" />
|
|
<Button @click="increment" variant="ghost" icon="chevron-right" />
|
|
|
|
<!-- View change button, default is months or can be set via props! -->
|
|
<TabButtons
|
|
:buttons="enabledModes"
|
|
class="ml-2"
|
|
v-model="activeView"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</slot>
|
|
|
|
<CalendarMonthly
|
|
v-if="activeView === 'Month'"
|
|
:events="events"
|
|
:currentMonth="currentMonth"
|
|
:currentMonthDates="currentMonthDates"
|
|
:config="overrideConfig"
|
|
@setCurrentDate="(d) => updateCurrentDate(d)"
|
|
/>
|
|
|
|
<CalendarWeekly
|
|
v-else-if="activeView === 'Week'"
|
|
:events="events"
|
|
:weeklyDates="datesInWeeks[week]"
|
|
:config="overrideConfig"
|
|
/>
|
|
|
|
<CalendarDaily
|
|
v-else-if="activeView === 'Day'"
|
|
:events="events"
|
|
:current-date="selectedDay"
|
|
:config="overrideConfig"
|
|
>
|
|
<template #header="{ parseDateWithDay, currentDate, fullDay }">
|
|
<slot
|
|
name="daily-header"
|
|
v-bind="{ parseDateWithDay, currentDate, fullDay }"
|
|
/>
|
|
</template>
|
|
</CalendarDaily>
|
|
|
|
<NewEventModal
|
|
v-if="showEventModal"
|
|
v-model="showEventModal"
|
|
:event="newEvent"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<script setup>
|
|
import {
|
|
computed,
|
|
onMounted,
|
|
onUnmounted,
|
|
provide,
|
|
ref,
|
|
watch,
|
|
nextTick,
|
|
} from 'vue'
|
|
import { Button } from '../Button'
|
|
import { TabButtons } from '../TabButtons'
|
|
import {
|
|
getCalendarDates,
|
|
monthList,
|
|
handleSeconds,
|
|
formatMonthYear,
|
|
getWeekMonthParts,
|
|
} from './calendarUtils'
|
|
import { dayjs } from '../../utils/dayjs'
|
|
import DayIcon from './Icon/DayIcon.vue'
|
|
import WeekIcon from './Icon/WeekIcon.vue'
|
|
import MonthIcon from './Icon/MonthIcon.vue'
|
|
import DatePicker from '../DatePicker/DatePicker.vue'
|
|
import CalendarMonthly from './CalendarMonthly.vue'
|
|
import CalendarWeekly from './CalendarWeekly.vue'
|
|
import CalendarDaily from './CalendarDaily.vue'
|
|
import NewEventModal from './NewEventModal.vue'
|
|
import useEventModal from './composables/useEventModal'
|
|
|
|
const props = defineProps({
|
|
events: {
|
|
type: Object,
|
|
required: false,
|
|
default: [],
|
|
},
|
|
config: {
|
|
type: Object,
|
|
},
|
|
onClick: {
|
|
type: Function,
|
|
required: false,
|
|
},
|
|
onDblClick: {
|
|
type: Function,
|
|
required: false,
|
|
},
|
|
onCellClick: {
|
|
type: Function,
|
|
required: false,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(['create', 'update', 'delete'])
|
|
|
|
const defaultConfig = {
|
|
scrollToHour: 15,
|
|
disableModes: [],
|
|
defaultMode: 'Month',
|
|
isEditMode: false,
|
|
eventIcons: {},
|
|
hourHeight: 50,
|
|
enableShortcuts: true,
|
|
showIcon: true,
|
|
timeFormat: '12h',
|
|
weekends: ['sunday'],
|
|
}
|
|
|
|
const overrideConfig = { ...defaultConfig, ...props.config }
|
|
let activeView = ref(overrideConfig.defaultMode)
|
|
|
|
function updateActiveView(value, d, isPreviousMonth, isNextMonth) {
|
|
activeView.value = value
|
|
if (value == 'Day' && d) {
|
|
date.value = findIndexOfDate(d)
|
|
isPreviousMonth && decrementMonth()
|
|
isNextMonth && incrementMonth()
|
|
}
|
|
}
|
|
|
|
const selectedMonthDate = ref(dayjs().format('YYYY-MM-DD'))
|
|
|
|
function onMonthYearChange(val = '') {
|
|
const d = dayjs(val)
|
|
selectedMonthDate.value = d.format('YYYY-MM-DD')
|
|
|
|
setCalendarDate(selectedMonthDate.value)
|
|
}
|
|
|
|
function syncSelectedMonth(year, month) {
|
|
// Keep same day if possible; otherwise clamp to last day
|
|
if (typeof year === 'number' && typeof month === 'number') {
|
|
const currentDay = dayjs(selectedMonthDate.value).date()
|
|
|
|
let tentative = dayjs(
|
|
`${year}-${String(month + 1).padStart(2, '0')}-01`,
|
|
).date(currentDay)
|
|
|
|
if (tentative.month() !== month) {
|
|
// overflowed into next month, use last day of target month
|
|
tentative = tentative.startOf('month').month(month).endOf('month')
|
|
}
|
|
|
|
selectedMonthDate.value = tentative.format('YYYY-MM-DD')
|
|
}
|
|
}
|
|
|
|
// shortcuts for changing the active view and navigating through the calendar
|
|
onMounted(() => {
|
|
if (!overrideConfig.enableShortcuts) return
|
|
window.addEventListener('keydown', handleShortcuts)
|
|
})
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleShortcuts)
|
|
})
|
|
function handleShortcuts(e) {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
|
|
return
|
|
}
|
|
|
|
if (e.key.toLowerCase() === 'm') {
|
|
activeView.value = 'Month'
|
|
}
|
|
if (e.key.toLowerCase() === 'w') {
|
|
activeView.value = 'Week'
|
|
}
|
|
if (e.key.toLowerCase() === 'd') {
|
|
activeView.value = 'Day'
|
|
}
|
|
if (e.key.toLowerCase() === 't') {
|
|
setCalendarDate()
|
|
}
|
|
if (e.key === 'ArrowLeft') {
|
|
decrement()
|
|
}
|
|
if (e.key === 'ArrowRight') {
|
|
increment()
|
|
}
|
|
}
|
|
|
|
provide('activeView', activeView)
|
|
provide('config', overrideConfig)
|
|
|
|
const parseEvents = computed(() => {
|
|
return (
|
|
props.events?.map((event) => {
|
|
const { fromDate, toDate, fromTime, toTime, ...rest } = event
|
|
const date = fromDate
|
|
const fromDateTime = fromDate + ' ' + fromTime
|
|
const toDateTime = toDate + ' ' + toTime
|
|
|
|
return {
|
|
...rest,
|
|
date,
|
|
fromDateTime,
|
|
toDateTime,
|
|
fromDate,
|
|
toDate,
|
|
fromTime,
|
|
toTime,
|
|
}
|
|
}) || []
|
|
)
|
|
})
|
|
const events = ref(parseEvents.value)
|
|
|
|
watch(
|
|
() => props.events,
|
|
() => reloadEvents(),
|
|
{ deep: true },
|
|
)
|
|
|
|
function reloadEvents() {
|
|
events.value = parseEvents.value
|
|
}
|
|
|
|
events.value.forEach((event) => {
|
|
if (!event.fromTime || !event.toTime) return
|
|
|
|
event.fromTime = handleSeconds(event.fromTime)
|
|
event.toTime = handleSeconds(event.toTime)
|
|
})
|
|
|
|
const { showEventModal, newEvent, openNewEventModal } = useEventModal()
|
|
|
|
provide('calendarActions', {
|
|
createNewEvent,
|
|
updateEventState,
|
|
deleteEvent,
|
|
handleCellClick,
|
|
updateActiveView,
|
|
props,
|
|
})
|
|
|
|
// CRUD actions on an event
|
|
function createNewEvent(event) {
|
|
events.value.push(event)
|
|
event.fromDateTime = event.fromDate + ' ' + event.fromTime
|
|
event.toDateTime = event.toDate + ' ' + event.toTime
|
|
emit('create', event)
|
|
}
|
|
|
|
function updateEventState(event) {
|
|
const eventID = event.id
|
|
let eventIndex = events.value.findIndex((e) => e.id === eventID)
|
|
event.fromDateTime = event.fromDate + ' ' + event.fromTime
|
|
event.toDateTime = event.toDate + ' ' + event.toTime
|
|
events.value[eventIndex] = event
|
|
emit('update', event)
|
|
}
|
|
|
|
function deleteEvent(eventID) {
|
|
// Delete event
|
|
const eventIndex = events.value.findIndex((event) => event.id === eventID)
|
|
events.value.splice(eventIndex, 1)
|
|
emit('delete', eventID)
|
|
}
|
|
|
|
function openModal(data) {
|
|
const { e, view, date, time, isFullDay } = data
|
|
const config = overrideConfig.isEditMode
|
|
openNewEventModal(e, view, date, config, time, isFullDay)
|
|
}
|
|
|
|
function handleCellClick(e, date, time = '', isFullDay = false) {
|
|
const data = {
|
|
e,
|
|
view: activeView.value,
|
|
date,
|
|
time,
|
|
isFullDay,
|
|
}
|
|
|
|
if (props.onCellClick) {
|
|
props.onCellClick(data)
|
|
return
|
|
}
|
|
openModal(data)
|
|
}
|
|
|
|
// Calendar View Options
|
|
const actionOptions = [
|
|
{ label: 'Day', value: 'Day', iconLeft: DayIcon },
|
|
{ label: 'Week', value: 'Week', iconLeft: WeekIcon },
|
|
{ label: 'Month', value: 'Month', iconLeft: MonthIcon },
|
|
]
|
|
let enabledModes = actionOptions.filter(
|
|
(mode) => !overrideConfig.disableModes.includes(mode.value),
|
|
)
|
|
|
|
let currentYear = ref(new Date().getFullYear())
|
|
let currentMonth = ref(new Date().getMonth())
|
|
let currentDate = ref(new Date())
|
|
|
|
let currentMonthDates = computed(() => {
|
|
let dates = getCalendarDates(currentMonth.value, currentYear.value)
|
|
return dates
|
|
})
|
|
|
|
let datesInWeeks = computed(() => {
|
|
let dates = [...currentMonthDates.value]
|
|
let datesInWeeks = []
|
|
while (dates.length) {
|
|
let week = dates.splice(0, 7)
|
|
datesInWeeks.push(week)
|
|
}
|
|
return datesInWeeks
|
|
})
|
|
|
|
function findCurrentWeek(date) {
|
|
return datesInWeeks.value.findIndex((week) =>
|
|
week.find(
|
|
(d) =>
|
|
new Date(d).toLocaleDateString().split('T')[0] ===
|
|
new Date(date).toLocaleDateString().split('T')[0],
|
|
),
|
|
)
|
|
}
|
|
|
|
let week = ref(findCurrentWeek(currentDate.value))
|
|
|
|
let date = ref(
|
|
currentMonthDates.value.findIndex(
|
|
(d) => new Date(d).toDateString() === currentDate.value.toDateString(),
|
|
),
|
|
)
|
|
let selectedDay = computed(() => currentMonthDates.value[date.value])
|
|
|
|
function updateCurrentDate(d) {
|
|
activeView.value = 'Day'
|
|
date.value = findIndexOfDate(d)
|
|
week.value = findCurrentWeek(d)
|
|
}
|
|
|
|
function increment() {
|
|
incrementClickEvents[activeView.value]()
|
|
syncSelectedMonth(currentYear.value, currentMonth.value)
|
|
}
|
|
|
|
function decrement() {
|
|
decrementClickEvents[activeView.value]()
|
|
syncSelectedMonth(currentYear.value, currentMonth.value)
|
|
}
|
|
|
|
const incrementClickEvents = {
|
|
Month: incrementMonth,
|
|
Week: incrementWeek,
|
|
Day: incrementDay,
|
|
}
|
|
|
|
const decrementClickEvents = {
|
|
Month: decrementMonth,
|
|
Week: decrementWeek,
|
|
Day: decrementDay,
|
|
}
|
|
|
|
function incrementMonth() {
|
|
currentMonth.value++
|
|
if (currentMonth.value > 11) {
|
|
currentMonth.value = 0
|
|
currentYear.value++
|
|
}
|
|
// After month changes, recompute month dates and reset to first in-month day
|
|
date.value = findFirstDateOfMonth(currentMonth.value, currentYear.value)
|
|
week.value = findCurrentWeek(currentMonthDates.value[date.value])
|
|
}
|
|
|
|
function decrementMonth() {
|
|
if (currentMonth.value === 0) {
|
|
currentMonth.value = 11
|
|
currentYear.value--
|
|
} else {
|
|
currentMonth.value--
|
|
}
|
|
// After adjusting month/year, pick last in-month date and its week
|
|
date.value = findLastDateOfMonth(currentMonth.value, currentYear.value)
|
|
week.value = findCurrentWeek(currentMonthDates.value[date.value])
|
|
}
|
|
|
|
function incrementWeek() {
|
|
const nextWeek = week.value + 1 // target next week index
|
|
|
|
// Case 1: still within current grid
|
|
if (nextWeek < datesInWeeks.value.length) {
|
|
week.value = nextWeek
|
|
const weekDates = datesInWeeks.value[week.value]
|
|
const spansNextMonth = weekDates.some(
|
|
(d) => d.getMonth() !== currentMonth.value,
|
|
) // overlap into next month
|
|
if (spansNextMonth) {
|
|
// cross boundary -> advance month
|
|
incrementMonth()
|
|
week.value = 0 // first week row of new month
|
|
const firstWeekDates = datesInWeeks.value[0]
|
|
const day = firstInMonth(firstWeekDates, currentMonth.value) // first in-month day
|
|
date.value = findIndexOfDate(day)
|
|
return
|
|
}
|
|
const day = firstInMonth(weekDates, currentMonth.value) // first in-month day in target week
|
|
date.value = findIndexOfDate(day)
|
|
return
|
|
}
|
|
|
|
// Case 2: overflow -> next month first week
|
|
incrementMonth()
|
|
week.value = 0
|
|
const firstWeekDates = datesInWeeks.value[0]
|
|
const day = firstInMonth(firstWeekDates, currentMonth.value) // first valid in-month day
|
|
date.value = findIndexOfDate(day)
|
|
}
|
|
|
|
function decrementWeek() {
|
|
const prevWeek = week.value - 1 // target previous week index
|
|
|
|
// Case 1: still within current grid
|
|
if (prevWeek >= 0) {
|
|
week.value = prevWeek
|
|
const weekDates = datesInWeeks.value[week.value]
|
|
const spansPrevMonth = weekDates.some(
|
|
(d) => d.getMonth() !== currentMonth.value,
|
|
) // overlap into previous month
|
|
if (spansPrevMonth) {
|
|
// cross boundary -> go to previous month
|
|
decrementMonth()
|
|
week.value = datesInWeeks.value.length - 1 // last week row of new month
|
|
const targetWeekDates = datesInWeeks.value[week.value]
|
|
const day = firstInMonth(targetWeekDates, currentMonth.value) // first day actually in that month
|
|
date.value = findIndexOfDate(day)
|
|
return
|
|
}
|
|
const day = firstInMonth(weekDates, currentMonth.value) // first in-month day in target week
|
|
date.value = findIndexOfDate(day)
|
|
return
|
|
}
|
|
|
|
// Case 2: underflow -> jump to previous month
|
|
decrementMonth()
|
|
let targetIndex = datesInWeeks.value.length - 1 // start at last row
|
|
const lastWeekDates = datesInWeeks.value[targetIndex]
|
|
const hasNextMonthDates = lastWeekDates.some(
|
|
(d) => d.getMonth() !== currentMonth.value,
|
|
) // overlap into next month
|
|
if (hasNextMonthDates && targetIndex > 0) {
|
|
targetIndex = targetIndex - 1 // skip overlap row
|
|
}
|
|
week.value = targetIndex
|
|
const targetWeekDates = datesInWeeks.value[week.value]
|
|
const day = firstInMonth(targetWeekDates, currentMonth.value) // first valid in-month day
|
|
date.value = findIndexOfDate(day)
|
|
}
|
|
|
|
function incrementDay() {
|
|
date.value++
|
|
if (
|
|
date.value > currentMonthDates.value.length - 1 ||
|
|
!isCurrentMonthDate(currentMonthDates.value[date.value])
|
|
) {
|
|
incrementMonth()
|
|
}
|
|
}
|
|
|
|
function decrementDay() {
|
|
date.value--
|
|
if (
|
|
date.value < 0 ||
|
|
!isCurrentMonthDate(currentMonthDates.value[date.value])
|
|
) {
|
|
decrementMonth()
|
|
}
|
|
}
|
|
|
|
function firstInMonth(weekDates, month) {
|
|
return weekDates.find((d) => d.getMonth() === month) || weekDates[0]
|
|
}
|
|
|
|
function findLastDateOfMonth(month, year) {
|
|
let inputDate = new Date(year, month + 1, 0)
|
|
let lastDateIndex = currentMonthDates.value.findIndex(
|
|
(date) => new Date(date).toDateString() === inputDate.toDateString(),
|
|
)
|
|
return lastDateIndex
|
|
}
|
|
|
|
function findFirstDateOfMonth(month, year) {
|
|
let inputDate = new Date(year, month, 1)
|
|
let firstDateIndex = currentMonthDates.value.findIndex(
|
|
(date) => new Date(date).toDateString() === inputDate.toDateString(),
|
|
)
|
|
return firstDateIndex
|
|
}
|
|
|
|
function findIndexOfDate(date) {
|
|
return currentMonthDates.value.findIndex(
|
|
(d) => new Date(d).toDateString() === new Date(date).toDateString(),
|
|
)
|
|
}
|
|
|
|
const currentMonthYear = computed(() => {
|
|
if (activeView.value === 'Day') {
|
|
const dayDate = currentMonthDates.value[date.value]
|
|
if (dayDate) {
|
|
return dayjs(dayDate).format('ddd, D MMM YYYY')
|
|
}
|
|
}
|
|
|
|
// Non-week views or empty week fallback
|
|
if (activeView.value !== 'Week')
|
|
return formatMonthYear(currentMonth.value, currentYear.value)
|
|
|
|
const weekDates = datesInWeeks.value[week.value] || []
|
|
if (!weekDates.length)
|
|
return formatMonthYear(currentMonth.value, currentYear.value)
|
|
|
|
const parts = getWeekMonthParts(weekDates)
|
|
if (parts.length === 1) return formatMonthYear(parts[0].month, parts[0].year)
|
|
|
|
const short = monthList.map((m) => m.slice(0, 3))
|
|
const first = parts[0]
|
|
const last = parts[parts.length - 1]
|
|
|
|
return first.year === last.year
|
|
? `${short[first.month]} - ${short[last.month]} ${first.year}` // Same year span
|
|
: `${short[first.month]} ${first.year} - ${short[last.month]} ${last.year}` // Cross-year span
|
|
})
|
|
|
|
function isCurrentMonthDate(date) {
|
|
date = new Date(date)
|
|
return date.getMonth() === currentMonth.value
|
|
}
|
|
|
|
function setCalendarDate(d) {
|
|
const dt = d ? new Date(d) : new Date()
|
|
if (dt.toString() === 'Invalid Date') return
|
|
currentYear.value = dt.getFullYear()
|
|
currentMonth.value = dt.getMonth()
|
|
currentDate.value = dt
|
|
// Wait for reactive recalculations of month dates
|
|
nextTick(() => {
|
|
week.value = findCurrentWeek(dt)
|
|
const idx = findIndexOfDate(dt)
|
|
if (idx >= 0) {
|
|
date.value = idx
|
|
} else {
|
|
// Fallback: first date of month
|
|
date.value = findFirstDateOfMonth(currentMonth.value, currentYear.value)
|
|
}
|
|
})
|
|
}
|
|
|
|
defineExpose({
|
|
reloadEvents,
|
|
currentMonthYear,
|
|
currentYear,
|
|
currentMonth,
|
|
enabledModes,
|
|
activeView,
|
|
decrement,
|
|
increment,
|
|
updateActiveView,
|
|
setCalendarDate,
|
|
onMonthYearChange,
|
|
selectedMonthDate,
|
|
})
|
|
</script>
|