570 lines
23 KiB
TypeScript
570 lines
23 KiB
TypeScript
import React, { useState, useEffect, use } from 'react';
|
||
import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar';
|
||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||
import AvailabilityForm from '../availability/AvailabilityForm';
|
||
import ProtectedRoute from '../protectedRoute';
|
||
import { UserRole } from "@prisma/client";
|
||
import common from '../../src/helpers/common';
|
||
|
||
import { toast } from 'react-toastify';
|
||
import { ToastContainer } from 'react-toastify';
|
||
import 'react-toastify/dist/ReactToastify.css';
|
||
import moment from 'moment'; // ToDo: obsolete, remove it
|
||
import 'moment/locale/bg'; // Import Bulgarian locale
|
||
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
|
||
|
||
import { FaArrowLeft, FaArrowRight, FaRegCalendarAlt, FaRegListAlt, FaRegCalendarCheck } from 'react-icons/fa';
|
||
import { MdToday } from 'react-icons/md';
|
||
|
||
import { useSwipeable } from 'react-swipeable';
|
||
import axiosInstance from '../../src/axiosSecure';
|
||
import { set } from 'date-fns';
|
||
import { get } from 'http';
|
||
|
||
// import { set, format, addDays } from 'date-fns';
|
||
// import { isEqual, isSameDay, getHours, getMinutes } from 'date-fns';
|
||
// import { filter } from 'jszip';
|
||
// import e from 'express';
|
||
|
||
|
||
|
||
|
||
// Set moment to use the Bulgarian locale
|
||
moment.locale('bg');
|
||
const localizer = momentLocalizer(moment);
|
||
// Bulgarian translations for Calendar labels
|
||
const messages = {
|
||
allDay: 'Цял ден',
|
||
previous: 'Предишен',
|
||
next: 'Следващ',
|
||
today: 'Днес',
|
||
month: 'Месец',
|
||
week: 'Седмица',
|
||
day: 'Ден',
|
||
agenda: 'Дневен ред',
|
||
date: 'Дата',
|
||
time: 'Час',
|
||
event: 'Събитие', // or 'Събитие' depending on context
|
||
// Any other labels you want to translate...
|
||
};
|
||
|
||
const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublishedDate }) => {
|
||
const [editLockedBefore, setEditLockedBefore] = useState(new Date(lastPublishedDate));
|
||
const [isAdmin, setIsAdmin] = useState(false);
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
setIsAdmin(await ProtectedRoute.IsInRole(UserRole.ADMIN));
|
||
} catch (error) {
|
||
console.error("Failed to check admin role:", error);
|
||
}
|
||
})();
|
||
}, []);
|
||
//const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
|
||
|
||
const [date, setDate] = useState(new Date());
|
||
//ToDo: see if we can optimize this
|
||
const [evts, setEvents] = useState(events); // Existing events
|
||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||
const [displayedEvents, setDisplayedEvents] = useState(evts); // Events to display in the calendar
|
||
|
||
const [currentView, setCurrentView] = useState('month');
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [visibleRange, setVisibleRange] = useState(() => {
|
||
const start = new Date();
|
||
start.setDate(1); // Set to the first day of the current month
|
||
const end = new Date(start.getFullYear(), start.getMonth() + 1, 0); // Last day of the current month
|
||
return { start, end };
|
||
});
|
||
|
||
const [cartEvent, setCartEvent] = useState(null);
|
||
function getCartEvent(date) {
|
||
const dayOfWeek = common.getDayOfWeekNameEnEnumForDate(date);
|
||
const ce = cartEvents?.find(e => e.dayofweek === dayOfWeek);
|
||
return ce;
|
||
}
|
||
|
||
useEffect(() => {
|
||
//console.log("useEffect: ", date, selectedEvents, cartEvents);
|
||
setCartEvent(getCartEvent(date));
|
||
},
|
||
[date, selectedEvents]);
|
||
|
||
// Update internal state when `events` prop changes
|
||
useEffect(() => {
|
||
//if we have isBySystem - set type to assignment
|
||
let updatedEvents = events?.map(event => {
|
||
if (event.isBySystem) {
|
||
event.type = "assignment";
|
||
}
|
||
return event;
|
||
});
|
||
updatedEvents = events?.map(event => ({
|
||
...event,
|
||
date: new Date(event.startTime).setHours(0, 0, 0, 0),
|
||
startTime: new Date(event.startTime),
|
||
endTime: event.endTime ? new Date(event.endTime) : undefined
|
||
}));
|
||
|
||
setEvents(updatedEvents);
|
||
// Call any function here to process and set displayedEvents
|
||
// based on the new events, if necessary
|
||
}, [events]);
|
||
|
||
const onRangeChange = (range) => {
|
||
if (Array.isArray(range)) {
|
||
// For week and day views, range is an array of dates
|
||
setVisibleRange({ start: range[0], end: range[range.length - 1] });
|
||
} else {
|
||
// For month view, range is an object with start and end
|
||
setVisibleRange(range);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (currentView === 'agenda') {
|
||
const filtered = evts?.filter(event => event.type === "assignment");
|
||
setDisplayedEvents(filtered);
|
||
} else {
|
||
// Function to generate weekly occurrences of an event
|
||
const recurringEvents = evts?.filter(event => event.type !== "assignment" && (event.dayOfMonth === null || event.dayOfMonth === undefined)) || [];
|
||
const occurrences = recurringEvents?.flatMap(event => generateOccurrences(event, visibleRange.start, visibleRange.end)) || [];
|
||
const nonRecurringEvents = evts?.filter(event => event.dayOfMonth !== null) || [];
|
||
|
||
setDisplayedEvents([...nonRecurringEvents, ...recurringEvents, ...occurrences]);
|
||
}
|
||
//setDisplayedEvents(evts);
|
||
}, [visibleRange, evts, currentView]);
|
||
|
||
// todo: review that
|
||
const handlers = useSwipeable({
|
||
onSwipedLeft: () => navigate('NEXT'),
|
||
onSwipedRight: () => navigate('PREV'),
|
||
preventDefaultTouchmoveEvent: true,
|
||
trackMouse: true,
|
||
});
|
||
const navigate = (action) => {
|
||
console.log('navigate', action);
|
||
setDate((currentDate) => {
|
||
const newDate = new Date(currentDate);
|
||
if (action === 'NEXT') {
|
||
newDate.setMonth(newDate.getMonth() + 1);
|
||
} else if (action === 'PREV') {
|
||
newDate.setMonth(newDate.getMonth() - 1);
|
||
}
|
||
return newDate;
|
||
});
|
||
};
|
||
|
||
const generateOccurrences = (event, start, end) => {
|
||
const occurrences = [];
|
||
const eventStart = new Date(event.startTime);
|
||
let current = new Date(event.startTime); // Assuming startTime has the start date
|
||
|
||
// Determine the end date for the event series
|
||
const seriesEndDate = event.endDate ? new Date(event.endDate) : end;
|
||
seriesEndDate.setHours(23, 59, 59); // Set to the end of the day
|
||
|
||
while (current <= seriesEndDate && current <= end) {
|
||
// Check if the event should be repeated weekly or on a specific day of the month
|
||
if (event.repeatWeekly && current.getDay() === eventStart.getDay()) {
|
||
// For weekly recurring events
|
||
addOccurrence(event, current, occurrences);
|
||
} else if (event.dayOfMonth && current.getDate() === event.dayOfMonth) {
|
||
// For specific day of month events
|
||
addOccurrence(event, current, occurrences);
|
||
}
|
||
|
||
// Move to the next day
|
||
current = new Date(current.setDate(current.getDate() + 1));
|
||
}
|
||
|
||
return occurrences;
|
||
};
|
||
|
||
// Helper function to add an occurrence
|
||
const addOccurrence = (event, current, occurrences) => {
|
||
// Skip the original event date
|
||
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
if (current.toDateString() !== eventStart.toDateString()) {
|
||
const occurrence = {
|
||
...event,
|
||
startTime: new Date(current.setHours(eventStart.getHours(), eventStart.getMinutes())),
|
||
endTime: new Date(current.setHours(eventEnd.getHours(), eventEnd.getMinutes())),
|
||
date: current,
|
||
type: 'recurring'
|
||
};
|
||
occurrences.push(occurrence);
|
||
}
|
||
};
|
||
const filterEvents = (evts, publisherId, startdate) => {
|
||
setDate(startdate); // Assuming setDate is a function that sets some state or context
|
||
|
||
const filterDayOfWeek = startdate.getDay(); // Sunday - 0, Monday - 1, ..., Saturday - 6
|
||
|
||
// Filter events based on the publisher ID and the start date/time
|
||
const existingEvents = evts?.filter(event => {
|
||
// Ensure the event belongs to the specified publisher
|
||
const isPublisherMatch = (event.publisher?.id || event.publisherId) === publisherId;
|
||
const eventDayOfWeek = event.startTime.getDay();
|
||
|
||
let isDateMatch;
|
||
const eventDate = new Date(event.startTime);
|
||
if (event.repeatWeekly && filterDayOfWeek === eventDayOfWeek) {
|
||
isDateMatch = true;
|
||
} else if (event.date) {
|
||
// Compare the full date. issameday is not working. do it manually
|
||
isDateMatch = eventDate.setHours(0, 0, 0, 0) === new Date(startdate).setHours(0, 0, 0, 0);
|
||
}
|
||
|
||
return isPublisherMatch && isDateMatch;
|
||
});
|
||
|
||
return existingEvents;
|
||
};
|
||
|
||
|
||
// const totalHours = maxHour - minHour;
|
||
|
||
const handleSelect = ({ mode, start, end }) => {
|
||
//we set the time to proper timezone
|
||
const startdate = common.setTimezone(start);
|
||
const enddate = common.setTimezone(end);
|
||
|
||
if (!start || !end) return;
|
||
//readonly for past dates (ToDo: if not admin)
|
||
if (!isAdmin) {
|
||
if (startdate < new Date() || end < new Date() || startdate > end) return;
|
||
//or if schedule is published (lastPublishedDate)
|
||
if (editLockedBefore && startdate < editLockedBefore) {
|
||
toast.error(`Не можете да променяте предпочитанията си за дати преди ${common.getDateFormattedShort(editLockedBefore)}.`, { autoClose: 5000 });
|
||
return;
|
||
|
||
}
|
||
}
|
||
// Check if start and end are on the same day
|
||
if (startdate.toDateString() !== enddate.toDateString()) {
|
||
end = common.setTimeHHmm(startdate, "23:59");
|
||
}
|
||
|
||
// Update date state and calculate events based on the new startdate
|
||
setDate(startdate);
|
||
const existingEvents = filterEvents(evts, publisherId, startdate);
|
||
console.log("handleSelect: ", existingEvents);
|
||
|
||
// Use the updated startdate for getCartEvent and ensure it reflects in the state properly
|
||
const cartEvent = getCartEvent(startdate);
|
||
setCartEvent(cartEvent);
|
||
console.log("cartEvent: ", cartEvent);
|
||
setSelectedEvents(existingEvents);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleEventClick = (event) => {
|
||
if (event.type === "assignment") return;
|
||
//select the whole day
|
||
let start = new Date(event.startTime);
|
||
start.setHours(0, 0, 0, 0);
|
||
let end = new Date(event.startTime);
|
||
end.setHours(23, 59, 59, 999);
|
||
handleSelect({ mode: 'select', start: start, end: end });
|
||
};
|
||
|
||
const handleDialogClose = async (dialogEvent) => {
|
||
setIsModalOpen(false);
|
||
if (dialogEvent === null || dialogEvent === undefined) {
|
||
} else {
|
||
let e = await axiosInstance.get(`/api/?action=getCalendarEvents&publisherId=${publisherId}`);
|
||
var newEvents = e.data;
|
||
// set start and end to Date objects for all events. Fix for the calendar component
|
||
newEvents.forEach(event => {
|
||
event.startTime = new Date(event.startTime);
|
||
event.endTime = new Date(event.endTime);
|
||
});
|
||
setEvents(newEvents);
|
||
}
|
||
|
||
console.log("handleSave: ", dialogEvent);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
|
||
// REDRAW (PAGING) STYLES FOR THE EVENT DEFINED HERE
|
||
const eventStyleGetter = (event, start, end, isSelected) => {
|
||
|
||
//console.log("eventStyleGetter: " + event);
|
||
let backgroundColor = '#3174ad'; // default color for calendar events - #3174ad
|
||
if (currentView === 'agenda') {
|
||
return { style: {} }
|
||
}
|
||
if (event.type === "assignment") {
|
||
//event.title = event.publisher.name; //ToDo: add other publishers names
|
||
}
|
||
if (event.type === "availability") {
|
||
|
||
}
|
||
if (event.isFromPreviousAssignment) { //ToDo: does it work?
|
||
// orange-500 from Tailwind CSS
|
||
backgroundColor = '#f56565';
|
||
}
|
||
if (event.isFromPreviousMonth) {
|
||
//gray
|
||
backgroundColor = '#a0aec0';
|
||
}
|
||
|
||
|
||
// if (event.isActive) {
|
||
switch (event.type) {
|
||
case 'assignment':
|
||
backgroundColor = '#48bb78' // always green-500 as we don't pass isConfirmed correctly
|
||
//backgroundColor = event.isConfirmed ? '#48bb78' : '#f6e05e'; // green-500 and yellow-300 from Tailwind CSS
|
||
break;
|
||
case 'recurring':
|
||
backgroundColor = '#63b3ed'; // blue-300 from Tailwind CSS
|
||
break;
|
||
default: // availability
|
||
//backgroundColor = '#a0aec0'; // gray-400 from Tailwind CSS
|
||
break;
|
||
}
|
||
// } else {
|
||
// backgroundColor = '#a0aec0'; // Default color for inactive events
|
||
// }
|
||
|
||
return {
|
||
style: {
|
||
backgroundColor,
|
||
opacity: event.startTime < new Date() ? 0.5 : 1,
|
||
color: 'white',
|
||
border: '0px',
|
||
display: 'block',
|
||
}
|
||
};
|
||
}
|
||
|
||
// INITIAL STYLE FOR THE EVENT DEFINED HERE
|
||
const EventWrapper = ({ event, style }) => {
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
let eventStyle = {
|
||
...style
|
||
};
|
||
const handleMouseEnter = () => setIsHovered(true);
|
||
const handleMouseLeave = () => setIsHovered(false);
|
||
if (currentView !== 'agenda') {
|
||
//if event.type is availability show in blue. if it is schedule - green if confirmed, yellow if not confirmed
|
||
var bgColor = "";
|
||
//ToDo: fix this. maybe we're missing some properties
|
||
// if (event.isFromPreviousMonth) {
|
||
// // set opacity to 0.5
|
||
// bgColor = "bg-orange-500";
|
||
// }
|
||
if (event.type === "assignment") {
|
||
bgColor = event.isBySystem ? "bg-red-500" : (event.isConfirmed || true ? "bg-green-500" : "bg-yellow-500");
|
||
|
||
//event.title = event.publisher.name; //ToDo: add other publishers names
|
||
//event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
|
||
} else {
|
||
if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) {
|
||
try {
|
||
if (event.type === "recurring") {
|
||
event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
|
||
}
|
||
else {
|
||
event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
|
||
}
|
||
}
|
||
catch (err) {
|
||
event.title = event.startTime + " - " + event.endTime;
|
||
console.log("Error in EventWrapper: " + err);
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
|
||
eventStyle = {
|
||
...style,
|
||
// backgroundColor: bgColorClass,
|
||
//height: "50px",
|
||
//color: 'white',
|
||
//if (event.isFromPreviousAssignment) { set opacity to 0.5 }
|
||
// opacity: event.isFromPreviousMonth ? 0.5 : 1,
|
||
whiteSpace: 'normal', // Allow the text to wrap to the next line
|
||
overflow: 'hidden', // Hide overflowed content
|
||
textOverflow: 'ellipsis' // Add ellipsis to text that's too long to fit
|
||
};
|
||
if (event.date < new Date()) {
|
||
eventStyle.opacity = 0.5;
|
||
}
|
||
|
||
}
|
||
const onDelete = (event) => {
|
||
// Remove the event from the calendar
|
||
setEvents(currentEvents => currentEvents.filter(e => e.id !== event.id));
|
||
};
|
||
|
||
const onConfirm = (event) => {
|
||
console.log("onConfirm: " + event.id);
|
||
toast.info("Потвърдено!", { autoClose: 2000 });
|
||
// Update the event data
|
||
event.isConfirmed = true;
|
||
event.isBySystem = false;
|
||
// Update the events array by first removing the old event and then adding the updated one
|
||
setEvents(currentEvents => {
|
||
const filteredEvents = currentEvents.filter(e => e.id !== event.id);
|
||
return [...filteredEvents, event];
|
||
});
|
||
//store the updated event in the database
|
||
var assignment = {
|
||
isConfirmed: true,
|
||
isBySystem: false
|
||
};
|
||
axiosInstance.put('/api/data/assignments/' + event.id, assignment)
|
||
.then((response) => {
|
||
console.log(response);
|
||
})
|
||
.catch((error) => {
|
||
console.log(error);
|
||
});
|
||
};
|
||
return (
|
||
<div style={eventStyle} className={bgColor + " relative"}
|
||
onMouseEnter={handleMouseEnter}
|
||
onMouseLeave={handleMouseLeave} >
|
||
{event.title}
|
||
{isHovered && event.type == "assignment" && (event.status == "pending" || event.status == undefined)
|
||
&& (
|
||
<div className="absolute top-1 left-0 right-0 flex justify-between px-1">
|
||
{/* Delete Icon */}
|
||
{/* <span
|
||
className="disabled cursor-pointer rounded-full bg-red-500 text-white flex items-center justify-center"
|
||
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
|
||
onClick={() => onDelete(event)}
|
||
>
|
||
✕
|
||
</span> */}
|
||
|
||
{/* Confirm Icon */}
|
||
{/* {!event.isConfirmed && (
|
||
<span
|
||
className=" cursor-pointer rounded-full bg-green-500 text-white flex items-center justify-center"
|
||
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
|
||
onClick={() => onConfirm(event)}
|
||
>
|
||
✓
|
||
</span>
|
||
)} */}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
const CustomToolbar = ({ onNavigate, label, onView, view }) => {
|
||
return (
|
||
<div className="rbc-toolbar">
|
||
<span className="rbc-btn-group">
|
||
<button type="button" onClick={() => onNavigate('PREV')}>
|
||
<FaArrowLeft className="icon-large" />
|
||
</button>
|
||
<button type="button" onClick={() => onNavigate('TODAY')}>
|
||
<MdToday className="icon-large" />
|
||
</button>
|
||
<button type="button" onClick={() => onNavigate('NEXT')}>
|
||
<FaArrowRight className="icon-large" />
|
||
</button>
|
||
</span>
|
||
<span className="rbc-toolbar-label">{label}</span>
|
||
<span className="rbc-btn-group">
|
||
<button type="button" onClick={() => onView('month')} className={view === 'month' ? 'rbc-active' : ''}>
|
||
<FaRegCalendarAlt className="icon-large" />
|
||
</button>
|
||
<button type="button" onClick={() => onView('week')} className={view === 'week' ? 'rbc-active' : ''}>
|
||
<FaRegListAlt className="icon-large" />
|
||
</button>
|
||
<button type="button" onClick={() => onView('agenda')} className={view === 'agenda' ? 'rbc-active' : ''}>
|
||
<FaRegCalendarCheck className="icon-large" />
|
||
</button>
|
||
{/* Add more view buttons as needed */}
|
||
</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
const CustomEventAgenda = ({
|
||
event
|
||
}) => (
|
||
<span>
|
||
<em style={{ color: 'black' }}>{event.title}</em>
|
||
<p>{event.desc}</p>
|
||
</span>
|
||
);
|
||
|
||
|
||
return (
|
||
<> <div {...handlers} className="flex flex-col"
|
||
>
|
||
{/* достъпности на {publisherId} */}
|
||
{/* having multiple ToastContainers causes double rendering of toasts and all kind of problems */}
|
||
{/* <ToastContainer position="top-center" style={{ zIndex: 9999 }} /> */}
|
||
</div>
|
||
<Calendar
|
||
localizer={localizer}
|
||
events={displayedEvents}
|
||
startAccessor="startTime"
|
||
endAccessor="endTime"
|
||
selectable={true}
|
||
onSelectSlot={handleSelect}
|
||
onSelectEvent={handleEventClick}
|
||
style={{ height: '100%', width: '100%' }}
|
||
min={cartEvent?.startTime} // Set minimum time
|
||
max={cartEvent?.endTime} // Set maximum time
|
||
messages={messages}
|
||
view={currentView}
|
||
views={['month', 'week', 'agenda']}
|
||
onView={view => setCurrentView(view)}
|
||
onRangeChange={onRangeChange}
|
||
components={{
|
||
event: EventWrapper,
|
||
toolbar: CustomToolbar,
|
||
view: CustomEventAgenda,
|
||
agenda: {
|
||
event: CustomEventAgenda
|
||
},
|
||
// ... other custom components
|
||
}}
|
||
eventPropGetter={(eventStyleGetter)}
|
||
date={date}
|
||
showAllEvents={true}
|
||
onNavigate={setDate}
|
||
className="rounded-lg shadow-lg"
|
||
longPressThreshold={150} // default value 250
|
||
/>
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||
<div className="modal-content">
|
||
<AvailabilityForm
|
||
publisherId={publisherId}
|
||
existingItems={selectedEvents}
|
||
date={date}
|
||
onDone={handleDialogClose}
|
||
inline={true}
|
||
cartEvent={cartEvent}
|
||
// Pass other props as needed
|
||
/>
|
||
</div>
|
||
<div className="fixed inset-0 bg-black opacity-50" onClick={handleCancel}></div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default AvCalendar;
|