Merge pull request #3851 from modoboa/refacto/vuetify_based_calendar

Replaced FullCalendar by Vuetify internal calendar
This commit is contained in:
Antoine Nguyen
2026-01-12 14:32:03 +01:00
committed by GitHub
11 changed files with 515 additions and 232 deletions
+36 -14
View File
@@ -26,7 +26,7 @@
:error-messages="formErrors.start"
density="compact"
variant="outlined"
type="datetime-local"
:type="dateType"
class="mr-4"
/>
<v-text-field
@@ -36,7 +36,7 @@
:error-messages="formErrors.end"
density="compact"
variant="outlined"
type="datetime-local"
:type="dateType"
/>
</div>
<div class="d-flex mb-3">
@@ -133,14 +133,27 @@ const title = computed(() => {
return props.event ? $gettext('Edit event') : $gettext('New event')
})
const dateType = computed(() => {
return form.value.allDay ? 'date' : 'datetime-local'
})
watch(
() => props.info,
(value) => {
if (value) {
const start =
typeof value.start === 'number'
? DateTime.fromMillis(value.start)
: DateTime.fromJSDate(value.start)
const end =
typeof value.end === 'number'
? DateTime.fromMillis(value.end)
: DateTime.fromJSDate(value.end)
const format = value.timed ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd'
form.value = {
start: DateTime.fromISO(value.startStr).toFormat('yyyy-MM-dd HH:mm'),
end: DateTime.fromISO(value.endStr).toFormat('yyyy-MM-dd HH:mm'),
allDay: value.allDay,
start: start.toFormat(format),
end: end.toFormat(format),
allDay: !value.timed,
}
}
},
@@ -153,10 +166,9 @@ watch(
if (value) {
form.value = { ...value }
form.value.calendar = form.value.calendar.pk
form.value.start = DateTime.fromISO(value.start).toFormat(
'yyyy-MM-dd HH:mm'
)
form.value.end = DateTime.fromISO(value.end).toFormat('yyyy-MM-dd HH:mm')
const format = form.value.timed ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd'
form.value.start = DateTime.fromMillis(value.start).toFormat(format)
form.value.end = DateTime.fromMillis(value.end).toFormat(format)
api.getAttendees().then((resp) => {
attendees.value = resp.data
})
@@ -176,11 +188,21 @@ async function saveEvent() {
}
working.value = true
try {
var data = JSON.parse(JSON.stringify(form.value))
const fromDate = new Date(data.start)
data.start = fromDate.toISOString()
const toDate = new Date(data.end)
data.end = toDate.toISOString()
const data = {
title: form.value.title,
description: form.value.description,
allDay: form.value.allDay,
attendees: form.value.attendees,
calendar: form.value.calendar,
}
if (data.allDay) {
data.start_date = form.value.start
data.end_date = form.value.end
} else {
data.start = new Date(form.value.start).toISOString()
data.end = new Date(form.value.end).toISOString()
}
if (!props.event) {
await api.createUserEvent(form.value.calendar, data)
busStore.displayNotification({ msg: $gettext('Event added') })
+426 -142
View File
@@ -1,43 +1,130 @@
<template>
<div class="text-h5 ml-4 mb-6">
{{ $gettext('Calendar') }}
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div style="display: grid; grid-template-rows: auto auto 1fr; height: 96vh">
<div class="text-h5 ml-4 mb-6">
{{ $gettext('Calendar') }}
</div>
<v-dialog v-model="showInformation" persistent max-width="800px">
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
</v-dialog>
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
<CalendarForm
:initial-calendar="selectedCalendar"
@color-changed="refreshCalendarEvents"
@close="closeCalendarForm"
/>
</v-dialog>
<v-dialog v-model="showEventForm" persistent max-width="800px">
<EventForm
:info="selectInfo"
:event="selectedEvent"
@refresh-calendar="refreshCalendarEvents"
@close="closeEventForm"
/>
</v-dialog>
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
<CalendarAccessRulesForm
v-if="selectedCalendar"
:calendar-pk="selectedCalendar.pk"
@close="closeAccessRulesForm"
/>
</v-dialog>
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
<ImportEventsForm
v-if="selectedCalendar"
:calendar="selectedCalendar"
@events-imported="refreshCalendarEvents"
@close="closeImportEventsForm"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
<v-toolbar v-if="calendarRef" color="white" flat>
<v-btn
class="me-4"
color="grey-darken-2"
variant="outlined"
@click="setToday"
>
{{ $gettext('Today') }}
</v-btn>
<v-btn
color="grey-darken-2"
size="small"
variant="text"
icon
@click="goPrev"
>
<v-icon size="small"> mdi-chevron-left </v-icon>
</v-btn>
<v-btn
color="grey-darken-2"
size="small"
variant="text"
icon
@click="goNext"
>
<v-icon size="small"> mdi-chevron-right </v-icon>
</v-btn>
<v-toolbar-title>
{{ calendarRef.title }}
</v-toolbar-title>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn color="grey-darken-2" variant="outlined" v-bind="props">
<span>{{ typeToLabel[ctype] }}</span>
<v-icon end> mdi-menu-down </v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="ctype = 'day'">
<v-list-item-title>{{ $gettext('Day') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="ctype = 'week'">
<v-list-item-title>{{ $gettext('Week') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="ctype = 'month'">
<v-list-item-title>{{ $gettext('Month') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
<div style="overflow: auto">
<v-calendar
ref="calendarRef"
v-model="focus"
:events="events"
first-day-of-week="1"
:type="ctype"
:locale="current"
event-name="title"
:event-color="getEventColor"
:event-ripple="false"
@mousedown:event="startDrag"
@mousedown:time="startTime"
@mousedown:day="startDay"
@click:day="dayClickCallback"
@mouseleave="cancelDrag"
@mousemove:time="mouseMove"
@mousemove:day="mouseMoveDay"
@mouseup:time="endDrag"
@mouseup:day="endDragDay"
@change="fetchUserEvents"
>
<template #event="{ event, timed, eventSummary }">
<div class="v-event-draggable">
<component :is="eventSummary"></component>
</div>
<div
v-if="timed"
class="v-event-drag-bottom"
@mousedown.stop="extendBottom(event)"
></div>
</template>
</v-calendar>
</div>
<v-dialog v-model="showInformation" persistent max-width="800px">
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
</v-dialog>
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
<CalendarForm
:initial-calendar="selectedCalendar"
@color-changed="refreshCalendarEvents"
@close="closeCalendarForm"
/>
</v-dialog>
<v-dialog v-model="showEventForm" persistent max-width="800px">
<EventForm
:info="selectInfo"
:event="selectedEvent"
@refresh-calendar="refreshCalendarEvents"
@close="closeEventForm"
/>
</v-dialog>
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
<CalendarAccessRulesForm
v-if="selectedCalendar"
:calendar-pk="selectedCalendar.pk"
@close="closeAccessRulesForm"
/>
</v-dialog>
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
<ImportEventsForm
v-if="selectedCalendar"
:calendar="selectedCalendar"
@events-imported="refreshCalendarEvents"
@close="closeImportEventsForm"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
</div>
</template>
<script setup>
@@ -53,10 +140,6 @@ import CalendarDetail from '@/components/calendars/CalendarDetail'
import ConfirmDialog from '@/components/tools/ConfirmDialog'
import EventForm from '@/components/calendars/EventForm'
import ImportEventsForm from '@/components/calendars/ImportEventsForm'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
const { $gettext, current } = useGettext()
const layoutStore = useLayoutStore()
@@ -64,6 +147,7 @@ const busStore = useBusStore()
const calendarRef = ref()
const confirm = ref()
const events = ref([])
const selectInfo = ref()
const selectedCalendar = ref(null)
const selectedEvent = ref(null)
@@ -73,6 +157,22 @@ const showCalendarForm = ref(false)
const showInformation = ref(false)
const showEventForm = ref(false)
const showImportEventsForm = ref(false)
const ctype = ref('week')
const focus = ref('')
const dragEvent = ref(null)
const dragTime = ref(null)
const dragDay = ref(null)
const createEvent = ref(null)
const createStart = ref(null)
const extendOriginal = ref(null)
const updating = ref(false)
const moving = ref(false)
const typeToLabel = {
day: $gettext('Day'),
week: $gettext('Week'),
month: $gettext('Month'),
}
const leftMenuItems = computed(() => {
const result = [
@@ -121,21 +221,36 @@ const leftMenuItems = computed(() => {
return result
})
async function fetchUserCalendarEvents(
calendarPk,
info,
successCallback,
failureCallback
) {
try {
const resp = await api.getUserCalendarEvents(calendarPk, {
start: info.startStr,
end: info.endStr,
async function fetchUserEvents({ start, end }) {
let newEvents = []
for (const calendar of userCalendars.value) {
const resp = await api.getUserCalendarEvents(calendar.pk, {
start: start.date,
end: end.date,
})
successCallback(resp.data)
} catch (err) {
failureCallback(err)
newEvents = newEvents.concat(
resp.data.map((event) => {
const newEvent = { ...event }
newEvent.start = Date.parse(event.start)
newEvent.end = Date.parse(event.end)
newEvent.timed = !event.allDay
return newEvent
})
)
}
events.value = newEvents
}
const setToday = () => {
focus.value = ''
}
const goPrev = () => {
calendarRef.value.prev()
}
const goNext = () => {
calendarRef.value.next()
}
function openCalendarForm() {
@@ -178,58 +293,231 @@ function closeImportEventsForm() {
showImportEventsForm.value = false
}
function fetchUserCalendars() {
api.getUserCalendars().then((resp) => {
userCalendars.value = resp.data
async function fetchUserCalendars() {
const resp = await api.getUserCalendars()
userCalendars.value = resp.data
}
function refreshCalendarEvents() {
fetchUserEvents({
start: calendarRef.value.renderProps.start,
end: calendarRef.value.renderProps.end,
})
}
function refreshCalendarEvents(calendarPk) {
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendarPk}`)
evtSource.refetch()
}
function selectCallback(info) {
selectInfo.value = info
showEventForm.value = true
}
async function eventClickCallback(info) {
const resp = await api.getUserEvent(
info.event.extendedProps.calendar.pk,
info.event.id
)
selectedEvent.value = resp.data
showEventForm.value = true
}
function updateEventDates(calEvent) {
var data = {
start: calEvent.start,
end: calEvent.end,
const dayClickCallback = (nativeEvent, { date }) => {
if (!selectedEvent.value) {
const startDate = new Date(date)
const endDate = new Date(date)
selectInfo.value = {
color: '#2196F3',
start: startDate,
end: endDate,
timed: false,
}
showEventForm.value = true
}
const evtCalendar = calEvent.extendedProps.calendar
if (calEvent.allDay) {
const end = DateTime.fromJSDate(data.start)
end.plus({ days: 1 })
data.start.setHours(0, 0, 0)
data.end = end
}
async function updateEventDates(calEvent) {
updating.value = true
var data = {
start: DateTime.fromMillis(calEvent.start).toJSDate(),
end: calEvent.end ? DateTime.fromMillis(calEvent.end).toJSDate() : null,
}
const evtCalendar = calEvent.calendar
if (!calEvent.timed) {
data.start_date = data.start.toISOString().split('T')[0]
data.end_date = data.end.toISOString().split('T')[0]
data.allDay = calEvent.allDay
} else if (!calEvent.end) {
const end = DateTime.fromJSDate(data.start)
end.plus({ hours: 1 })
data.end = end
}
api.patchUserEvent(evtCalendar.pk, calEvent.id, data).then((response) => {
try {
await api.patchUserEvent(evtCalendar.pk, calEvent.id, data)
busStore.displayNotification({ msg: $gettext('Event updated') })
})
} finally {
updating.value = false
}
}
function eventDropCallback(info) {
updateEventDates(info.event)
const roundTime = (time, down = true) => {
const roundTo = 15 // minutes
const roundDownTime = roundTo * 60 * 1000
return down
? time - (time % roundDownTime)
: time + (roundDownTime - (time % roundDownTime))
}
function eventResizeCallback(info) {
updateEventDates(info.event)
const toTime = (tms) => {
return new Date(
tms.year,
tms.month - 1,
tms.day,
tms.hour,
tms.minute
).getTime()
}
const getEventColor = (event) => {
const rgb = parseInt(event.color.substring(1), 16)
const r = (rgb >> 16) & 0xff
const g = (rgb >> 8) & 0xff
const b = (rgb >> 0) & 0xff
return event === dragEvent.value
? `rgba(${r}, ${g}, ${b}, 0.7)`
: event === createEvent.value
? `rgba(${r}, ${g}, ${b}, 0.7)`
: event.color
}
const startDrag = (nativeEvent, { event, timed }) => {
if (event) {
dragEvent.value = event
if (timed) {
dragTime.value = null
extendOriginal.value = null
} else {
dragDay.value = null
}
}
}
const startTime = (nativeEvent, tms) => {
const mouse = toTime(tms)
if (dragEvent.value && dragTime.value === null) {
const start = dragEvent.value.start
dragTime.value = mouse - start
} else {
createStart.value = roundTime(mouse)
createEvent.value = {
title: $gettext('New event'),
color: '#2196F3',
start: createStart.value,
end: createStart.value,
timed: true,
}
events.value.push(createEvent.value)
}
}
const startDay = (nativeEvent, tms) => {
const mouse = toTime(tms)
if (dragEvent.value && dragDay.value === null) {
dragDay.value = mouse - dragEvent.value.start
}
}
const mouseMove = (nativeEvent, tms) => {
const mouse = toTime(tms)
if (dragEvent.value && dragTime.value !== null) {
moving.value = true
const start = dragEvent.value.start
const end = dragEvent.value.end
const duration = end - start
const newStartTime = mouse - dragTime.value
const newStart = roundTime(newStartTime)
const newEnd = newStart + duration
dragEvent.value.start = newStart
dragEvent.value.end = newEnd
} else if (createEvent.value && createStart.value !== null) {
moving.value = true
const mouseRounded = roundTime(mouse, false)
const min = Math.min(mouseRounded, createStart.value)
const max = Math.max(mouseRounded, createStart.value)
createEvent.value.start = min
createEvent.value.end = max
}
}
const mouseMoveDay = (nativeEvent, tms) => {
const mouse = toTime(tms)
if (dragEvent.value && dragDay.value !== null) {
moving.value = true
const start = dragEvent.value.start
const end = dragEvent.value.end
const duration = end - start
const newStartTime = mouse - dragDay.value
const newStart = roundTime(newStartTime)
const newEnd = newStart + duration
dragEvent.value.start = newStart
dragEvent.value.end = newEnd
}
}
const extendBottom = (event) => {
createEvent.value = event
createStart.value = event.start
extendOriginal.value = event.end
}
const endDrag = () => {
if (createEvent.value && !extendOriginal.value) {
selectInfo.value = createEvent.value
showEventForm.value = true
} else {
if (moving.value) {
if (dragEvent.value) {
updateEventDates(dragEvent.value)
} else if (createEvent.value) {
updateEventDates(createEvent.value)
}
} else {
selectedEvent.value = dragEvent.value
showEventForm.value = true
}
dragTime.value = null
dragEvent.value = null
createEvent.value = null
createStart.value = null
extendOriginal.value = null
moving.value = false
}
}
const endDragDay = () => {
if (dragEvent.value) {
if (moving.value) {
updateEventDates(dragEvent.value)
} else {
selectedEvent.value = dragEvent.value
showEventForm.value = true
}
}
dragEvent.value = null
dragDay.value = null
moving.value = false
}
const cancelDrag = () => {
if (createEvent.value) {
if (extendOriginal.value) {
createEvent.value.end = extendOriginal.value
} else {
const i = events.value.indexOf(createEvent.value)
if (i !== -1) {
events.value.splice(i, 1)
}
}
}
createEvent.value = null
createStart.value = null
dragTime.value = null
dragEvent.value = null
moving.value = false
}
function closeEventForm() {
@@ -252,40 +540,12 @@ async function deleteCalendar(calendarPk) {
return
}
await api.deleteUserCalendar(calendarPk)
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendarPk}`)
evtSource.remove()
fetchUserCalendars()
await fetchUserCalendars()
events.value = []
calendarRef.value.checkChange()
busStore.displayNotification({ msg: $gettext('Calendar deleted') })
}
const calendarOptions = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
scrollTime: '09:00:00',
height: '100%',
firstDay: 1,
initialView: 'timeGridWeek',
locale: current,
selectable: true,
selectMirror: true,
editable: true,
dayMaxEventRows: true,
businessHours: {
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '09:00',
endTime: '18:00',
},
select: selectCallback,
eventClick: eventClickCallback,
eventDrop: eventDropCallback,
eventResize: eventResizeCallback,
}
watch(
leftMenuItems,
(value) => {
@@ -294,23 +554,47 @@ watch(
{ immediate: true }
)
watch(userCalendars, (value) => {
const fullc = calendarRef.value.getApi()
if (!fullc) {
return
}
for (const calendar of value) {
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
if (!evtSource) {
fullc.addEventSource({
id: `user-${calendar.pk}`,
events: fetchUserCalendarEvents.bind(this, calendar.pk),
})
}
}
onMounted(() => {
calendarRef.value.scrollToTime('07:00')
calendarRef.value.checkChange()
})
onMounted(() => {
fetchUserCalendars()
})
await fetchUserCalendars()
</script>
<style scoped>
.v-event-draggable {
padding-left: 6px;
}
.v-event-timed {
user-select: none;
-webkit-user-select: none;
}
.v-event-drag-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
height: 4px;
cursor: ns-resize;
&::after {
display: none;
position: absolute;
left: 50%;
height: 4px;
border-top: 1px solid white;
border-bottom: 1px solid white;
width: 16px;
margin-left: -8px;
opacity: 0.8;
content: '';
}
&:hover::after {
display: block;
}
}
</style>