feat(calendar): add calendar database integration and drag and drop

This commit is contained in:
Dominik 2025-06-22 22:32:16 +02:00
parent 3a96d0e259
commit fd7be58541
6 changed files with 183 additions and 116 deletions

View file

@ -136,15 +136,15 @@ export const GET = auth(async function GET(req, { params }) {
start_time: 'asc', start_time: 'asc',
}, },
select: { select: {
id: requestUserId === requestedUserId ? true : false, id: true,
reason: requestUserId === requestedUserId ? true : false, reason: true,
start_time: true, start_time: true,
end_time: true, end_time: true,
is_recurring: requestUserId === requestedUserId ? true : false, is_recurring: true,
recurrence_end_date: requestUserId === requestedUserId ? true : false, recurrence_end_date: true,
rrule: requestUserId === requestedUserId ? true : false, rrule: true,
created_at: requestUserId === requestedUserId ? true : false, created_at: true,
updated_at: requestUserId === requestedUserId ? true : false, updated_at: true,
}, },
}, },
}, },
@ -167,6 +167,7 @@ export const GET = auth(async function GET(req, { params }) {
calendar.push({ ...event.meeting, type: 'event' }); calendar.push({ ...event.meeting, type: 'event' });
} else { } else {
calendar.push({ calendar.push({
id: event.meeting.id,
start_time: event.meeting.start_time, start_time: event.meeting.start_time,
end_time: event.meeting.end_time, end_time: event.meeting.end_time,
type: 'blocked_private', type: 'blocked_private',
@ -182,6 +183,7 @@ export const GET = auth(async function GET(req, { params }) {
calendar.push({ ...event, type: 'event' }); calendar.push({ ...event, type: 'event' });
} else { } else {
calendar.push({ calendar.push({
id: event.id,
start_time: event.start_time, start_time: event.start_time,
end_time: event.end_time, end_time: event.end_time,
type: 'blocked_private', type: 'blocked_private',
@ -190,6 +192,7 @@ export const GET = auth(async function GET(req, { params }) {
} }
for (const slot of requestedUser.blockedSlots) { for (const slot of requestedUser.blockedSlots) {
if (requestUserId === requestedUserId) {
calendar.push({ calendar.push({
start_time: slot.start_time, start_time: slot.start_time,
end_time: slot.end_time, end_time: slot.end_time,
@ -200,9 +203,16 @@ export const GET = auth(async function GET(req, { params }) {
rrule: slot.rrule, rrule: slot.rrule,
created_at: slot.created_at, created_at: slot.created_at,
updated_at: slot.updated_at, updated_at: slot.updated_at,
type: type: 'blocked_owned',
requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private',
}); });
} else {
calendar.push({
start_time: slot.start_time,
end_time: slot.end_time,
id: slot.id,
type: 'blocked_private',
});
}
} }
return returnZodTypeCheckedResponse(UserCalendarResponseSchema, { return returnZodTypeCheckedResponse(UserCalendarResponseSchema, {

View file

@ -13,6 +13,7 @@ export const BlockedSlotSchema = zod
start_time: eventStartTimeSchema, start_time: eventStartTimeSchema,
end_time: eventEndTimeSchema, end_time: eventEndTimeSchema,
type: zod.literal('blocked_private'), type: zod.literal('blocked_private'),
id: zod.string(),
}) })
.openapi('BlockedSlotSchema', { .openapi('BlockedSlotSchema', {
description: 'Blocked time slot in the user calendar', description: 'Blocked time slot in the user calendar',

View file

@ -1,10 +1,16 @@
'use client'; 'use client';
import { Calendar, momentLocalizer } from 'react-big-calendar'; import { Calendar as RBCalendar, momentLocalizer } from 'react-big-calendar';
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop';
import moment from 'moment'; import moment from 'moment';
import '@/components/react-big-calendar.css'; import '@/components/react-big-calendar.css';
import 'react-big-calendar/lib/addons/dragAndDrop/styles.css'; import 'react-big-calendar/lib/addons/dragAndDrop/styles.css';
import CustomToolbar from '@/components/custom-toolbar'; import CustomToolbar from '@/components/custom-toolbar';
import React from 'react';
import { useGetApiUserUserCalendar } from '@/generated/api/user/user';
import { useRouter } from 'next/navigation';
import { usePatchApiEventEventID } from '@/generated/api/event/event';
import { useSession } from 'next-auth/react';
moment.updateLocale('en', { moment.updateLocale('en', {
week: { week: {
@ -13,25 +19,131 @@ moment.updateLocale('en', {
}, },
}); });
const DaDRBCalendar = withDragAndDrop<
{
id: string;
start: Date;
end: Date;
},
{
id: string;
title: string;
}
>(RBCalendar);
const localizer = momentLocalizer(moment); const localizer = momentLocalizer(moment);
const MyCalendar = (props) => ( export default function Calendar({ userId }: { userId: string }) {
<div> const sesstion = useSession();
<Calendar const [currentView, setCurrentView] = React.useState<
'month' | 'week' | 'day' | 'agenda' | 'work_week'
>('week');
const [currentDate, setCurrentDate] = React.useState<Date>(new Date());
const router = useRouter();
const { data, refetch } = useGetApiUserUserCalendar(userId, {
start: moment(currentDate)
.startOf(
currentView === 'agenda'
? 'month'
: currentView === 'work_week'
? 'week'
: currentView,
)
.toISOString(),
end: moment(currentDate)
.endOf(
currentView === 'agenda'
? 'month'
: currentView === 'work_week'
? 'week'
: currentView,
)
.toISOString(),
});
const { mutate: patchEvent } = usePatchApiEventEventID();
return (
<DaDRBCalendar
localizer={localizer} localizer={localizer}
//events={myEventsList}
startAccessor='start'
endAccessor='end'
style={{ height: 500 }} style={{ height: 500 }}
culture='de-DE' culture='de-DE'
defaultView='week' defaultView='week'
/*CustomToolbar*/
components={{ components={{
toolbar: CustomToolbar, toolbar: CustomToolbar,
}} }}
/*CustomToolbar*/ onView={setCurrentView}
view={currentView}
date={currentDate}
onNavigate={(date) => {
setCurrentDate(date);
}}
events={
data?.data.calendar.map((event) => ({
id: event.id,
title: event.type === 'event' ? event.title : 'Blocker',
start: new Date(event.start_time),
end: new Date(event.end_time),
})) ?? []
}
onSelectEvent={(event) => {
router.push(`/events/${event.id}`);
}}
onSelectSlot={(slotInfo) => {
router.push(
`/events/new?start=${slotInfo.start.toISOString()}&end=${slotInfo.end.toISOString()}`,
);
}}
resourceIdAccessor={(event) => event.id}
resourceTitleAccessor={(event) => event.title}
startAccessor={(event) => event.start}
endAccessor={(event) => event.end}
selectable={sesstion.data?.user?.id === userId}
onEventDrop={(event) => {
const { start, end, event: droppedEvent } = event;
const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString();
patchEvent(
{
eventID: droppedEvent.id,
data: {
start_time: startISO,
end_time: endISO,
},
},
{
onSuccess: () => {
refetch();
},
onError: (error) => {
console.error('Error updating event:', error);
},
},
);
}}
onEventResize={(event) => {
const { start, end, event: resizedEvent } = event;
const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString();
patchEvent(
{
eventID: resizedEvent.id,
data: {
start_time: startISO,
end_time: endISO,
},
},
{
onSuccess: () => {
refetch();
},
onError: (error) => {
console.error('Error resizing event:', error);
},
},
);
}}
/> />
</div> );
); }
export default MyCalendar;

View file

@ -1,57 +1,20 @@
/* custom-toolbar.css */
/* Container der Toolbar */ /* Container der Toolbar */
.custom-toolbar { .custom-toolbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
/*border-radius: 8px;*/
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
} }
/*.custom-toolbar .view-change .view-switcher {
display: flex;
gap: 8px;
justify-content: center;
}
.custom-toolbar .view-change .view-switcher button {
padding: 8px 16px;
background-color: #c1830d;
/*border: 1px solid #ccc;*/
/* border-radius: 11px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
height: 30px;
margin-top: 3.5px;
color: #ffffff;
}
.custom-toolbar .view-change .view-switcher button:hover:not(:disabled) {
background-color: #e0e0e0;
border-color: #999;
}
.custom-toolbar .view-change .view-switcher button:disabled {
background-color: #d0d0d0;
border-color: #aaa;
cursor: default;
}*/
/* Anzeige des aktuellen Datums (Monat und Jahr) */ /* Anzeige des aktuellen Datums (Monat und Jahr) */
.custom-toolbar .current-date { .custom-toolbar .current-date {
font-weight: bold; font-weight: bold;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
color: #ffffff; color: #ffffff;
/*margin: 4px 0;*/
background-color: #717171; background-color: #717171;
width: 178px;
height: 37px; height: 37px;
border-radius: 11px; border-radius: 11px;
} }
@ -65,7 +28,6 @@
.custom-toolbar .navigation-controls button { .custom-toolbar .navigation-controls button {
padding: 8px 12px; padding: 8px 12px;
/*background-color: #2196F3;*/
color: #ffffff; color: #ffffff;
border: none; border: none;
border-radius: 11px; border-radius: 11px;
@ -95,7 +57,6 @@
.custom-toolbar .dropdowns select { .custom-toolbar .dropdowns select {
padding: 8px 12px; padding: 8px 12px;
/*border: 1px solid #ccc;*/
border-radius: 11px; border-radius: 11px;
font-size: 10px; font-size: 10px;
background-color: #555555; background-color: #555555;
@ -108,9 +69,9 @@
border-color: #999; border-color: #999;
} }
.right-section { .right-section,
.view-switcher {
background-color: #717171; background-color: #717171;
width: 393px;
height: 48px; height: 48px;
border-radius: 11px; border-radius: 11px;
justify-items: center; justify-items: center;
@ -124,18 +85,11 @@
margin-bottom: 3.5px; margin-bottom: 3.5px;
} }
/*.custom-toolbar .navigation-controls .today button { .view-change,
background-color: #c6c6c6; .right-section {
height: 30px;
width: 100px;
color: #000000;
margin-top: 3.5px;
}*/
.view-change {
background-color: #717171; background-color: #717171;
height: 48px; height: 48px;
width: 323px; padding: 0 8px;
border-radius: 11px; border-radius: 11px;
justify-items: center; justify-items: center;
} }
@ -144,7 +98,6 @@
color: #000000; color: #000000;
background-color: #c6c6c6; background-color: #c6c6c6;
height: 36px; height: 36px;
width: 85px;
border-radius: 11px; border-radius: 11px;
font-size: 12px; font-size: 12px;
align-self: center; align-self: center;
@ -152,7 +105,6 @@
.datepicker { .datepicker {
text-align: center; text-align: center;
width: 85px;
height: 30px; height: 30px;
} }

View file

@ -1,19 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import './custom-toolbar.css'; import './custom-toolbar.css';
import { Button } from '@/components/custom-ui/button'; import { Button } from '@/components/ui/button';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { NavigateAction } from 'react-big-calendar';
interface CustomToolbarProps { interface CustomToolbarProps {
//Aktuell angezeigtes Datum //Aktuell angezeigtes Datum
date: Date; date: Date;
//Aktuelle Ansicht //Aktuelle Ansicht
view: 'month' | 'week' | 'day' | 'agenda'; view: 'month' | 'week' | 'day' | 'agenda' | 'work_week';
onNavigate: (action: string, newDate?: Date) => void; onNavigate: (action: NavigateAction, newDate?: Date) => void;
//Ansichtwechsel //Ansichtwechsel
onView: (newView: 'month' | 'week' | 'day' | 'agenda') => void; onView: (newView: 'month' | 'week' | 'day' | 'agenda' | 'work_week') => void;
} }
const CustomToolbar: React.FC<CustomToolbarProps> = ({ const CustomToolbar: React.FC<CustomToolbarProps> = ({
@ -76,27 +76,11 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
setSelectedYear(getISOWeekYear(date)); setSelectedYear(getISOWeekYear(date));
}, [date]); }, [date]);
//Dropdown-Liste der Wochen
const totalWeeks = getISOWeeksInYear(selectedYear);
const weekOptions = Array.from({ length: totalWeeks }, (_, i) => i + 1);
//Jahresliste
const yearOptions = Array.from(
{ length: 21 },
(_, i) => selectedYear - 10 + i,
);
//Start (Montag) und Ende (Sonntag) der aktuell angezeigten Woche berechnen //Start (Montag) und Ende (Sonntag) der aktuell angezeigten Woche berechnen
const weekStartDate = getDateOfISOWeek(selectedWeek, selectedYear); const weekStartDate = getDateOfISOWeek(selectedWeek, selectedYear);
const weekEndDate = new Date(weekStartDate); const weekEndDate = new Date(weekStartDate);
weekEndDate.setDate(weekStartDate.getDate() + 6); weekEndDate.setDate(weekStartDate.getDate() + 6);
//Monat und Jahr von Start- und Enddatum ermitteln
const monthStart = format(weekStartDate, 'MMMM');
const monthEnd = format(weekEndDate, 'MMMM');
const yearAtStart = format(weekStartDate, 'yyyy');
const yearAtEnd = format(weekEndDate, 'yyyy');
//Ansichtwechsel //Ansichtwechsel
const handleViewChange = (newView: 'month' | 'week' | 'day' | 'agenda') => { const handleViewChange = (newView: 'month' | 'week' | 'day' | 'agenda') => {
onView(newView); onView(newView);
@ -134,7 +118,7 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
} }
//Datum im DatePicker aktualisieren //Datum im DatePicker aktualisieren
setSelectedDate(newDate); setSelectedDate(newDate);
onNavigate('SET_DATE', newDate); onNavigate('DATE', newDate);
}; };
//Pfeiltaste nach Hinten //Pfeiltaste nach Hinten
@ -160,7 +144,7 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
} }
//Datum im DatePicker aktualisieren //Datum im DatePicker aktualisieren
setSelectedDate(newDate); setSelectedDate(newDate);
onNavigate('SET_DATE', newDate); onNavigate('DATE', newDate);
}; };
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date()); const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
@ -174,14 +158,14 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({
setSelectedWeek(newWeek); setSelectedWeek(newWeek);
setSelectedYear(newYear); setSelectedYear(newYear);
const newDate = getDateOfISOWeek(newWeek, newYear); const newDate = getDateOfISOWeek(newWeek, newYear);
onNavigate('SET_DATE', newDate); onNavigate('DATE', newDate);
} else if (view === 'day') { } else if (view === 'day') {
onNavigate('SET_DATE', date); onNavigate('DATE', date);
} else if (view === 'month') { } else if (view === 'month') {
const newDate = new Date(date.getFullYear(), date.getMonth(), 1); const newDate = new Date(date.getFullYear(), date.getMonth(), 1);
onNavigate('SET_DATE', newDate); onNavigate('DATE', newDate);
} else if (view === 'agenda') { } else if (view === 'agenda') {
onNavigate('SET_DATE', date); onNavigate('DATE', date);
} }
} }
}; };

View file

@ -546,7 +546,8 @@ button.rbc-input::-moz-focus-inner {
padding-right: 15px; padding-right: 15px;
text-transform: lowercase; text-transform: lowercase;
} }
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td,
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td.rbc-agenda-time-cell {
border-left: 1px solid #ddd; border-left: 1px solid #ddd;
} }
.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { .rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td {
@ -767,7 +768,6 @@ button.rbc-input::-moz-focus-inner {
-ms-flex: 1; -ms-flex: 1;
flex: 1; flex: 1;
width: 100%; width: 100%;
border: 1px solid #ddd;
min-height: 0; min-height: 0;
} }
.rbc-time-view .rbc-time-gutter { .rbc-time-view .rbc-time-gutter {
@ -870,10 +870,18 @@ button.rbc-input::-moz-focus-inner {
-ms-flex-align: start; -ms-flex-align: start;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
border-top: 2px solid #717171; /*#ddd*/
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
} }
.rbc-time-header-content {
border-bottom: 2px solid #717171; /*#ddd*/
}
.rbc-time-column :last-child {
border-bottom: 0;
}
.rbc-time-content > .rbc-time-gutter { .rbc-time-content > .rbc-time-gutter {
-webkit-box-flex: 0; -webkit-box-flex: 0;
-ms-flex: none; -ms-flex: none;