444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
import { addMonths, addDays, format, isSameDay, isWithinInterval, parseISO, getDay, parse, differenceInDays } from 'date-fns';
|
|
import React, { useContext, useEffect, useState, useMemo, useCallback } from 'react';
|
|
import { FlatList, ScrollView, StyleSheet, View, Text, TouchableOpacity, Modal } from 'react-native';
|
|
import ErrorBoundary from 'react-native-error-boundary';
|
|
import { RefreshControl } from 'react-native-web-refresh-control';
|
|
import { InterfaceContext } from '../Interface';
|
|
import { SettingsContext } from '../SettingsProvider';
|
|
import ErrorDisplay from '../common/ErrorDisplay';
|
|
import { ThemeButton, ThemeText } from '../common/ThemeTypes';
|
|
import { useColors, useDayColors } from '../common/colors';
|
|
import DatePicker from '../common/DatePicker';
|
|
|
|
const dayTypeNames = { schoolday: 'Schultag', weekend: 'Wochenende', holiday: 'Feiertag', bridgeday: 'Brückentag', vacation: 'Ferien', examday: 'Prüftag' }
|
|
const dayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
|
const dayNamesShort = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
|
let errorObject;
|
|
|
|
const Calendar = () => {
|
|
const colors = useColors();
|
|
const dayColors = useDayColors();
|
|
const [calendarData, setCalendarData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
const { fetchAppointments, fetchTests, fetchDayTypes, refreshInterface, userInformation } = useContext(InterfaceContext);
|
|
const { settings } = useContext(SettingsContext);
|
|
const [days, setDays] = useState([]);
|
|
const [selectedView, setSelectedView] = useState('days');
|
|
const [showDayModal, setShowDayModal] = useState(false);
|
|
const [dayModalDate, setDayModalDate] = useState(new Date());
|
|
const [startDate, setStartDate] = useState(new Date(format(new Date(), 'yyyy-MM-dd')));
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
if (!userInformation) {
|
|
refreshInterface()
|
|
throw new Error("UserInformation is Null");
|
|
}
|
|
let type = userInformation.usertype == 'pupil' ? 'class' : 'teacher';
|
|
let value = userInformation.usertype == 'pupil' ? userInformation.classid : userInformation.id;
|
|
const endDate = addMonths(startDate, 12);
|
|
const [appointments, tests, dayTypes] = await Promise.all([
|
|
fetchAppointments(startDate),
|
|
fetchTests(type, value, startDate),
|
|
fetchDayTypes(startDate, endDate)
|
|
]);
|
|
|
|
if (!appointments || !tests || !dayTypes) throw new Error("Data returned is Null");
|
|
setCalendarData({ appointments, tests, dayTypes });
|
|
setDays(eachDayOfInterval({ start: startDate, end: endDate }));
|
|
setError(false);
|
|
} catch (error) {
|
|
console.error(error)
|
|
errorObject = error;
|
|
setError(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [settings, userInformation, startDate]);
|
|
|
|
const refresh = () => {
|
|
fetchData();
|
|
};
|
|
|
|
const openDayModal = useCallback((date) => {
|
|
setDayModalDate(date);
|
|
setShowDayModal(true);
|
|
}, []);
|
|
|
|
const renderContent = () => {
|
|
if (loading && !calendarData) {
|
|
return null;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<ErrorDisplay message={errorObject?.message} onRefresh={refresh} />
|
|
)
|
|
}
|
|
|
|
try {
|
|
if (selectedView === 'days') {
|
|
return (
|
|
<FlatList
|
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
|
|
data={days}
|
|
keyExtractor={(item) => item.toString()}
|
|
renderItem={({ item }) => (
|
|
<Day
|
|
onClick={() => openDayModal(item)}
|
|
date={item}
|
|
calendarData={calendarData}
|
|
dayColors={dayColors}
|
|
colors={colors}
|
|
/>
|
|
)}
|
|
maxToRenderPerBatch={20}
|
|
updateCellsBatchingPeriod={0}
|
|
getItemLayout={(data, index) => (
|
|
{ length: 70, offset: 70 * index, index }
|
|
)}
|
|
/>
|
|
);
|
|
} else if (selectedView === 'tests') {
|
|
return (
|
|
<FlatList
|
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
|
|
data={calendarData.tests}
|
|
keyExtractor={(item) => item.date + item.from + item.until + item.subject}
|
|
renderItem={({ item }) => (
|
|
<View style={[styles.dayBox, styles.rowContainer, { backgroundColor: colors.backPrimary }]}>
|
|
<ThemeText style={{ width: 90 }}>{format(item.date, 'dd.MM.yyyy')}</ThemeText>
|
|
<ThemeText style={{ width: 60 }}>{item.subject}</ThemeText>
|
|
<ThemeText style={{ width: 30 }}>{item.type}</ThemeText>
|
|
<ThemeText style={{ width: 40 }}>{item.room}</ThemeText>
|
|
<ThemeText style={{ width: 40 }}>{item.from}-{item.until}</ThemeText>
|
|
{userInformation.usertype == 'teacher' && <ThemeText style={{ width: 50 }}>{item.class}</ThemeText>}
|
|
{userInformation.usertype == 'pupil' && <ThemeText style={{ width: 50 }}>{item.class}</ThemeText>}
|
|
{userInformation.usertype == 'pupil' && <ThemeText style={{ width: 30 }}>{item.teacher}</ThemeText>}
|
|
</View>
|
|
)}
|
|
getItemLayout={(data, index) => (
|
|
{ length: 70, offset: 70 * index, index }
|
|
)}
|
|
/>
|
|
);
|
|
} else if (selectedView === 'appointments') {
|
|
// Group appointments by day
|
|
const groupedAppointments = calendarData.appointments.reduce((acc, appointment) => {
|
|
if (!appointment.from) {
|
|
return acc;
|
|
}
|
|
const dateKey = format(appointment.from, 'yyyy-MM-dd');
|
|
if (!acc[dateKey]) {
|
|
acc[dateKey] = [];
|
|
}
|
|
acc[dateKey].push(appointment);
|
|
return acc;
|
|
}, {});
|
|
|
|
// Convert object to array for rendering
|
|
const groupedAppointmentsArray = Object.keys(groupedAppointments).map((date) => ({
|
|
date: parseISO(date),
|
|
appointments: groupedAppointments[date]
|
|
}));
|
|
|
|
return (
|
|
<FlatList
|
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} colors={[colors.primary]} />}
|
|
data={groupedAppointmentsArray}
|
|
keyExtractor={(item) => format(item.date, 'yyyy-MM-dd')}
|
|
renderItem={({ item }) => (
|
|
<View style={{ marginTop: 5, borderBottomWidth: 1, borderColor: '#ccc', }}>
|
|
<ThemeText style={{ fontSize: 18, marginBottom: 10, marginLeft: 20 }}>{format(item.date, 'dd.MM.yyyy')}</ThemeText>
|
|
{item.appointments.map((appointment, index) => (
|
|
<View key={index} style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 5, marginBottom: 10 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<>
|
|
<ThemeText>{`${format(appointment.from, 'HH:mm')} - ${endsNextDayMidnight(appointment.from, appointment.until) ? '24:00' : format(appointment.until, 'HH:mm')}`}</ThemeText>
|
|
{!endsNextDayMidnight(appointment.from, appointment.until) && <ThemeText>{`bis ${format(appointment.until, 'dd.MM.yyyy')}`}</ThemeText>}
|
|
</>
|
|
</View>
|
|
<View style={{ flex: 2 }}>
|
|
<ThemeText>{appointment.description}</ThemeText>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
getItemLayout={(data, index) => (
|
|
{ length: 40, offset: 40 * index, index }
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(error)
|
|
return (
|
|
<View>
|
|
<ThemeText>Fehler beim rendern des Kalenders</ThemeText>
|
|
<ThemeText>{JSON.stringify(calendarData.times)}</ThemeText>
|
|
<ThemeText>{JSON.stringify(calendarData.dayTypes)}</ThemeText>
|
|
</View>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
// @ts-expect-error - Für Web, TS kennt userSelect nicht
|
|
<View style={{ flex: 1, backgroundColor: colors.backPrimary, userSelect: 'none' }}>
|
|
<View style={styles.container}>
|
|
<DatePicker
|
|
date={startDate}
|
|
setDate={(date) => {
|
|
setStartDate(date)
|
|
}}
|
|
/>
|
|
<ThemeButton onPress={() => setSelectedView('days')} color={selectedView === 'days' ? colors.accent : null}><Text>Tage</Text></ThemeButton>
|
|
<ThemeButton onPress={() => setSelectedView('tests')} color={selectedView === 'tests' ? colors.accent : null}><Text>Prüfungen</Text></ThemeButton>
|
|
<ThemeButton onPress={() => setSelectedView('appointments')} color={selectedView === 'appointments' ? colors.accent : null}><Text>Termine</Text></ThemeButton>
|
|
</View >
|
|
<ErrorBoundary>
|
|
{renderContent()}
|
|
{showDayModal && <DayModal date={dayModalDate} calendarData={calendarData} colors={colors} dayColors={dayColors} onClose={() => setShowDayModal(false)} userInformation={userInformation} />}
|
|
</ErrorBoundary>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default Calendar;
|
|
|
|
const styles = StyleSheet.create({
|
|
dayBox: {
|
|
paddingLeft: 10,
|
|
height: 40,
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
borderBottomWidth: 1,
|
|
borderColor: '#ccc',
|
|
},
|
|
container: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
alignItems: 'center',
|
|
paddingRight: 10,
|
|
gap: 10,
|
|
},
|
|
rowContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-start',
|
|
alignItems: 'center',
|
|
}
|
|
});
|
|
|
|
const Day = React.memo(({ date, calendarData, dayColors, onClick }: any) => {
|
|
let dayType = useMemo(() => getDayType(date, calendarData), [date, calendarData]);
|
|
const tests = useMemo(() => getTests(date, calendarData), [date, calendarData]);
|
|
const appointments = useMemo(() => getAppointments(date, calendarData), [date, calendarData]);
|
|
|
|
let testText;
|
|
if (tests.length > 0) {
|
|
dayType = 'examday';
|
|
|
|
if (tests.length === 1) {
|
|
testText = ` Prüfung: ${tests[0].subject} ${tests[0].type} `;
|
|
} else if (tests.length > 1) {
|
|
testText = ` ${tests.length} Prüfungen `;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<TouchableOpacity onPress={onClick} style={[styles.dayBox, styles.rowContainer, { backgroundColor: dayColors[dayType] }]}>
|
|
<ThemeText style={{ width: 90 }}>{format(date, 'dd.MM.yyyy')}</ThemeText>
|
|
<ThemeText style={{ width: 30 }}>{dayNamesShort[getDay(date)]}</ThemeText>
|
|
<ThemeText>{testText}{appointments.length > 0 ? ` Termine: ${appointments.length}` : ""}</ThemeText>
|
|
</TouchableOpacity>
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
const res = prevProps.date === nextProps.date &&
|
|
prevProps.calendarData === nextProps.calendarData &&
|
|
prevProps.dayColors === nextProps.dayColors;
|
|
return res
|
|
});
|
|
|
|
|
|
const DayModal = React.memo(({ date, calendarData, onClose, dayColors, colors, userInformation }: any) => {
|
|
let dayType = useMemo(() => getDayType(date, calendarData), [date, calendarData]);
|
|
const tests = useMemo(() => getTests(date, calendarData), [date, calendarData]);
|
|
const appointments = useMemo(() => getAppointments(date, calendarData), [date, calendarData]);
|
|
|
|
if (tests.length > 0) {
|
|
dayType = 'examday';
|
|
}
|
|
|
|
const modalStyles = StyleSheet.create({
|
|
centeredView: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginTop: 22,
|
|
},
|
|
modalView: {
|
|
margin: 20,
|
|
backgroundColor: colors.backSecondary,
|
|
borderRadius: 20,
|
|
width: '80%',
|
|
shadowColor: '#000',
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 4,
|
|
elevation: 5,
|
|
borderWidth: 5,
|
|
borderColor: colors.backSecondary,
|
|
maxWidth: 500,
|
|
},
|
|
modalHeader: {
|
|
backgroundColor: dayColors[dayType],
|
|
borderTopLeftRadius: 18,
|
|
borderTopRightRadius: 18,
|
|
padding: 20,
|
|
alignItems: 'center',
|
|
},
|
|
modalContent: {
|
|
padding: 20,
|
|
},
|
|
dayBox: {
|
|
padding: 10,
|
|
borderBottomWidth: 1,
|
|
borderColor: '#ccc',
|
|
backgroundColor: colors.backPrimary,
|
|
},
|
|
titleText: {
|
|
fontWeight: 'bold',
|
|
fontSize: 20,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 18,
|
|
marginTop: 15,
|
|
marginBottom: 5,
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Modal
|
|
transparent={true}
|
|
visible={true}
|
|
onRequestClose={onClose}
|
|
>
|
|
<View style={modalStyles.centeredView}>
|
|
<View style={modalStyles.modalView}>
|
|
<View style={modalStyles.modalHeader}>
|
|
<ThemeText style={{ fontSize: 16 }}>{dayNames[getDay(date)]}</ThemeText>
|
|
<ThemeText style={modalStyles.titleText}>{format(date, 'dd.MM.yyyy')}</ThemeText>
|
|
<ThemeText style={{ fontSize: 16 }}>{dayTypeNames[dayType]}</ThemeText>
|
|
</View>
|
|
<View style={modalStyles.modalContent}>
|
|
<ThemeText style={modalStyles.sectionTitle}>Tests:</ThemeText>
|
|
{tests.length > 0 ? (
|
|
tests.map((test, index) => (
|
|
<View key={index} style={modalStyles.dayBox}>
|
|
<ThemeText>{`${test.subject} ${test.type} ${test.room} ${test.from}-${test.until} ${userInformation.usertype == 'pupil' ? test.teacher : test.class}`}</ThemeText>
|
|
</View>
|
|
))
|
|
) : (
|
|
<ThemeText>Keine Tests</ThemeText>
|
|
)}
|
|
|
|
<ThemeText style={modalStyles.sectionTitle}>Termine:</ThemeText>
|
|
{appointments.length > 0 ? (
|
|
appointments.map((appointment, index) => (
|
|
<View key={index} style={modalStyles.dayBox}>
|
|
<ThemeText style={{ fontWeight: 'bold' }}>
|
|
{`${format(appointment.from, 'dd.MM.yyyy HH:mm')} - `}
|
|
{`${endsSameDay(appointment.from, appointment.until) ? '' : format(appointment.until, 'dd.MM.yyyy') + ' '}`}
|
|
{`${endsNextDayMidnight(appointment.from, appointment.until) ? '24:00' : format(appointment.until, 'HH:mm')}`}
|
|
</ThemeText>
|
|
<ThemeText>{appointment.description}</ThemeText>
|
|
</View>
|
|
))
|
|
) : (
|
|
<ThemeText>Keine Termine</ThemeText>
|
|
)}
|
|
|
|
<ThemeButton onPress={onClose} style={{ marginTop: 20 }}><Text>Schließen</Text></ThemeButton>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
});
|
|
|
|
|
|
const getDayType = (date, calendarData) => {
|
|
const dayType = calendarData.dayTypes.find((d) =>
|
|
new Date(d.from) <= date && date <= new Date(d.until)
|
|
);
|
|
return dayType ? dayType.type : "schultag";
|
|
};
|
|
|
|
const getTests = (date, calendarData) => {
|
|
return calendarData.tests.filter((test) => isSameDay(parseISO(test.date), date));
|
|
};
|
|
|
|
const getAppointments = (date, calendarData) => {
|
|
return calendarData?.appointments?.filter((app) => {
|
|
if (!app.from || !app.until) {
|
|
return false; // Skip appointments without a valid start or end date
|
|
}
|
|
|
|
const appointmentStart = parseISO(app.from);
|
|
const appointmentEnd = parseISO(app.until);
|
|
|
|
// Check if the date falls within the appointment range
|
|
if (isWithinInterval(date, { start: appointmentStart, end: appointmentEnd })) {
|
|
return true;
|
|
}
|
|
|
|
// Check if the appointment spans multiple days
|
|
if (isSameDay(appointmentStart, appointmentEnd) && isSameDay(date, appointmentStart)) {
|
|
return true; // Single day appointment
|
|
}
|
|
|
|
return false;
|
|
});
|
|
};
|
|
|
|
|
|
function eachDayOfInterval({ start, end = new Date(Date.now()), }: { start: Date, end?: Date }) {
|
|
const days: Date[] = []
|
|
|
|
while (end >= start) {
|
|
days.push(start)
|
|
start = addDays(start, 1)
|
|
}
|
|
|
|
return days
|
|
}
|
|
|
|
function endsSameDay(start, end) {
|
|
const startD = new Date(start)
|
|
const endD = new Date(end)
|
|
|
|
const daysDifference = differenceInDays(startD, endD)
|
|
|
|
return daysDifference == 0 || endsNextDayMidnight(startD, endD)
|
|
}
|
|
|
|
function endsNextDayMidnight(start, end) {
|
|
const startD = new Date(start)
|
|
const endD = new Date(end)
|
|
|
|
const daysDifference = differenceInDays(startD, endD)
|
|
|
|
const endTimeIsMidnight = endD.getHours() === 0 && endD.getMinutes() === 0 && endD.getSeconds() === 0;
|
|
return (daysDifference == 1 || daysDifference == -1) && endTimeIsMidnight
|
|
} |