From 29fd040dadaa32b83d0b6709805de1fb6316363e Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Tue, 25 Mar 2025 21:52:40 -0400 Subject: [PATCH 01/10] initial modal changes --- src/components/ConfirmAbsenceModal.tsx | 128 +++++++++++++++++++++++++ src/components/InputForm.tsx | 53 ++++------ 2 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 src/components/ConfirmAbsenceModal.tsx diff --git a/src/components/ConfirmAbsenceModal.tsx b/src/components/ConfirmAbsenceModal.tsx new file mode 100644 index 00000000..e26adca0 --- /dev/null +++ b/src/components/ConfirmAbsenceModal.tsx @@ -0,0 +1,128 @@ +import { + Alert, + Box, + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import { MdWarning } from 'react-icons/md'; + +interface ConfirmAbsenceModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isSubmitting: boolean; + lessonDate: string; + hasLessonPlan: boolean; + isWithin14Days: boolean; +} + +export const ConfirmAbsenceModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + isSubmitting, + lessonDate, + hasLessonPlan, + isWithin14Days, +}) => { + const formattedDate = new Date(lessonDate + 'T00:00:00').toLocaleDateString( + 'en-CA', + { + month: 'long', + day: 'numeric', + year: 'numeric', + } + ); + + return ( + + + + + {hasLessonPlan ? 'Confirm Absence' : 'No Lesson Plan Added'} + + + + + + {hasLessonPlan ? ( + <> + Confirm Absence on {formattedDate}. + + ) : ( + <> + Declare absence on {formattedDate} without + adding a lesson plan? + + )} + + {isWithin14Days && ( + + + + + + + You are submitting a late report. Please aim to report + absences at least 14 days in advance. + + + + )} + + + + + + + + + + ); +}; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index 5bceb19a..0addf8a4 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -16,6 +16,7 @@ import { VStack, useDisclosure, useToast, + useTheme, } from '@chakra-ui/react'; import { Absence, Prisma } from '@prisma/client'; @@ -24,6 +25,7 @@ import { DateOfAbsence } from './DateOfAbsence'; import { FileUpload } from './FileUpload'; import { InputDropdown } from './InputDropdown'; import { SearchDropdown } from './SearchDropdown'; +import { ConfirmAbsenceModal } from './ConfirmAbsenceModal'; interface InputFormProps { onClose?: () => void; @@ -57,6 +59,7 @@ const InputForm: React.FC = ({ }); const [lessonPlan, setLessonPlan] = useState(null); const [errors, setErrors] = useState>({}); + const theme = useTheme(); const validateForm = () => { const newErrors: Record = {}; @@ -211,6 +214,11 @@ const InputForm: React.FC = ({ })); }; + const selectedDate = new Date(formData.lessonDate + 'T00:00:00'); + const now = new Date(); + const isWithin14Days = + (selectedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) <= 14; + return ( = ({ - - - - Confirm Absence - - - Please confirm your absence on{' '} - - {new Date(formData.lessonDate + 'T00:00:00').toLocaleDateString( - 'en-CA', - { - weekday: 'long', - month: 'long', - day: 'numeric', - } - )} - - . - - - - - - - - + ); }; From d51f55a07d419926b56fab7c5943d3d8b40fda73 Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Sat, 29 Mar 2025 13:35:45 -0400 Subject: [PATCH 02/10] complete confirmation modals --- src/components/ConfirmAbsenceModal.tsx | 68 +++++++++++++++----------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/components/ConfirmAbsenceModal.tsx b/src/components/ConfirmAbsenceModal.tsx index e26adca0..199a8c62 100644 --- a/src/components/ConfirmAbsenceModal.tsx +++ b/src/components/ConfirmAbsenceModal.tsx @@ -1,7 +1,7 @@ import { - Alert, Box, Button, + HStack, Modal, ModalBody, ModalContent, @@ -9,7 +9,9 @@ import { ModalHeader, ModalOverlay, Text, + useTheme, VStack, + Icon, } from '@chakra-ui/react'; import { MdWarning } from 'react-icons/md'; @@ -32,6 +34,8 @@ export const ConfirmAbsenceModal: React.FC = ({ hasLessonPlan, isWithin14Days, }) => { + const theme = useTheme(); + const formattedDate = new Date(lessonDate + 'T00:00:00').toLocaleDateString( 'en-CA', { @@ -44,48 +48,53 @@ export const ConfirmAbsenceModal: React.FC = ({ return ( - + {hasLessonPlan ? 'Confirm Absence' : 'No Lesson Plan Added'} - - + + {hasLessonPlan ? ( <> - Confirm Absence on {formattedDate}. + Please confirm your absence on{' '} + {formattedDate}. ) : ( <> - Declare absence on {formattedDate} without + Declare absence on {formattedDate}, without adding a lesson plan? )} {isWithin14Days && ( - - - - - - - You are submitting a late report. Please aim to report - absences at least 14 days in advance. - - - + + + + You are submitting a late report. Please aim to report + absences at least 14 days in advance. + + )} @@ -93,9 +102,9 @@ export const ConfirmAbsenceModal: React.FC = ({ @@ -117,7 +126,8 @@ export const ConfirmAbsenceModal: React.FC = ({ maxW="104px" h="40px" borderRadius="lg" - fontSize="lg" + fontSize="16px" + font-weight="400" > Confirm From a0f699c4f82e1ce70524ea57a35f810c54b023f1 Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Sat, 29 Mar 2025 19:11:52 -0400 Subject: [PATCH 03/10] toast complete --- src/components/AbsenceDetails.tsx | 26 +++++++++++++++ src/components/ClaimAbsenceToast.tsx | 49 ++++++++++++++++++++++++++++ src/components/InputForm.tsx | 6 ---- 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/components/ClaimAbsenceToast.tsx diff --git a/src/components/AbsenceDetails.tsx b/src/components/AbsenceDetails.tsx index 467001b8..1f82190c 100644 --- a/src/components/AbsenceDetails.tsx +++ b/src/components/AbsenceDetails.tsx @@ -19,10 +19,13 @@ import { FiEdit2, FiMapPin, FiTrash2, FiUser } from 'react-icons/fi'; import { IoEyeOutline } from 'react-icons/io5'; import AbsenceStatusTag from './AbsenceStatusTag'; import LessonPlanView from './LessonPlanView'; +import { useToast } from '@chakra-ui/react'; +import ClaimAbsenceToast from './ClaimAbsenceToast'; const AbsenceDetails = ({ isOpen, onClose, event }) => { const theme = useTheme(); const userData = useUserData(); + const toast = useToast(); if (!event) return null; const userId = userData.id; @@ -224,6 +227,29 @@ const AbsenceDetails = ({ isOpen, onClose, event }) => { height="44px" fontSize="16px" fontWeight="500" + onClick={() => { + // toast modal is here for now + const firstName = event.absentTeacher.firstName; + const absenceDate = event.start + ? new Date(event.start).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + }) + : 'N/A'; + toast({ + position: 'bottom', + duration: 10000, + isClosable: true, + render: () => ( + + ), + }); + }} > Fill this Absence diff --git a/src/components/ClaimAbsenceToast.tsx b/src/components/ClaimAbsenceToast.tsx new file mode 100644 index 00000000..ce62d622 --- /dev/null +++ b/src/components/ClaimAbsenceToast.tsx @@ -0,0 +1,49 @@ +import { Box, Text, useTheme } from '@chakra-ui/react'; +import { MdCheckCircle, MdError } from 'react-icons/md'; + +const ClaimAbsenceToast = ({ firstName, date, success }) => { + const theme = useTheme(); + + const modalColor = success + ? theme.colors.positiveGreen[200] || 'green.200' + : theme.colors.errorRed[200] || 'red.200'; + + const message = success + ? `You have successfully claimed ` + : `There was an error in claiming `; + + const Icon = success ? MdCheckCircle : MdError; + + return ( + + + + + + {message} + + {firstName}'s + {' '} + absence on{' '} + + {date} + + . + + + ); +}; + +export default ClaimAbsenceToast; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index 0addf8a4..785ae3d3 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -5,12 +5,6 @@ import { FormErrorMessage, FormLabel, Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, Text, Textarea, VStack, From 54cbbf3ffc8e80c4876fe7d81133b0c775b773f3 Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Sat, 29 Mar 2025 19:24:06 -0400 Subject: [PATCH 04/10] merge --- src/components/ConfirmAbsenceModal.tsx | 128 +++++++++++++++++++++++++ src/components/InputForm.tsx | 48 ++++------ 2 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 src/components/ConfirmAbsenceModal.tsx diff --git a/src/components/ConfirmAbsenceModal.tsx b/src/components/ConfirmAbsenceModal.tsx new file mode 100644 index 00000000..e26adca0 --- /dev/null +++ b/src/components/ConfirmAbsenceModal.tsx @@ -0,0 +1,128 @@ +import { + Alert, + Box, + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import { MdWarning } from 'react-icons/md'; + +interface ConfirmAbsenceModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isSubmitting: boolean; + lessonDate: string; + hasLessonPlan: boolean; + isWithin14Days: boolean; +} + +export const ConfirmAbsenceModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + isSubmitting, + lessonDate, + hasLessonPlan, + isWithin14Days, +}) => { + const formattedDate = new Date(lessonDate + 'T00:00:00').toLocaleDateString( + 'en-CA', + { + month: 'long', + day: 'numeric', + year: 'numeric', + } + ); + + return ( + + + + + {hasLessonPlan ? 'Confirm Absence' : 'No Lesson Plan Added'} + + + + + + {hasLessonPlan ? ( + <> + Confirm Absence on {formattedDate}. + + ) : ( + <> + Declare absence on {formattedDate} without + adding a lesson plan? + + )} + + {isWithin14Days && ( + + + + + + + You are submitting a late report. Please aim to report + absences at least 14 days in advance. + + + + )} + + + + + + + + + + ); +}; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index d5a898bc..c974dd1b 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -16,6 +16,7 @@ import { VStack, useDisclosure, useToast, + useTheme, } from '@chakra-ui/react'; import { Absence, Prisma } from '@prisma/client'; @@ -24,6 +25,7 @@ import { DateOfAbsence } from './DateOfAbsence'; import { FileUpload } from './FileUpload'; import { InputDropdown } from './InputDropdown'; import { SearchDropdown } from './SearchDropdown'; +import { ConfirmAbsenceModal } from './ConfirmAbsenceModal'; interface InputFormProps { onClose?: () => void; @@ -59,6 +61,7 @@ const InputForm: React.FC = ({ }); const [lessonPlan, setLessonPlan] = useState(null); const [errors, setErrors] = useState>({}); + const theme = useTheme(); const validateForm = () => { const newErrors: Record = {}; @@ -213,6 +216,11 @@ const InputForm: React.FC = ({ })); }; + const selectedDate = new Date(formData.lessonDate + 'T00:00:00'); + const now = new Date(); + const isWithin14Days = + (selectedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) <= 14; + return ( = ({ - - - - Confirm Absence - - - Please confirm your absence on{' '} - - {new Date(formData.lessonDate + 'T00:00:00').toLocaleDateString( - 'en-CA', - { - weekday: 'long', - month: 'long', - day: 'numeric', - } - )} - - . - - - - - - - - + ); }; From 882fb9b3fc7e25d6d321286a028f2edf15ad3240 Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Sat, 29 Mar 2025 13:35:45 -0400 Subject: [PATCH 05/10] complete confirmation modals --- src/components/ConfirmAbsenceModal.tsx | 68 +++++++++++++++----------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/components/ConfirmAbsenceModal.tsx b/src/components/ConfirmAbsenceModal.tsx index e26adca0..199a8c62 100644 --- a/src/components/ConfirmAbsenceModal.tsx +++ b/src/components/ConfirmAbsenceModal.tsx @@ -1,7 +1,7 @@ import { - Alert, Box, Button, + HStack, Modal, ModalBody, ModalContent, @@ -9,7 +9,9 @@ import { ModalHeader, ModalOverlay, Text, + useTheme, VStack, + Icon, } from '@chakra-ui/react'; import { MdWarning } from 'react-icons/md'; @@ -32,6 +34,8 @@ export const ConfirmAbsenceModal: React.FC = ({ hasLessonPlan, isWithin14Days, }) => { + const theme = useTheme(); + const formattedDate = new Date(lessonDate + 'T00:00:00').toLocaleDateString( 'en-CA', { @@ -44,48 +48,53 @@ export const ConfirmAbsenceModal: React.FC = ({ return ( - + {hasLessonPlan ? 'Confirm Absence' : 'No Lesson Plan Added'} - - + + {hasLessonPlan ? ( <> - Confirm Absence on {formattedDate}. + Please confirm your absence on{' '} + {formattedDate}. ) : ( <> - Declare absence on {formattedDate} without + Declare absence on {formattedDate}, without adding a lesson plan? )} {isWithin14Days && ( - - - - - - - You are submitting a late report. Please aim to report - absences at least 14 days in advance. - - - + + + + You are submitting a late report. Please aim to report + absences at least 14 days in advance. + + )} @@ -93,9 +102,9 @@ export const ConfirmAbsenceModal: React.FC = ({ @@ -117,7 +126,8 @@ export const ConfirmAbsenceModal: React.FC = ({ maxW="104px" h="40px" borderRadius="lg" - fontSize="lg" + fontSize="16px" + font-weight="400" > Confirm From 600920950a5cefc6a2f6f0693e6e62e7cad5f856 Mon Sep 17 00:00:00 2001 From: Anthony Tecsa Date: Sat, 29 Mar 2025 19:11:52 -0400 Subject: [PATCH 06/10] toast complete --- src/components/AbsenceDetails.tsx | 25 ++++++++++++++ src/components/ClaimAbsenceToast.tsx | 49 ++++++++++++++++++++++++++++ src/components/InputForm.tsx | 6 ---- 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 src/components/ClaimAbsenceToast.tsx diff --git a/src/components/AbsenceDetails.tsx b/src/components/AbsenceDetails.tsx index df331934..b3321246 100644 --- a/src/components/AbsenceDetails.tsx +++ b/src/components/AbsenceDetails.tsx @@ -24,6 +24,7 @@ import { IoEyeOutline } from 'react-icons/io5'; import AbsenceStatusTag from './AbsenceStatusTag'; import LessonPlanView from './LessonPlanView'; import { Role } from '@utils/types'; +import ClaimAbsenceToast from './ClaimAbsenceToast'; const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { const theme = useTheme(); @@ -307,10 +308,34 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { !isUserAbsentTeacher && !isAdminMode && ( diff --git a/src/components/ClaimAbsenceToast.tsx b/src/components/ClaimAbsenceToast.tsx new file mode 100644 index 00000000..ce62d622 --- /dev/null +++ b/src/components/ClaimAbsenceToast.tsx @@ -0,0 +1,49 @@ +import { Box, Text, useTheme } from '@chakra-ui/react'; +import { MdCheckCircle, MdError } from 'react-icons/md'; + +const ClaimAbsenceToast = ({ firstName, date, success }) => { + const theme = useTheme(); + + const modalColor = success + ? theme.colors.positiveGreen[200] || 'green.200' + : theme.colors.errorRed[200] || 'red.200'; + + const message = success + ? `You have successfully claimed ` + : `There was an error in claiming `; + + const Icon = success ? MdCheckCircle : MdError; + + return ( + + + + + + {message} + + {firstName}'s + {' '} + absence on{' '} + + {date} + + . + + + ); +}; + +export default ClaimAbsenceToast; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index c974dd1b..68eba5f1 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -5,12 +5,6 @@ import { FormErrorMessage, FormLabel, Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, Text, Textarea, VStack, From d2a577082c45eea7d1c1ef3e30e6e2de3b0e936f Mon Sep 17 00:00:00 2001 From: Anthony Tecsa <97964832+anthonytecsa@users.noreply.github.com> Date: Sat, 29 Mar 2025 19:29:27 -0400 Subject: [PATCH 07/10] Update src/components/ConfirmAbsenceModal.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/ConfirmAbsenceModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ConfirmAbsenceModal.tsx b/src/components/ConfirmAbsenceModal.tsx index 199a8c62..38d3f022 100644 --- a/src/components/ConfirmAbsenceModal.tsx +++ b/src/components/ConfirmAbsenceModal.tsx @@ -127,7 +127,7 @@ export const ConfirmAbsenceModal: React.FC = ({ h="40px" borderRadius="lg" fontSize="16px" - font-weight="400" + fontWeight="400" > Confirm From f0cb8d5ebffbd30fab2806e5391d43fa2721a847 Mon Sep 17 00:00:00 2001 From: Chinemerem Date: Tue, 15 Apr 2025 18:05:32 -0400 Subject: [PATCH 08/10] Squash --- .env.sample | 3 +- .eslintrc.json | 14 - .github/workflows/cron-job.yml | 8 +- README.md | 43 +- app/api/colorGroups/route.ts | 15 + app/api/declareAbsence/route.ts | 49 +- app/api/editAbsence/route.ts | 80 ++ app/api/filter/locations/route.ts | 2 +- app/api/filter/subjects/route.ts | 2 +- app/api/getAbsenceDates/route.ts | 2 +- app/api/locations/[id]/route.ts | 24 +- app/api/locations/inUse/route.ts | 32 + app/api/locations/route.ts | 15 +- app/api/reminders/route.ts | 27 +- app/api/searchDrive/route.ts | 2 +- app/api/settings/route.ts | 2 +- app/api/subjects/[id]/route.ts | 22 +- app/api/subjects/inUse/route.ts | 32 + app/api/subjects/route.ts | 12 +- app/api/system/batch-update/route.ts | 145 +++ app/api/users/[id]/route.ts | 100 +- app/api/users/email/[email]/route.ts | 2 +- app/api/users/route.ts | 3 +- eslint.config.mjs | 25 +- hooks/useAbsences.ts | 8 +- hooks/useChangeManagement.ts | 459 +++++++++ hooks/useUserData.ts | 68 +- hooks/useUserFiltering.ts | 43 +- prisma/seed/seed.ts | 21 +- src/components/EditableRoleCell.tsx | 142 --- src/components/Footer.tsx | 5 - .../{SistemaLogoColour.tsx => TacetLogo.tsx} | 0 src/components/UserManagementCard.tsx | 136 --- src/components/{ => absences}/AbsenceBox.tsx | 26 +- src/components/{ => absences}/FileUpload.tsx | 67 +- .../{ => absences/details}/AbsenceDetails.tsx | 311 ++++-- .../absences/details/AbsenceFillThanks.tsx | 82 ++ .../details}/AbsenceStatusTag.tsx | 0 .../details}/ClaimAbsenceToast.tsx | 4 +- .../absences/details/EditableNotes.tsx | 169 ++++ .../{ => absences/details}/LessonPlanView.tsx | 149 ++- .../absences/modals/AdminTeacherFields.tsx | 85 ++ .../{ => absences/modals}/DateOfAbsence.tsx | 4 +- .../{ => absences/modals}/InputDropdown.tsx | 25 +- .../{ => absences/modals}/SearchDropdown.tsx | 124 +-- .../modals/declare/ConfirmDeclareModal.tsx} | 29 +- .../modals/declare/DeclareAbsenceForm.tsx} | 238 ++--- .../absences/modals/edit/ConfirmEditModal.tsx | 48 + .../absences/modals/edit/EditAbsenceForm.tsx | 314 ++++++ .../{ => calendar}/CalendarIcon.tsx | 0 .../{ => calendar}/CalendarTabs.tsx | 4 +- .../{ => calendar}/MiniCalendar.tsx | 0 .../sidebar/AbsenceStatusAccordion.tsx | 68 ++ .../{ => calendar/sidebar}/Accordion.tsx | 0 .../sidebar}/ArchivedAccordion.tsx | 12 +- .../sidebar}/CalendarSidebar.tsx | 26 +- .../sidebar}/LocationAccordion.tsx | 4 +- .../sidebar}/SubjectAccordion.tsx | 2 +- .../stats}/CircularProgress.tsx | 0 .../{ => dashboard/stats}/CustomTooltip.tsx | 0 .../{ => dashboard/stats}/DualColourBar.tsx | 0 .../stats}/MonthlyAbsencesCard.tsx | 12 +- .../stats}/TotalAbsencesCard.tsx | 20 +- .../dashboard/system_options/EntityTable.tsx | 897 ++++++++++++++++++ .../system_options/LocationsTable.tsx | 32 + .../system_options/SubjectsTable.tsx | 32 + .../SystemChangesConfirmationDialog.tsx | 420 ++++++++ .../system_options/SystemOptionsModal.tsx | 385 ++++++++ .../system_options/SystemSettings.tsx | 104 ++ .../system_options/UnsavedChangesDialog.tsx | 107 +++ .../user_management/EditableRoleCell.tsx | 220 +++++ .../EditableSubscriptionsCell.tsx | 381 ++++++++ .../user_management}/FilterPopup.tsx | 16 +- .../user_management}/OperatorMenu.tsx | 2 +- .../user_management/UserManagementCard.tsx | 245 +++++ .../user_management}/UserManagementTable.tsx | 130 +-- .../calendar}/AdminTeacherToggle.tsx | 0 .../{ => header/calendar}/CalendarHeader.tsx | 29 +- .../dashboard}/DashboardHeader.tsx | 62 +- .../dashboard}/ExportAbsencesButton.tsx | 25 +- .../{ => header/dashboard}/YearDropdown.tsx | 0 .../{ => header/profile}/ProfileMenu.tsx | 7 +- .../{ => header/profile}/SignOutButton.tsx | 0 src/pages/calendar.tsx | 107 +-- src/pages/dashboard.tsx | 53 +- src/pages/index.tsx | 4 +- theme/components.ts | 15 + types/next-auth.d.ts | 2 +- utils/getCalendarStyles.ts | 37 +- utils/getSelectedYearAbsences.ts | 21 + utils/submitAbsence.ts | 83 ++ utils/types.ts | 25 +- utils/uploadFile.ts | 18 + utils/validateAbsenceForm.ts | 34 + vercel.json | 8 + 95 files changed, 5813 insertions(+), 1054 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 app/api/colorGroups/route.ts create mode 100644 app/api/editAbsence/route.ts create mode 100644 app/api/locations/inUse/route.ts create mode 100644 app/api/subjects/inUse/route.ts create mode 100644 app/api/system/batch-update/route.ts create mode 100644 hooks/useChangeManagement.ts delete mode 100644 src/components/EditableRoleCell.tsx delete mode 100644 src/components/Footer.tsx rename src/components/{SistemaLogoColour.tsx => TacetLogo.tsx} (100%) delete mode 100644 src/components/UserManagementCard.tsx rename src/components/{ => absences}/AbsenceBox.tsx (86%) rename src/components/{ => absences}/FileUpload.tsx (60%) rename src/components/{ => absences/details}/AbsenceDetails.tsx (59%) create mode 100644 src/components/absences/details/AbsenceFillThanks.tsx rename src/components/{ => absences/details}/AbsenceStatusTag.tsx (100%) rename src/components/{ => absences/details}/ClaimAbsenceToast.tsx (91%) create mode 100644 src/components/absences/details/EditableNotes.tsx rename src/components/{ => absences/details}/LessonPlanView.tsx (53%) create mode 100644 src/components/absences/modals/AdminTeacherFields.tsx rename src/components/{ => absences/modals}/DateOfAbsence.tsx (95%) rename src/components/{ => absences/modals}/InputDropdown.tsx (86%) rename src/components/{ => absences/modals}/SearchDropdown.tsx (66%) rename src/components/{ConfirmAbsenceModal.tsx => absences/modals/declare/ConfirmDeclareModal.tsx} (82%) rename src/components/{InputForm.tsx => absences/modals/declare/DeclareAbsenceForm.tsx} (52%) create mode 100644 src/components/absences/modals/edit/ConfirmEditModal.tsx create mode 100644 src/components/absences/modals/edit/EditAbsenceForm.tsx rename src/components/{ => calendar}/CalendarIcon.tsx (100%) rename src/components/{ => calendar}/CalendarTabs.tsx (96%) rename src/components/{ => calendar}/MiniCalendar.tsx (100%) create mode 100644 src/components/calendar/sidebar/AbsenceStatusAccordion.tsx rename src/components/{ => calendar/sidebar}/Accordion.tsx (100%) rename src/components/{ => calendar/sidebar}/ArchivedAccordion.tsx (97%) rename src/components/{ => calendar/sidebar}/CalendarSidebar.tsx (84%) rename src/components/{ => calendar/sidebar}/LocationAccordion.tsx (96%) rename src/components/{ => calendar/sidebar}/SubjectAccordion.tsx (97%) rename src/components/{ => dashboard/stats}/CircularProgress.tsx (100%) rename src/components/{ => dashboard/stats}/CustomTooltip.tsx (100%) rename src/components/{ => dashboard/stats}/DualColourBar.tsx (100%) rename src/components/{ => dashboard/stats}/MonthlyAbsencesCard.tsx (95%) rename src/components/{ => dashboard/stats}/TotalAbsencesCard.tsx (90%) create mode 100644 src/components/dashboard/system_options/EntityTable.tsx create mode 100644 src/components/dashboard/system_options/LocationsTable.tsx create mode 100644 src/components/dashboard/system_options/SubjectsTable.tsx create mode 100644 src/components/dashboard/system_options/SystemChangesConfirmationDialog.tsx create mode 100644 src/components/dashboard/system_options/SystemOptionsModal.tsx create mode 100644 src/components/dashboard/system_options/SystemSettings.tsx create mode 100644 src/components/dashboard/system_options/UnsavedChangesDialog.tsx create mode 100644 src/components/dashboard/user_management/EditableRoleCell.tsx create mode 100644 src/components/dashboard/user_management/EditableSubscriptionsCell.tsx rename src/components/{ => dashboard/user_management}/FilterPopup.tsx (98%) rename src/components/{ => dashboard/user_management}/OperatorMenu.tsx (98%) create mode 100644 src/components/dashboard/user_management/UserManagementCard.tsx rename src/components/{ => dashboard/user_management}/UserManagementTable.tsx (72%) rename src/components/{ => header/calendar}/AdminTeacherToggle.tsx (100%) rename src/components/{ => header/calendar}/CalendarHeader.tsx (85%) rename src/components/{ => header/dashboard}/DashboardHeader.tsx (54%) rename src/components/{ => header/dashboard}/ExportAbsencesButton.tsx (74%) rename src/components/{ => header/dashboard}/YearDropdown.tsx (100%) rename src/components/{ => header/profile}/ProfileMenu.tsx (97%) rename src/components/{ => header/profile}/SignOutButton.tsx (100%) create mode 100644 utils/getSelectedYearAbsences.ts create mode 100644 utils/submitAbsence.ts create mode 100644 utils/uploadFile.ts create mode 100644 utils/validateAbsenceForm.ts create mode 100644 vercel.json diff --git a/.env.sample b/.env.sample index 4426b425..b5f7dba0 100644 --- a/.env.sample +++ b/.env.sample @@ -13,4 +13,5 @@ POSTGRES_DATABASE= POSTGRES_PASSWORD= POSTGRES_USER= SISTEMA_PASSWORD= -SISTEMA_EMAIL_DOMAIN= \ No newline at end of file +SISTEMA_EMAIL_DOMAIN= +CRON_SECRET= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 7a351534..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": ["next", "next/core-web-vitals", "plugin:prettier/recommended"], - "plugins": ["prettier", "@typescript-eslint"], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ], - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "error" - } -} diff --git a/.github/workflows/cron-job.yml b/.github/workflows/cron-job.yml index b5cd3e0f..d00f8917 100644 --- a/.github/workflows/cron-job.yml +++ b/.github/workflows/cron-job.yml @@ -2,7 +2,7 @@ name: Send Email Reminders for Missing Lessons on: schedule: - - cron: '0 0 * * *' # Runs every day at 00:00 UTC + - cron: '0 0 * * *' workflow_dispatch: jobs: @@ -10,7 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Log current time - run: date -u # Logs the current UTC time + run: date -u - name: Call reminders API - run: curl -X GET "${{ secrets.NEXT_PUBLIC_PROD_URL }}/api/reminders" + run: | + curl -X GET "${{ secrets.NEXT_PUBLIC_PROD_URL }}/api/reminders" \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" diff --git a/README.md b/README.md index a9bcd7e9..2f6cf6ba 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,25 @@ -# Sistema +# Sistema Tacet -## Setup +## Preview -- Make sure you have been added to the [UW Blueprint Github Workspace](https://github.com/uwblueprint/). -- Install Docker Desktop ([MacOS](https://docs.docker.com/docker-for-mac/install/) | [Windows](https://docs.docker.com/desktop/install/windows-install/) | [Linux](https://docs.docker.com/engine/install/#server)) and ensure that it is running. -- Install [Node.js](https://nodejs.org/) (v22 tested). It's recommended to use [NVM (Node Version Manager)](https://github.com/nvm-sh/nvm) to manage your Node.js versions. +- Absence Calendar + image - - [Node.js for MacOS](https://nodejs.org/en/download/) - - [Node.js for Windows](https://nodejs.org/en/download/) - - [Node.js for Linux](https://nodejs.org/en/download/package-manager/) +- Admin Dashboard + image + +## Prerequisites + +- If you intend to contribute to this project ensure you have been added to the [UW Blueprint Github Organization](https://github.com/uwblueprint/). +- Install [Node.js](https://nodejs.org/en/download/) (v22 tested). It's recommended to use [NVM (Node Version Manager)](https://github.com/nvm-sh/nvm) to manage your Node.js versions. +- Install Docker Desktop ([MacOS](https://docs.docker.com/desktop/setup/install/mac-install/) | [Windows](https://docs.docker.com/desktop/install/windows-install/) | [Linux](https://docs.docker.com/desktop/setup/install/linux/)) and ensure that it is running. +- + +## Clone and Install - Clone the [Sistema Github Repository](https://github.com/uwblueprint/sistema) to your local machine and `cd` into the project folder: @@ -34,12 +45,12 @@ npm install - Build and start the Docker containers ```bash -docker-compose up --build +docker compose up --build ``` ## Secrets -- Create A [HashiCorp Cloud Platform Account](https://portal.cloud.hashicorp.com/sign-in?ajs_aid=9085f07d-f411-42b4-855b-72795f4fdbcc&product_intent=vault) +- Create a [HashiCorp Cloud Platform Account](https://portal.cloud.hashicorp.com/sign-in?ajs_aid=9085f07d-f411-42b4-855b-72795f4fdbcc&product_intent=vault) - Make sure you have been added to the [Sistema HashiCorp Vault](https://github.com/uwblueprint/). - Install [HashiCorp Vault](https://developer.hashicorp.com/hcp/tutorials/get-started-hcp-vault-secrets/hcp-vault-secrets-install-cli#install-hcp-vault-secrets-cli) in order to pull secrets - In the folder where you cloned the Sistema repository, log into Vault @@ -82,22 +93,22 @@ Use the arrow keys to navigate: ↓ ↑ → ← ## Docker Commands -If you’re new to Docker, you can learn more about `docker-compose` commands at +If you’re new to Docker, you can learn more about `docker compose` commands at this [docker compose overview](https://docs.docker.com/compose/reference/). ```bash # Start Docker Containers -docker-compose up --build +docker compose up --build ``` ```bash # Stop Containers -docker-compose down +docker compose down ``` ```bash # Remove Containers, Networks, and Volumes: -docker-compose down --volumes +docker compose down --volumes ``` ```bash @@ -157,7 +168,7 @@ This will open an interactive shell inside the container. ## Accessing Database ```bash -# Open a Postgres shell in the sistema-db -1 Docker container and connect to the sistema database +# Open a Postgres shell in the sistema-db-1 Docker container and connect to the sistema database docker exec -it sistema-db-1 psql -U sistema -d sistema # Retrieve all rows from the "Absence" table SELECT * FROM public."Absence"; @@ -267,7 +278,7 @@ git push -f ``` - Commit messages and PR names are descriptive and written in **imperative tense**. The first word should be capitalized. E.g. "Create user REST endpoints", not "Created user REST endpoints" -- PRs can contain multiple commits, they do not need to be squashed together before merging as long as each commit is atomic. Our repo is configured to only allow squash commits to `main` so the entire PR will appear as 1 commit on `main`, but the individual commits are preserved when viewing the PR. +- PRs can contain multiple commits, they do not need to be squashed together before merging as long as each commit is atomic. ## Version Control Guide diff --git a/app/api/colorGroups/route.ts b/app/api/colorGroups/route.ts new file mode 100644 index 00000000..e9d7eeb0 --- /dev/null +++ b/app/api/colorGroups/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@utils/prisma'; + +export async function GET() { + try { + const colorGroups = await prisma.colorGroup.findMany(); + return NextResponse.json(colorGroups); + } catch (error) { + console.error('Error fetching color groups:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/declareAbsence/route.ts b/app/api/declareAbsence/route.ts index 48f64072..94f363d9 100644 --- a/app/api/declareAbsence/route.ts +++ b/app/api/declareAbsence/route.ts @@ -1,17 +1,28 @@ -import { NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextResponse } from 'next/server'; export async function POST(req: Request) { try { const body = await req.json(); - console.log(body); + + const { + lessonDate, + reasonOfAbsence, + notes, + absentTeacherId, + substituteTeacherId, + locationId, + subjectId, + roomNumber, + lessonPlanFile, + } = body; if ( - !body.lessonDate || - !body.reasonOfAbsence || - !body.absentTeacherId || - !body.locationId || - !body.subjectId + !lessonDate || + !reasonOfAbsence || + !absentTeacherId || + !locationId || + !subjectId ) { console.log('Missing required fields'); return NextResponse.json( @@ -23,12 +34,12 @@ export async function POST(req: Request) { const newAbsence = await prisma.$transaction(async (prisma) => { let lessonPlanId: number | null = null; - if (body.lessonPlanFile) { + if (lessonPlanFile) { const lessonPlan = await prisma.lessonPlanFile.create({ data: { - name: body.lessonPlanFile.name, - url: body.lessonPlanFile.url, - size: body.lessonPlanFile.size, + name: lessonPlanFile.name, + url: lessonPlanFile.url, + size: lessonPlanFile.size, }, }); lessonPlanId = lessonPlan.id; @@ -36,15 +47,15 @@ export async function POST(req: Request) { const absence = await prisma.absence.create({ data: { - lessonDate: new Date(body.lessonDate), + lessonDate: new Date(lessonDate), lessonPlanId, - reasonOfAbsence: body.reasonOfAbsence, - notes: body.notes || null, - absentTeacherId: body.absentTeacherId, - substituteTeacherId: body.substituteTeacherId || null, - locationId: body.locationId, - subjectId: body.subjectId, - roomNumber: body.roomNumber || null, + reasonOfAbsence: reasonOfAbsence, + notes: notes || null, + absentTeacherId: absentTeacherId, + substituteTeacherId: substituteTeacherId || null, + locationId: locationId, + subjectId: subjectId, + roomNumber: roomNumber || null, }, }); diff --git a/app/api/editAbsence/route.ts b/app/api/editAbsence/route.ts new file mode 100644 index 00000000..0fac7f06 --- /dev/null +++ b/app/api/editAbsence/route.ts @@ -0,0 +1,80 @@ +import { prisma } from '@utils/prisma'; +import { NextResponse } from 'next/server'; + +export async function PUT(req: Request) { + try { + const body = await req.json(); + const { id, lessonPlanFile, ...fields } = body; + + if (!id) { + return NextResponse.json( + { error: 'Missing absence ID' }, + { status: 400 } + ); + } + + const updatedAbsence = await prisma.$transaction(async (tx) => { + const existing = await tx.absence.findUnique({ + where: { id }, + include: { lessonPlan: true }, + }); + + if (!existing) { + throw new Error(`Absence with ID ${id} not found.`); + } + + const dataToUpdate: any = {}; + + if ('lessonDate' in fields) + dataToUpdate.lessonDate = new Date(fields.lessonDate); + if ('reasonOfAbsence' in fields) + dataToUpdate.reasonOfAbsence = fields.reasonOfAbsence; + if ('notes' in fields) dataToUpdate.notes = fields.notes || null; + if ('absentTeacherId' in fields) + dataToUpdate.absentTeacherId = fields.absentTeacherId; + if ('substituteTeacherId' in fields) + dataToUpdate.substituteTeacherId = fields.substituteTeacherId || null; + if ('locationId' in fields) dataToUpdate.locationId = fields.locationId; + if ('subjectId' in fields) dataToUpdate.subjectId = fields.subjectId; + if ('roomNumber' in fields) + dataToUpdate.roomNumber = fields.roomNumber || null; + + if (lessonPlanFile) { + if (existing.lessonPlanId) { + await tx.absence.update({ + where: { id }, + data: { lessonPlanId: null }, + }); + await tx.lessonPlanFile.delete({ + where: { id: existing.lessonPlanId }, + }); + } + + const newLessonPlan = await tx.lessonPlanFile.create({ + data: { + name: lessonPlanFile.name, + url: lessonPlanFile.url, + size: lessonPlanFile.size, + }, + }); + + dataToUpdate.lessonPlanId = newLessonPlan.id; + } + + const updated = await tx.absence.update({ + where: { id }, + data: dataToUpdate, + }); + + return updated; + }); + + return NextResponse.json(updatedAbsence, { status: 200 }); + } catch (err) { + console.error('Error in PUT /api/editAbsence:', err.message || err); + return NextResponse.json( + { error: 'Internal Server Error', details: err.message }, + { status: 500 } + ); + } +} diff --git a/app/api/filter/locations/route.ts b/app/api/filter/locations/route.ts index 0935ad0e..ebc3f5d4 100644 --- a/app/api/filter/locations/route.ts +++ b/app/api/filter/locations/route.ts @@ -1,6 +1,6 @@ import { prisma } from '@utils/prisma'; -import { NextRequest, NextResponse } from 'next/server'; import { Location } from '@utils/types'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { try { diff --git a/app/api/filter/subjects/route.ts b/app/api/filter/subjects/route.ts index 16ff4766..be4ecfd2 100644 --- a/app/api/filter/subjects/route.ts +++ b/app/api/filter/subjects/route.ts @@ -1,6 +1,6 @@ import { prisma } from '@utils/prisma'; -import { NextRequest, NextResponse } from 'next/server'; import { SubjectAPI } from '@utils/types'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { try { diff --git a/app/api/getAbsenceDates/route.ts b/app/api/getAbsenceDates/route.ts index 7f4be61b..d808f3ea 100644 --- a/app/api/getAbsenceDates/route.ts +++ b/app/api/getAbsenceDates/route.ts @@ -1,6 +1,6 @@ +import { YearlyAbsenceData } from '@utils/types'; import { NextResponse } from 'next/server'; import { getAbsenceYearRanges } from './absenceDates'; -import { YearlyAbsenceData } from '@utils/types'; export async function GET() { try { diff --git a/app/api/locations/[id]/route.ts b/app/api/locations/[id]/route.ts index 3ee096ea..36b7df93 100644 --- a/app/api/locations/[id]/route.ts +++ b/app/api/locations/[id]/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function PATCH( request: NextRequest, @@ -8,6 +8,13 @@ export async function PATCH( const params = await props.params; try { const id = parseInt(params.id); + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid location ID' }, + { status: 400 } + ); + } + const data = await request.json(); const { name, abbreviation, archived } = data; @@ -20,7 +27,9 @@ export async function PATCH( } catch (error) { console.error('Error updating location:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to update location.', + }, { status: 500 } ); } @@ -34,7 +43,10 @@ export async function DELETE( try { const id = parseInt(params.id, 10); if (isNaN(id)) { - return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + return NextResponse.json( + { error: 'Invalid location ID' }, + { status: 400 } + ); } // Check if the location is used in any absences @@ -47,7 +59,7 @@ export async function DELETE( return NextResponse.json( { error: - 'Cannot delete location because it is used in existing absences', + 'This location cannot be deleted because it is used in existing absences', }, { status: 409 } ); @@ -58,7 +70,9 @@ export async function DELETE( } catch (error) { console.error('Error deleting location:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to delete location.', + }, { status: 500 } ); } diff --git a/app/api/locations/inUse/route.ts b/app/api/locations/inUse/route.ts new file mode 100644 index 00000000..ad7d4610 --- /dev/null +++ b/app/api/locations/inUse/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@utils/prisma'; + +export async function GET() { + try { + // Find all locations that are used in any absences + const locationsInUse = await prisma.absence.findMany({ + select: { + locationId: true, + }, + distinct: ['locationId'], + where: { + locationId: { + not: undefined, + }, + }, + }); + + // Extract the location IDs and filter out any null values + const locationIds = locationsInUse + .map((absence) => absence.locationId) + .filter((id): id is number => id !== null && id !== undefined); + + return NextResponse.json({ locationsInUse: locationIds }); + } catch (error) { + console.error('Error checking locations in use:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/locations/route.ts b/app/api/locations/route.ts index b09747f3..2acd2cd5 100644 --- a/app/api/locations/route.ts +++ b/app/api/locations/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET() { try { @@ -18,21 +18,26 @@ export async function POST(request: NextRequest) { try { const { name, abbreviation } = (await request.json()) ?? {}; - if (!name || !abbreviation) { + if (!name) { return NextResponse.json( - { error: 'name and abbreviation are required' }, + { error: 'Location name is required' }, { status: 400 } ); } const location = await prisma.location.create({ - data: { name, abbreviation }, + data: { + name, + abbreviation: abbreviation || '', + }, }); return NextResponse.json(location); } catch (error) { console.error('Error creating location:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to create location.', + }, { status: 500 } ); } diff --git a/app/api/reminders/route.ts b/app/api/reminders/route.ts index 6b58affa..7b22e8ab 100644 --- a/app/api/reminders/route.ts +++ b/app/api/reminders/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; import { sendEmail } from '@utils/sendEmail'; +import { NextResponse } from 'next/server'; const UPLOAD_LINK = `${process.env.NEXT_PUBLIC_PROD_URL!}/calendar`; @@ -23,13 +23,16 @@ function getUTCDateWithoutTime(baseDate: Date, daysToAdd: number): Date { ); } -async function getAdminEmails() { +async function getAdminEmails(allowedDomain: string): Promise { try { const adminUsers = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { email: true }, }); - return adminUsers.map((user) => user.email); + + return adminUsers + .map((user) => user.email) + .filter((email) => email.endsWith(`@${allowedDomain}`)); } catch (error) { console.error('Error fetching admin emails:', error); return []; @@ -106,6 +109,7 @@ async function sendReminders( const today = new Date(); const targetDate = getUTCDateWithoutTime(today, daysBefore); const nextDay = new Date(targetDate.getTime() + 86400000); + const allowedDomain = process.env.SISTEMA_EMAIL_DOMAIN!; const users = await getUsersWithPendingLessonPlans(targetDate, nextDay); @@ -114,10 +118,17 @@ async function sendReminders( return 0; } - const adminEmails = await getAdminEmails(); + const adminEmails = await getAdminEmails(allowedDomain); const emailPromises = users.flatMap((user) => user.absences.map(async (absence) => { + if (!user.email.endsWith(`@${allowedDomain}`)) { + console.warn( + `Skipped email to ${user.email} due to domain restriction` + ); + return false; + } + const emailBody = createEmailBody(user, absence, isUrgent); const emailContent = { to: user.email, @@ -141,7 +152,13 @@ async function sendReminders( return successfulEmails; } -export async function GET() { +export async function GET(req: Request) { + if ( + req.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}` + ) { + return new Response('Unauthorized', { status: 401 }); + } + try { const [sevenDayCount, twoDayCount] = await Promise.all([ sendReminders(7, false), diff --git a/app/api/searchDrive/route.ts b/app/api/searchDrive/route.ts index a4d2c011..b4f1d061 100644 --- a/app/api/searchDrive/route.ts +++ b/app/api/searchDrive/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { google } from 'googleapis'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { const newHeaders = new Headers(req.headers); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 25c94a72..a4ad3405 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET() { try { diff --git a/app/api/subjects/[id]/route.ts b/app/api/subjects/[id]/route.ts index 4cc26b57..200095ca 100644 --- a/app/api/subjects/[id]/route.ts +++ b/app/api/subjects/[id]/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function PATCH( request: NextRequest, @@ -9,7 +9,10 @@ export async function PATCH( try { const id = parseInt(params.id); if (isNaN(id)) { - return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + return NextResponse.json( + { error: 'Invalid subject ID' }, + { status: 400 } + ); } const data = await request.json(); @@ -27,7 +30,9 @@ export async function PATCH( } catch (error) { console.error('Error updating subject:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to update subject.', + }, { status: 500 } ); } @@ -41,7 +46,10 @@ export async function DELETE( try { const id = parseInt(params.id); if (isNaN(id)) { - return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + return NextResponse.json( + { error: 'Invalid subject ID' }, + { status: 400 } + ); } // Check if the subject is used in any absences @@ -54,7 +62,7 @@ export async function DELETE( return NextResponse.json( { error: - 'Cannot delete subject because it is used in existing absences', + 'This subject cannot be deleted because it is used in existing absences', }, { status: 409 } ); @@ -65,7 +73,9 @@ export async function DELETE( } catch (error) { console.error('Error deleting subject:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to delete subject.', + }, { status: 500 } ); } diff --git a/app/api/subjects/inUse/route.ts b/app/api/subjects/inUse/route.ts new file mode 100644 index 00000000..4dd28a5d --- /dev/null +++ b/app/api/subjects/inUse/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@utils/prisma'; + +export async function GET() { + try { + // Find all subjects that are used in any absences + const subjectsInUse = await prisma.absence.findMany({ + select: { + subjectId: true, + }, + distinct: ['subjectId'], + where: { + subjectId: { + not: undefined, + }, + }, + }); + + // Extract the subject IDs and filter out any null values + const subjectIds = subjectsInUse + .map((absence) => absence.subjectId) + .filter((id): id is number => id !== null && id !== undefined); + + return NextResponse.json({ subjectsInUse: subjectIds }); + } catch (error) { + console.error('Error checking subjects in use:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/subjects/route.ts b/app/api/subjects/route.ts index b0bb5688..88d49bbc 100644 --- a/app/api/subjects/route.ts +++ b/app/api/subjects/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET() { try { @@ -21,9 +21,9 @@ export async function GET() { export async function POST(request: NextRequest) { try { const { name, abbreviation, colorGroupId } = await request.json(); - if (!name || !abbreviation || !colorGroupId) { + if (!name || colorGroupId === undefined) { return NextResponse.json( - { error: 'name, abbreviation, and colorGroupId are required' }, + { error: 'Subject name and color group are required' }, { status: 400 } ); } @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { const subject = await prisma.subject.create({ data: { name, - abbreviation, + abbreviation: abbreviation || '', colorGroupId, }, include: { @@ -43,7 +43,9 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('Error creating subject:', error); return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Unable to create subject.', + }, { status: 500 } ); } diff --git a/app/api/system/batch-update/route.ts b/app/api/system/batch-update/route.ts new file mode 100644 index 00000000..54003b67 --- /dev/null +++ b/app/api/system/batch-update/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@utils/prisma'; + +export async function POST(req: NextRequest) { + try { + // Parse the request body + const body = await req.json(); + const { subjects, locations, settings } = body; + + // Validate request data + if (!subjects && !locations && !settings) { + return NextResponse.json( + { error: 'No changes provided' }, + { status: 400 } + ); + } + + // Use prisma transaction to ensure atomicity + const result = await prisma.$transaction(async (tx) => { + // Process subject changes + if (subjects) { + // Create new subjects + if (subjects.create && subjects.create.length > 0) { + for (const subject of subjects.create) { + await tx.subject.create({ + data: { + name: subject.name, + abbreviation: subject.abbreviation, + colorGroupId: subject.colorGroupId, + }, + }); + } + } + + // Update existing subjects + if (subjects.update && subjects.update.length > 0) { + for (const { id, changes } of subjects.update) { + await tx.subject.update({ + where: { id }, + data: changes, + }); + } + } + + // Delete subjects + if (subjects.delete && subjects.delete.length > 0) { + for (const id of subjects.delete) { + // Check if subject is in use + const absenceCount = await tx.absence.count({ + where: { subjectId: id }, + }); + + if (absenceCount > 0) { + throw new Error( + `Cannot delete subject with ID ${id} as it is used in ${absenceCount} absences` + ); + } + + await tx.subject.delete({ + where: { id }, + }); + } + } + } + + // Process location changes + if (locations) { + // Create new locations + if (locations.create && locations.create.length > 0) { + for (const location of locations.create) { + await tx.location.create({ + data: { + name: location.name, + abbreviation: location.abbreviation, + }, + }); + } + } + + // Update existing locations + if (locations.update && locations.update.length > 0) { + for (const { id, changes } of locations.update) { + await tx.location.update({ + where: { id }, + data: changes, + }); + } + } + + // Delete locations + if (locations.delete && locations.delete.length > 0) { + for (const id of locations.delete) { + // Check if location is in use + const absenceCount = await tx.absence.count({ + where: { locationId: id }, + }); + + if (absenceCount > 0) { + throw new Error( + `Cannot delete location with ID ${id} as it is used in ${absenceCount} absences` + ); + } + + await tx.location.delete({ + where: { id }, + }); + } + } + } + + // Process settings changes + if (settings && settings.absenceCap !== undefined) { + // Update the global absence cap setting + const existingSettings = await tx.globalSettings.findFirst(); + + if (existingSettings) { + // Update existing settings + await tx.globalSettings.update({ + where: { id: existingSettings.id }, + data: { absenceCap: settings.absenceCap }, + }); + } else { + // Create new settings if none exist + await tx.globalSettings.create({ + data: { absenceCap: settings.absenceCap }, + }); + } + } + + return { success: true }; + }); + + return NextResponse.json(result); + } catch (error) { + console.error('Error in batch update:', error); + + // Extract readable error message + let errorMessage = 'Internal server error'; + if (error instanceof Error) { + errorMessage = error.message; + } + + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 6d5aac75..3b2f733c 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; function validateId(id: string): number | null { const parsedId = Number(id); @@ -18,11 +18,30 @@ export async function GET( const searchParams = request.nextUrl.searchParams; const getAbsences = searchParams.get('getAbsences') === 'true'; + const getMailingLists = searchParams.get('getMailingLists') === 'true'; try { const user = await prisma.user.findUnique({ where: { id: validatedId }, - include: { absences: getAbsences }, + include: { + absences: getAbsences, + mailingLists: getMailingLists + ? { + include: { + subject: { + select: { + id: true, + name: true, + abbreviation: true, + colorGroup: { + select: { colorCodes: true }, + }, + }, + }, + }, + } + : false, + }, }); if (!user) { @@ -50,12 +69,79 @@ export async function PATCH( } try { - const { email, firstName, lastName, role } = await request.json(); + const { email, firstName, lastName, role, mailingListSubjectIds } = + await request.json(); - const updatedUser = await prisma.user.update({ - where: { id: validatedId }, - data: { email, firstName, lastName, role }, - }); + // Update basic user data if provided + const userData: any = {}; + if (email !== undefined) userData.email = email; + if (firstName !== undefined) userData.firstName = firstName; + if (lastName !== undefined) userData.lastName = lastName; + if (role !== undefined) userData.role = role; + + let updatedUser; + + // If mailingListSubjectIds is provided, we need to update the mailing lists + if (mailingListSubjectIds !== undefined) { + if (!Array.isArray(mailingListSubjectIds)) { + return NextResponse.json( + { error: 'mailingListSubjectIds must be an array' }, + { status: 400 } + ); + } + + // First update the user data + updatedUser = await prisma.user.update({ + where: { id: validatedId }, + data: userData, + }); + + // Then delete all existing mailing lists for this user + await prisma.mailingList.deleteMany({ + where: { userId: validatedId }, + }); + + // Then create new entries for each subject ID + if (mailingListSubjectIds.length > 0) { + const mailingListData = mailingListSubjectIds.map((subjectId) => ({ + userId: validatedId, + subjectId, + })); + + await prisma.mailingList.createMany({ + data: mailingListData, + }); + } + + // Return the updated user with mailing lists + updatedUser = await prisma.user.findUnique({ + where: { id: validatedId }, + include: { + mailingLists: { + include: { + subject: { + select: { + id: true, + name: true, + abbreviation: true, + colorGroup: { + select: { + colorCodes: true, + }, + }, + }, + }, + }, + }, + }, + }); + } else { + // Just update user data without touching mailing lists + updatedUser = await prisma.user.update({ + where: { id: validatedId }, + data: userData, + }); + } return NextResponse.json(updatedUser); } catch (error) { diff --git a/app/api/users/email/[email]/route.ts b/app/api/users/email/[email]/route.ts index 22b8060d..d3301254 100644 --- a/app/api/users/email/[email]/route.ts +++ b/app/api/users/email/[email]/route.ts @@ -1,6 +1,6 @@ -import { NextRequest, NextResponse } from 'next/server'; import { Prisma } from '@prisma/client'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; async function getRandomUser() { const user = await prisma.user.findFirstOrThrow({ diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 2092af13..b8f5ef72 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@utils/prisma'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; @@ -16,6 +16,7 @@ export async function GET(request: NextRequest) { select: { name: true, abbreviation: true, + archived: true, colorGroup: { select: { colorCodes: true }, }, diff --git a/eslint.config.mjs b/eslint.config.mjs index 528cfc8b..a74218ac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import { FlatCompat } from '@eslint/eslintrc'; import js from '@eslint/js'; -import prettier from 'eslint-plugin-prettier'; +import tseslintPlugin from '@typescript-eslint/eslint-plugin'; +import tseslintParser from '@typescript-eslint/parser'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,17 +10,11 @@ const __dirname = path.dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, - allConfig: js.configs.all, }); const eslintConfig = [ ...compat.config({ extends: ['next', 'next/core-web-vitals', 'plugin:prettier/recommended'], - settings: { - plugins: { - prettier, - }, - }, rules: { 'prettier/prettier': [ 'error', @@ -31,6 +26,22 @@ const eslintConfig = [ }, ignorePatterns: ['.next/'], }), + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tseslintParser, + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, + }, + plugins: { + '@typescript-eslint': tseslintPlugin, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error'], + }, + }, ]; export default eslintConfig; diff --git a/hooks/useAbsences.ts b/hooks/useAbsences.ts index 6ffa1d3c..ae2cdb85 100644 --- a/hooks/useAbsences.ts +++ b/hooks/useAbsences.ts @@ -33,7 +33,7 @@ const convertAbsenceToEvent = (absenceData: AbsenceAPI): EventInput => ({ absenceId: absenceData.id, }); -export const useAbsences = () => { +export const useAbsences = (refetchUserData?: () => void) => { const [events, setEvents] = useState([]); const toast = useToast(); @@ -46,6 +46,10 @@ export const useAbsences = () => { const data = await res.json(); const formattedEvents = data.events.map(convertAbsenceToEvent); setEvents(formattedEvents); + + if (refetchUserData) { + refetchUserData(); + } } catch (error) { console.error('Error fetching absences:', error); toast({ @@ -57,7 +61,7 @@ export const useAbsences = () => { isClosable: true, }); } - }, [toast]); + }, [refetchUserData, toast]); useEffect(() => { fetchAbsences(); diff --git a/hooks/useChangeManagement.ts b/hooks/useChangeManagement.ts new file mode 100644 index 00000000..795454a3 --- /dev/null +++ b/hooks/useChangeManagement.ts @@ -0,0 +1,459 @@ +import { Location, SubjectAPI } from '@utils/types'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * This hook manages pending changes to system entities using an entity-based tracking approach. + * Instead of tracking individual atomic changes, it stores the final state of each modified entity. + * + * Key features: + * - Stores maps of entities (by ID) for subjects, locations, and settings + * - Uses negative IDs for new entities + * - Represents deleted entities with null values + * - Simple update process: just update the entity in the appropriate map + * - Generates change display information by comparing original and current entities + */ + +interface UseChangeManagementProps { + subjects: SubjectAPI[]; + locations: Location[]; + absenceCap: number; + onRefresh?: () => void; + toast?: any; +} + +interface UseChangeManagementReturn { + pendingEntities: { + subjects: Map; + locations: Map; + settings: { absenceCap?: number }; + }; + handleUpdateSubject: (subject: SubjectAPI | null, id?: number) => void; + handleUpdateLocation: (location: Location | null, id?: number) => void; + handleUpdateAbsenceCap: (absenceCap: number) => void; + applyChanges: () => Promise; + clearChanges: () => void; + updatedSubjects: SubjectAPI[]; + updatedLocations: Location[]; + updatedAbsenceCap: number; +} + +export const useChangeManagement = ({ + subjects: initialSubjects, + locations: initialLocations, + absenceCap: initialAbsenceCap, + onRefresh, + toast, +}: UseChangeManagementProps): UseChangeManagementReturn => { + // Store maps of pending entity changes + const [pendingSubjects, setPendingSubjects] = useState< + Map + >(new Map()); + const [pendingLocations, setPendingLocations] = useState< + Map + >(new Map()); + const [pendingSettings, setPendingSettings] = useState<{ + absenceCap?: number; + }>({}); + + // Store local computed versions of entities with changes applied + const [updatedSubjectsList, setUpdatedSubjectsList] = useState([ + ...initialSubjects, + ]); + const [updatedLocationsList, setUpdatedLocationsList] = useState([ + ...initialLocations, + ]); + const [updatedAbsenceCap, setUpdatedAbsenceCap] = + useState(initialAbsenceCap); + + // Generate a unique negative ID for new entities + const generateTempId = useCallback(() => { + return -Date.now(); + }, []); + + // Reset local state when props change (e.g. after a refresh) + useEffect(() => { + setUpdatedSubjectsList([...initialSubjects]); + setUpdatedLocationsList([...initialLocations]); + setUpdatedAbsenceCap(initialAbsenceCap); + // Don't reset the pending changes here to preserve during refresh + }, [initialSubjects, initialLocations, initialAbsenceCap]); + + /** + * Handle updates to a subject + * @param subject The updated subject or null if deleted + */ + const handleUpdateSubject = useCallback( + (subject: SubjectAPI | null, id?: number) => { + setPendingSubjects((prev) => { + const newMap = new Map(prev); + if (subject) { + // If this is a new subject without an ID, generate a temporary negative ID + const subjectId = subject.id || generateTempId(); + + // Check if the subject is being reverted to its original state + if (subjectId > 0) { + // Only check for existing subjects (positive IDs) + const originalSubject = initialSubjects.find( + (s) => s.id === subjectId + ); + if ( + originalSubject && + originalSubject.name === subject.name && + originalSubject.abbreviation === subject.abbreviation && + originalSubject.colorGroupId === subject.colorGroupId && + originalSubject.archived === subject.archived + ) { + // If equal to original, remove from pending changes instead of adding + newMap.delete(subjectId); + return newMap; + } + } + + newMap.set(subjectId, { ...subject, id: subjectId }); + } else if (subject === null && id !== undefined) { + // Delete case + if (id < 0) { + // If deleting a newly added entity (negative ID), remove it from pending changes completely + newMap.delete(id); + } else { + // If deleting an existing entity (positive ID), mark for deletion + newMap.set(id, null); + } + } + return newMap; + }); + }, + [generateTempId, initialSubjects] + ); + + /** + * Handle updates to a location + * @param location The updated location or null if deleted + */ + const handleUpdateLocation = useCallback( + (location: Location | null, id?: number) => { + setPendingLocations((prev) => { + const newMap = new Map(prev); + if (location) { + // If this is a new location without an ID, generate a temporary negative ID + const locationId = location.id || generateTempId(); + + // Check if the location is being reverted to its original state + if (locationId > 0) { + // Only check for existing locations (positive IDs) + const originalLocation = initialLocations.find( + (l) => l.id === locationId + ); + if ( + originalLocation && + originalLocation.name === location.name && + originalLocation.abbreviation === location.abbreviation && + originalLocation.archived === location.archived + ) { + // If equal to original, remove from pending changes instead of adding + newMap.delete(locationId); + return newMap; + } + } + + newMap.set(locationId, { ...location, id: locationId }); + } else if (location === null && id !== undefined) { + // Delete case + if (id < 0) { + // If deleting a newly added entity (negative ID), remove it from pending changes completely + newMap.delete(id); + } else { + // If deleting an existing entity (positive ID), mark for deletion + newMap.set(id, null); + } + } + return newMap; + }); + }, + [generateTempId, initialLocations] + ); + + /** + * Handle updates to absence cap setting + */ + const handleUpdateAbsenceCap = useCallback( + (newAbsenceCap: number) => { + setPendingSettings((prev) => { + // If new absence cap equals original, remove it from pending settings + if (newAbsenceCap === initialAbsenceCap) { + const { ...rest } = prev; + return rest; + } + + return { + ...prev, + absenceCap: newAbsenceCap, + }; + }); + }, + [initialAbsenceCap] + ); + + // Update local state based on pending entity changes + useEffect(() => { + // Process subject changes + const newSubjectsList = [...initialSubjects]; + + // Add/update pending subjects + pendingSubjects.forEach((pendingSubject, id) => { + if (pendingSubject === null) { + // Remove deleted subjects + const index = newSubjectsList.findIndex((s) => s.id === id); + if (index !== -1) { + newSubjectsList.splice(index, 1); + } + } else if (id < 0) { + // Add new subjects with negative IDs + newSubjectsList.push(pendingSubject); + } else { + // Update existing subjects + const index = newSubjectsList.findIndex((s) => s.id === id); + if (index !== -1) { + newSubjectsList[index] = pendingSubject; + } + } + }); + + setUpdatedSubjectsList(newSubjectsList); + + // Process location changes + const newLocationsList = [...initialLocations]; + + // Add/update pending locations + pendingLocations.forEach((pendingLocation, id) => { + if (pendingLocation === null) { + // Remove deleted locations + const index = newLocationsList.findIndex((l) => l.id === id); + if (index !== -1) { + newLocationsList.splice(index, 1); + } + } else if (id < 0) { + // Add new locations with negative IDs + newLocationsList.push(pendingLocation); + } else { + // Update existing locations + const index = newLocationsList.findIndex((l) => l.id === id); + if (index !== -1) { + newLocationsList[index] = pendingLocation; + } + } + }); + + setUpdatedLocationsList(newLocationsList); + + // Process settings changes + if (pendingSettings.absenceCap !== undefined) { + setUpdatedAbsenceCap(pendingSettings.absenceCap); + } + }, [ + pendingSubjects, + pendingLocations, + pendingSettings, + initialSubjects, + initialLocations, + ]); + + /** + * Applies all pending changes to the backend via a single API call + * Implements an "all or nothing" approach where either all changes are applied + * or none are (transaction-like behavior) + */ + const applyChanges = async (): Promise => { + let hasChanges = + pendingSubjects.size > 0 || + pendingLocations.size > 0 || + Object.keys(pendingSettings).length > 0; + if (!hasChanges) return true; + + let success = true; + let errorMessage = ''; + + try { + // Prepare subject changes + const subjectChanges = { + create: [] as Partial[], + update: [] as { id: number; changes: Partial }[], + delete: [] as number[], + }; + + // Process subject changes + for (const [id, subject] of Array.from(pendingSubjects.entries())) { + if (subject === null) { + // Handle deletion + subjectChanges.delete.push(id); + } else if (id < 0) { + // Handle new subject + subjectChanges.create.push({ + name: subject.name, + abbreviation: subject.abbreviation, + colorGroupId: subject.colorGroupId, + }); + } else { + // Handle update + const originalSubject = initialSubjects.find((s) => s.id === id); + if (!originalSubject) continue; + + const updates: Partial = {}; + if (subject.name !== originalSubject.name) { + updates.name = subject.name; + } + if (subject.abbreviation !== originalSubject.abbreviation) { + updates.abbreviation = subject.abbreviation; + } + if (subject.archived !== originalSubject.archived) { + updates.archived = subject.archived; + } + if (subject.colorGroupId !== originalSubject.colorGroupId) { + updates.colorGroupId = subject.colorGroupId; + } + + // Only include if there are actual changes + if (Object.keys(updates).length > 0) { + subjectChanges.update.push({ id, changes: updates }); + } + } + } + + // Prepare location changes + const locationChanges = { + create: [] as Partial[], + update: [] as { id: number; changes: Partial }[], + delete: [] as number[], + }; + + // Process location changes + for (const [id, location] of Array.from(pendingLocations.entries())) { + if (location === null) { + // Handle deletion + locationChanges.delete.push(id); + } else if (id < 0) { + // Handle new location + locationChanges.create.push({ + name: location.name, + abbreviation: location.abbreviation, + }); + } else { + // Handle update + const originalLocation = initialLocations.find((l) => l.id === id); + if (!originalLocation) continue; + + const updates: Partial = {}; + if (location.name !== originalLocation.name) { + updates.name = location.name; + } + if (location.abbreviation !== originalLocation.abbreviation) { + updates.abbreviation = location.abbreviation; + } + if (location.archived !== originalLocation.archived) { + updates.archived = location.archived; + } + + // Only include if there are actual changes + if (Object.keys(updates).length > 0) { + locationChanges.update.push({ id, changes: updates }); + } + } + } + + // Prepare settings changes + const settingsChanges = { + absenceCap: + pendingSettings.absenceCap !== undefined && + pendingSettings.absenceCap !== initialAbsenceCap + ? pendingSettings.absenceCap + : undefined, + }; + + // Send all changes in a single transaction-like request + const response = await fetch('/api/system/batch-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subjects: subjectChanges, + locations: locationChanges, + settings: settingsChanges, + }), + }); + + if (!response.ok) { + success = false; + try { + const errorData = await response.json(); + errorMessage = errorData.error || 'Failed to save changes'; + } catch { + errorMessage = 'Failed to save changes'; + } + console.error('Error applying changes:', errorMessage); + } else { + // Refresh data after all changes have been applied + if (onRefresh) { + onRefresh(); + } + + // Show success toast + if (toast) { + toast({ + title: 'Changes saved', + status: 'success', + duration: 3000, + isClosable: true, + }); + } + + // Clear pending changes after successful application + clearChanges(); + } + } catch (error) { + console.error('Error applying changes:', error); + success = false; + errorMessage = + error instanceof Error ? error.message : 'Failed to save changes'; + } + + // Show error toast if there was a problem + if (!success && toast) { + toast({ + title: 'Error', + description: errorMessage || 'Failed to save changes', + status: 'error', + duration: 5000, + isClosable: true, + }); + } + + return success; + }; + + /** + * Clears all pending changes + */ + const clearChanges = useCallback(() => { + setPendingSubjects(new Map()); + setPendingLocations(new Map()); + setPendingSettings({}); + }, []); + + // Bundle all pending entity maps for easier access + const pendingEntities = { + subjects: pendingSubjects, + locations: pendingLocations, + settings: pendingSettings, + }; + + return { + pendingEntities, + handleUpdateSubject, + handleUpdateLocation, + handleUpdateAbsenceCap, + applyChanges, + clearChanges, + updatedSubjects: updatedSubjectsList, + updatedLocations: updatedLocationsList, + updatedAbsenceCap, + }; +}; diff --git a/hooks/useUserData.ts b/hooks/useUserData.ts index 761fd94f..e1a63c2a 100644 --- a/hooks/useUserData.ts +++ b/hooks/useUserData.ts @@ -1,13 +1,16 @@ import { Role, UserData } from '@utils/types'; import { useSession } from 'next-auth/react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { getSelectedYearAbsences } from '@utils/getSelectedYearAbsences'; interface UseUserDataReturn extends UserData { isAuthenticated: boolean; isLoading: boolean; } -export const useUserData = (): UseUserDataReturn => { +export const useUserData = (): UseUserDataReturn & { + refetchUserData: () => void; +} => { const { data: session, status } = useSession(); const [fetchedUserData, setFetchedUserData] = useState { }>(null); const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - const fetchUser = async () => { - if (status === 'authenticated' && session?.user?.id) { - try { - const res = await fetch( - `/api/users/${session.user.id}?getAbsences=true` - ); - if (!res.ok) throw new Error('Failed to fetch user'); - const user = await res.json(); + const fetchUser = useCallback(async () => { + if (status === 'authenticated' && session?.user?.id) { + try { + const res = await fetch( + `/api/users/${session.user.id}?getAbsences=true` + ); + if (!res.ok) throw new Error('Failed to fetch user'); + const user = await res.json(); + + const today = new Date(); + const currentYear = + today.getMonth() >= 8 ? today.getFullYear() : today.getFullYear() - 1; + const selectedYearRange = `${currentYear} - ${currentYear + 1}`; - setFetchedUserData({ - id: user.id, - name: `${user.firstName} ${user.lastName}`, - email: user.email, - image: user.profilePicture ?? undefined, - role: user.role as Role, - usedAbsences: user.absences.length, - }); - } catch (error) { - console.error('Failed to fetch user data:', error); - } finally { - setIsLoading(false); - } - } else if (status !== 'loading') { + setFetchedUserData({ + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + image: user.profilePicture ?? undefined, + role: user.role as Role, + usedAbsences: getSelectedYearAbsences( + user.absences, + selectedYearRange + ), + }); + } catch (error) { + console.error('Failed to fetch user data:', error); + } finally { setIsLoading(false); } - }; + } else if (status !== 'loading') { + setIsLoading(false); + } + }, [session?.user?.id, status]); + useEffect(() => { fetchUser(); - }, [status, session?.user?.id]); + }, [fetchUser]); return { id: fetchedUserData?.id ?? 0, name: fetchedUserData?.name ?? '', email: fetchedUserData?.email ?? '', image: fetchedUserData?.image, - role: fetchedUserData?.role ?? (Role.TEACHER as Role), + role: fetchedUserData?.role ?? Role.TEACHER, usedAbsences: fetchedUserData?.usedAbsences ?? 0, isAuthenticated: status === 'authenticated', isLoading, + refetchUserData: fetchUser, }; }; diff --git a/hooks/useUserFiltering.ts b/hooks/useUserFiltering.ts index 80272206..2bba4f2f 100644 --- a/hooks/useUserFiltering.ts +++ b/hooks/useUserFiltering.ts @@ -1,31 +1,26 @@ import { FilterOptions, UserAPI } from '@utils/types'; import { useMemo } from 'react'; -import { NO_EMAIL_TAGS } from '../src/components/FilterPopup'; +import { NO_EMAIL_TAGS } from '../src/components/dashboard/user_management/FilterPopup'; + const useUserFiltering = ( users: UserAPI[], filters: FilterOptions, searchTerm: string, sortField: string, - sortDirection: 'asc' | 'desc' + sortDirection: 'asc' | 'desc', + getSelectedYearAbsences: (absences?: any[]) => number ) => { const filteredUsers = useMemo(() => { - // We need to know how many tags exist in total - const allAvailableTags = new Set(); - users.forEach((user) => { - user.mailingLists?.forEach((list) => { - allAvailableTags.add(list.subject.name); - }); - }); - const totalTagCount = allAvailableTags.size; - const areAllTagsDisabled = filters.disabledTags?.length === totalTagCount; - return users.filter((user: UserAPI) => { const { role, absencesOperator, absencesValue, disabledTags } = filters; if (searchTerm) { const fullName = `${user.firstName} ${user.lastName}`.toLowerCase(); const searchLower = searchTerm.toLowerCase(); - if (!fullName.includes(searchLower)) { + if ( + !fullName.includes(searchLower) && + !user.email.toLowerCase().includes(searchLower) + ) { return false; } } @@ -34,18 +29,18 @@ const useUserFiltering = ( return false; } - if (absencesValue !== null && absencesValue !== undefined) { - const userAbsences = user.absences?.length || 0; + const filteredAbsences = getSelectedYearAbsences(user.absences); + if (absencesValue !== null && absencesValue !== undefined) { switch (absencesOperator) { case 'greater_than': - if (userAbsences <= absencesValue) return false; + if (filteredAbsences <= absencesValue) return false; break; case 'less_than': - if (userAbsences >= absencesValue) return false; + if (filteredAbsences >= absencesValue) return false; break; case 'equal_to': - if (userAbsences !== absencesValue) return false; + if (filteredAbsences !== absencesValue) return false; break; } } @@ -54,19 +49,17 @@ const useUserFiltering = ( const userTags = user.mailingLists?.map((list) => list.subject.name) || []; - // Check if user has no subscriptions if (userTags.length === 0) { - // Only show users with no subscriptions if the "No Email Tags" option is enabled return !disabledTags.includes(NO_EMAIL_TAGS); } - // For users with subscriptions, check if they have any enabled tag + // Include users who have at least one allowed tag return userTags.some((tag) => !disabledTags.includes(tag)); } return true; }); - }, [users, filters, searchTerm]); + }, [users, filters, searchTerm, getSelectedYearAbsences]); const sortedUsers = useMemo(() => { return [...filteredUsers].sort((a, b) => { @@ -82,14 +75,16 @@ const useUserFiltering = ( case 'email': return a.email.localeCompare(b.email) * modifier; case 'absences': - return (a.absences.length - b.absences.length) * modifier; + const aCount = getSelectedYearAbsences(a.absences); + const bCount = getSelectedYearAbsences(b.absences); + return (aCount - bCount) * modifier; case 'role': return a.role.localeCompare(b.role) * modifier; default: return 0; } }); - }, [filteredUsers, sortField, sortDirection]); + }, [filteredUsers, sortField, sortDirection, getSelectedYearAbsences]); return { sortedUsers }; }; diff --git a/prisma/seed/seed.ts b/prisma/seed/seed.ts index 95983c0c..dc3302a8 100644 --- a/prisma/seed/seed.ts +++ b/prisma/seed/seed.ts @@ -69,10 +69,10 @@ const main = async () => { ); const schools = [ - { name: 'Lambton Park Community School', abbreviation: 'LP' }, - { name: 'St Martin de Porres Catholic School', abbreviation: 'SC' }, - { name: 'Yorkwoods Public School', abbreviation: 'YW' }, - { name: 'Parkdale Junior Senior Public School', abbreviation: 'PD' }, + { name: 'Lambton Park Community School', abbreviation: 'Lambton' }, + { name: 'St Martin de Porres Catholic School', abbreviation: 'St Martin' }, + { name: 'Yorkwoods Public School', abbreviation: 'Yorkwoods' }, + { name: 'Parkdale Junior Senior Public School', abbreviation: 'Parkdale' }, { name: 'St Gertrude Elementary School', abbreviation: 'SG', @@ -151,11 +151,20 @@ const main = async () => { })) ); + const isESTWeekday = (date: Date): boolean => { + const weekdayInEST = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + weekday: 'short', + }).format(date); + + return !['Sat', 'Sun'].includes(weekdayInEST); + }; + const generateWeekdayFutureDate = (): Date => { let date: Date; do { date = faker.date.future({ years: 2 }); - } while (date.getDay() === 0 || date.getDay() === 6); + } while (!isESTWeekday(date)); return date; }; @@ -163,7 +172,7 @@ const main = async () => { let date: Date; do { date = faker.date.past({ years: 2 }); - } while (date.getDay() === 0 || date.getDay() === 6); + } while (!isESTWeekday(date)); return date; }; diff --git a/src/components/EditableRoleCell.tsx b/src/components/EditableRoleCell.tsx deleted file mode 100644 index 115e27cb..00000000 --- a/src/components/EditableRoleCell.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Box, Button, Icon, Text, useTheme } from '@chakra-ui/react'; -import React, { useState } from 'react'; -import { FiChevronDown, FiChevronUp, FiEdit2 } from 'react-icons/fi'; -import { IoCheckmark, IoCloseOutline } from 'react-icons/io5'; - -type EditableRoleCellProps = { - role: string; - onRoleChange: (newRole: string) => void; -}; - -const EditableRoleCell = ({ role, onRoleChange }: EditableRoleCellProps) => { - const [isEditing, setIsEditing] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [newRole, setNewRole] = useState(role); - const [isHovered, setIsHovered] = useState(false); - const theme = useTheme(); - - const handleEditClick = () => { - setIsEditing(true); - setIsDropdownOpen(false); - setIsHovered(false); - }; - - const toggleDropdown = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsDropdownOpen((prev) => !prev); - }; - - const handleRoleChange = (selectedRole: string) => { - setNewRole(selectedRole); - setIsDropdownOpen(false); - }; - - const handleConfirmClick = () => { - onRoleChange(newRole); - setIsEditing(false); - }; - - const handleCancelClick = () => { - setNewRole(role); - setIsEditing(false); - setIsHovered(false); - setIsDropdownOpen(false); - }; - - const oppositeRole = newRole === 'TEACHER' ? 'ADMIN' : 'TEACHER'; - - return ( - !isEditing && setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - {newRole === 'TEACHER' ? 'Teacher' : 'Admin'} - - - - {isEditing ? ( - - ) : ( - isHovered && - )} - - - - {isDropdownOpen && ( - - handleRoleChange(oppositeRole)} - > - - {oppositeRole === 'TEACHER' ? 'Teacher' : 'Admin'} - - - - )} - - {isEditing && newRole !== role && ( - - )} - - {isEditing && ( - - )} - - ); -}; - -export default EditableRoleCell; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index 6e2c5534..00000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Flex, FlexProps } from '@chakra-ui/react'; - -export const Footer = (props: FlexProps) => ( - -); diff --git a/src/components/SistemaLogoColour.tsx b/src/components/TacetLogo.tsx similarity index 100% rename from src/components/SistemaLogoColour.tsx rename to src/components/TacetLogo.tsx diff --git a/src/components/UserManagementCard.tsx b/src/components/UserManagementCard.tsx deleted file mode 100644 index d1ef207f..00000000 --- a/src/components/UserManagementCard.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Text, - Box, - useDisclosure, -} from '@chakra-ui/react'; -import { Role, UserAPI } from '@utils/types'; -import { useEffect, useState } from 'react'; -import { UserManagementTable } from './UserManagementTable'; - -const UserManagementCard = () => { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [absenceCap, setAbsenceCap] = useState(10); - - const { isOpen, onOpen, onClose } = useDisclosure(); - const [pendingUser, setPendingUser] = useState(null); - const [pendingRole, setPendingRole] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const usersResponse = await fetch( - '/api/users?getAbsences=true&getMailingLists=true' - ); - if (!usersResponse.ok) throw new Error('Failed to fetch users'); - const usersData = await usersResponse.json(); - setUsers(usersData); - - const settingsResponse = await fetch('/api/settings'); - if (!settingsResponse.ok) throw new Error('Failed to fetch settings'); - const settings = await settingsResponse.json(); - setAbsenceCap(settings.absenceCap); - } catch (error) { - console.error('Error fetching data:', error); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, []); - - const handleConfirmRoleChange = (userId: number, newRole: Role) => { - const user = users.find((u) => u.id === userId); - if (!user) return; - - setPendingUser(user); - setPendingRole(newRole); - onOpen(); - }; - - const confirmUpdateUserRole = async () => { - if (!pendingUser || !pendingRole) return; - - const apiUrl = `/api/users/${pendingUser.id}`; - const originalUsers = [...users]; - - setUsers((prevUsers) => - prevUsers.map((user) => - user.id === pendingUser.id ? { ...user, role: pendingRole } : user - ) - ); - - try { - const response = await fetch(apiUrl, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ role: pendingRole }), - }); - - if (!response.ok) { - setUsers(originalUsers); - throw new Error(response.statusText); - } - } catch (error: unknown) { - if (error instanceof Error) { - console.error('Error updating user:', error.message); - } - } finally { - onClose(); - setPendingUser(null); - setPendingRole(null); - } - }; - - return loading ? null : ( - - - - - - - Confirm Role Change - - - - Are you sure you want to change{' '} - - {pendingUser?.firstName} {pendingUser?.lastName} - - ’s role to {pendingRole}?{' '} - - - - - - - - - - ); -}; - -export default UserManagementCard; diff --git a/src/components/AbsenceBox.tsx b/src/components/absences/AbsenceBox.tsx similarity index 86% rename from src/components/AbsenceBox.tsx rename to src/components/absences/AbsenceBox.tsx index 8a8c4fb3..cccda778 100644 --- a/src/components/AbsenceBox.tsx +++ b/src/components/absences/AbsenceBox.tsx @@ -48,13 +48,12 @@ const AbsenceBox: React.FC = ({ return ( `${theme.space[1]} ${theme.space[1]}`, - margin: `4px 4px`, + padding: '2px 3px 4px 3px', borderRadius: (theme) => `${theme.radii.md}`, backgroundColor, textColor, - border: '0.1rem solid', - borderLeft: '5px solid', + border: '1px solid', + borderLeft: '7px solid', borderColor, position: 'relative', opacity, @@ -70,14 +69,29 @@ const AbsenceBox: React.FC = ({ color: textColor, transform: 'rotate(180deg)', }} + size={18} /> )} - + {title} - + {location} {highlightText && ( diff --git a/src/components/FileUpload.tsx b/src/components/absences/FileUpload.tsx similarity index 60% rename from src/components/FileUpload.tsx rename to src/components/absences/FileUpload.tsx index e13b6b33..d14c1a68 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/absences/FileUpload.tsx @@ -1,24 +1,19 @@ -import { - Box, - FormControl, - FormLabel, - Image, - Input, - Text, - useToast, -} from '@chakra-ui/react'; +import { Box, Image, Input, Text, useToast } from '@chakra-ui/react'; +import { LessonPlanFile } from '@utils/types'; import { useRef, useState } from 'react'; interface FileUploadProps { lessonPlan: File | null; - setLessonPlan: (lessonPlan: File | null) => void; - label?: string; + setLessonPlan: (file: File | null) => void; + existingFile?: LessonPlanFile | null; + isDisabled?: boolean; } export const FileUpload: React.FC = ({ lessonPlan, setLessonPlan, - label = 'Lesson Plan', + existingFile, + isDisabled, }) => { const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); @@ -32,52 +27,62 @@ export const FileUpload: React.FC = ({ title: 'Invalid File Type', description: 'Please upload a valid PDF file.', status: 'error', - duration: 4000, isClosable: true, }); } }; const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const file = e.target.files[0]; + if (isDisabled) return; + + const file = e.target.files?.[0]; + if (file) { validateAndSetFile(file); } }; const handleDragOver = (e: React.DragEvent) => { + if (isDisabled) return; e.preventDefault(); setIsDragging(true); }; const handleDragLeave = () => { + if (isDisabled) return; setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { + if (isDisabled) return; e.preventDefault(); setIsDragging(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const file = e.dataTransfer.files[0]; + + const file = e.dataTransfer.files?.[0]; + if (file) { validateAndSetFile(file); } }; return ( - - - {label} - - + <> = ({ > Upload - {lessonPlan ? `Selected file: ${lessonPlan.name}` : 'Upload PDF'} + {lessonPlan + ? `Selected file: ${lessonPlan.name}` + : existingFile + ? `Selected file: ${existingFile.name}` + : 'Upload PDF'} - + ); }; diff --git a/src/components/AbsenceDetails.tsx b/src/components/absences/details/AbsenceDetails.tsx similarity index 59% rename from src/components/AbsenceDetails.tsx rename to src/components/absences/details/AbsenceDetails.tsx index f0d0c71b..e8f8284b 100644 --- a/src/components/AbsenceDetails.tsx +++ b/src/components/absences/details/AbsenceDetails.tsx @@ -16,23 +16,45 @@ import { useToast, } from '@chakra-ui/react'; import { useUserData } from '@hooks/useUserData'; -import { Role } from '@utils/types'; +import { EventDetails, Role } from '@utils/types'; import { Buildings, Calendar } from 'iconsax-react'; -import { useRouter } from 'next/router'; import { useState } from 'react'; import { FiEdit2, FiMapPin, FiTrash2, FiUser } from 'react-icons/fi'; import { IoEyeOutline } from 'react-icons/io5'; -import AbsenceStatusTag from './AbsenceStatusTag'; +import EditAbsenceForm from '../modals/edit/EditAbsenceForm'; +import AbsenceFillThanks from './AbsenceFillThanks'; import ClaimAbsenceToast from './ClaimAbsenceToast'; +import AbsenceStatusTag from './AbsenceStatusTag'; +import EditableNotes from './EditableNotes'; import LessonPlanView from './LessonPlanView'; -const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { +interface AbsenceDetailsProps { + isOpen: boolean; + onClose: () => void; + event: EventDetails; + onDelete?: (absenceId: number) => void; + isAdminMode: boolean; + fetchAbsences: () => Promise; +} + +const AbsenceDetails: React.FC = ({ + isOpen, + onClose, + event, + onDelete, + isAdminMode, + fetchAbsences, +}) => { const theme = useTheme(); const userData = useUserData(); const [isDeleting, setIsDeleting] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isFilling, setIsFilling] = useState(false); + const [isFillDialogOpen, setIsFillDialogOpen] = useState(false); + const [isFillThanksOpen, setIsFillThanksOpen] = useState(false); + const toast = useToast(); - const router = useRouter(); if (!event) return null; @@ -41,6 +63,119 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { const isUserSubstituteTeacher = userId === event.substituteTeacher?.id; const isUserAdmin = userData.role === Role.ADMIN; + const getOrdinalNum = (number) => { + let selector; + + if (number <= 0) { + selector = 4; + } else if ((number > 3 && number < 21) || number % 10 > 3) { + selector = 0; + } else { + selector = number % 10; + } + + return number + ['th', 'st', 'nd', 'rd', ''][selector]; + }; + + const formatDate = (date: Date) => { + const parsedDate = new Date(date); + const weekday = parsedDate.toLocaleDateString('en-CA', { weekday: 'long' }); + const month = parsedDate.toLocaleDateString('en-CA', { month: 'long' }); + const day = parsedDate.getDate(); + + return `${weekday}, ${month} ${getOrdinalNum(day)}`; + }; + + const absenceDate = formatDate(event.start!!); + + const handleFillThanksDone = () => { + setIsFillThanksOpen(false); + }; + + const handleFillAbsenceClick = () => { + setIsFillDialogOpen(true); + }; + + const handleFillCancel = () => { + setIsFillDialogOpen(false); + }; + + const handleFillConfirm = async () => { + setIsFilling(true); + + try { + const response = await fetch('/api/editAbsence', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: event.absenceId, + lessonDate: event.start, + reasonOfAbsence: event.reasonOfAbsence, + notes: event.notes, + absentTeacherId: event.absentTeacher.id, + substituteTeacherId: userData.id, + locationId: event.locationId, + subjectId: event.subjectId, + roomNumber: event.roomNumber, + }), + }); + + if (!response.ok) { + throw new Error('Failed to fill absence'); + } + + toast({ + isClosable: true, + position: 'top', + render: () => ( + + ), + }); + + await fetchAbsences(); + setIsFillDialogOpen(false); + setIsFillThanksOpen(true); + } catch { + const formattedDate = new Date(event.start).toLocaleDateString('en-CA', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + toast({ + isClosable: true, + position: 'top', + render: () => ( + + ), + }); + } finally { + setIsFilling(false); + onClose(); + } + }; + + const handleEditClick = () => { + onClose(); + setIsEditModalOpen(true); + }; + const handleDeleteClick = () => { setIsDeleteDialogOpen(true); }; @@ -58,7 +193,7 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - isUserAdmin: isUserAdmin, + isUserAdmin, absenceId: event.absenceId, }), }); @@ -71,24 +206,21 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { title: 'Absence deleted', description: 'The absence has been successfully deleted.', status: 'success', - duration: 5000, isClosable: true, }); + await fetchAbsences(); setIsDeleteDialogOpen(false); onClose(); if (onDelete) { onDelete(event.absenceId); - } else { - router.reload(); } } catch (error) { toast({ title: 'Error', description: error.message || 'Failed to delete absence', status: 'error', - duration: 5000, isClosable: true, }); } finally { @@ -110,7 +242,11 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { isUserAbsentTeacher={isUserAbsentTeacher} isUserSubstituteTeacher={isUserSubstituteTeacher} isAdminMode={isAdminMode} - substituteTeacherFullName={event.substituteTeacherFullName} + substituteTeacherFullName={ + event.substituteTeacherFullName + ? event.substituteTeacherFullName + : undefined + } /> {isAdminMode && ( @@ -121,6 +257,7 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { } size="sm" variant="ghost" + onClick={handleEditClick} /> )} @@ -156,13 +293,7 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { - {event.start - ? new Date(event.start).toLocaleDateString('en-CA', { - weekday: 'long', - month: 'long', - day: 'numeric', - }) - : 'N/A'} + {absenceDate} @@ -188,10 +319,12 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { {(isAdminMode || isUserAbsentTeacher) && ( @@ -212,36 +345,11 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { )} {isUserAbsentTeacher && !isAdminMode && ( - - - Notes - - - {event.notes} - - - - } - size="sm" - variant="ghost" - position="absolute" - bottom="8px" - right="16px" - /> - + )} {event.notes && (!isUserAbsentTeacher || isAdminMode) && ( @@ -303,39 +411,15 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { )} - {/* Fill Absence Button*/} {!event.substituteTeacher && !isUserAbsentTeacher && !isAdminMode && ( @@ -344,6 +428,60 @@ const AbsenceDetails = ({ isOpen, onClose, event, isAdminMode, onDelete }) => { + + + + + Are you sure you want to fill this absence? + + + {"You won't be able to undo."} + + + + + + + + { + {isEditModalOpen && ( + setIsEditModalOpen(false)} + isCentered + > + + + + Edit Absence + + + + setIsEditModalOpen(false)} + /> + + + + )} ); }; diff --git a/src/components/absences/details/AbsenceFillThanks.tsx b/src/components/absences/details/AbsenceFillThanks.tsx new file mode 100644 index 00000000..27f2179b --- /dev/null +++ b/src/components/absences/details/AbsenceFillThanks.tsx @@ -0,0 +1,82 @@ +import { + Button, + Flex, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useTheme, +} from '@chakra-ui/react'; +import { FiCheckCircle } from 'react-icons/fi'; + +const AbsenceFillThanks = ({ isOpen, onClose, event, absenceDate }) => { + const theme = useTheme(); + + return ( + + + + + + + Thank you! + + + + + You have successfully filled + + {event?.absentTeacher?.firstName + "'s"} + + {' absence on '} + + {absenceDate + '.'} + + + Make sure to review the lesson plan! + + + Note: + + Please contact admin for any modifications. + + + + + + + + ); +}; + +export default AbsenceFillThanks; diff --git a/src/components/AbsenceStatusTag.tsx b/src/components/absences/details/AbsenceStatusTag.tsx similarity index 100% rename from src/components/AbsenceStatusTag.tsx rename to src/components/absences/details/AbsenceStatusTag.tsx diff --git a/src/components/ClaimAbsenceToast.tsx b/src/components/absences/details/ClaimAbsenceToast.tsx similarity index 91% rename from src/components/ClaimAbsenceToast.tsx rename to src/components/absences/details/ClaimAbsenceToast.tsx index ce62d622..183c7594 100644 --- a/src/components/ClaimAbsenceToast.tsx +++ b/src/components/absences/details/ClaimAbsenceToast.tsx @@ -5,8 +5,8 @@ const ClaimAbsenceToast = ({ firstName, date, success }) => { const theme = useTheme(); const modalColor = success - ? theme.colors.positiveGreen[200] || 'green.200' - : theme.colors.errorRed[200] || 'red.200'; + ? theme.colors.positiveGreen[200] + : theme.colors.errorRed[200]; const message = success ? `You have successfully claimed ` diff --git a/src/components/absences/details/EditableNotes.tsx b/src/components/absences/details/EditableNotes.tsx new file mode 100644 index 00000000..9fb609e0 --- /dev/null +++ b/src/components/absences/details/EditableNotes.tsx @@ -0,0 +1,169 @@ +import { + Box, + Button, + HStack, + IconButton, + Spacer, + Text, + Textarea, + useTheme, + useToast, +} from '@chakra-ui/react'; +import { useEffect, useRef, useState } from 'react'; +import { FiEdit2 } from 'react-icons/fi'; + +interface EditableNotesProps { + notes: string; + absenceId: number; + fetchAbsences: () => Promise; +} + +function EditableNotes({ + notes, + absenceId, + fetchAbsences, +}: EditableNotesProps) { + const theme = useTheme(); + const toast = useToast(); + const [isEditing, setIsEditing] = useState(false); + const [savedNotes, setSavedNotes] = useState(notes); + const [tempNotes, setTempNotes] = useState(notes); + const [isSaving, setIsSaving] = useState(false); + const noteBoxRef = useRef(null); + const textAreaRef = useRef(null); + const [initialHeight, setInitialHeight] = useState( + undefined + ); + + const emptyNotesSpace = 41; + const spaceAfterNotes = 33; + const minHeight = emptyNotesSpace + spaceAfterNotes; + + const handleEditClick = () => { + const height = noteBoxRef.current?.offsetHeight ?? minHeight; + setInitialHeight(Math.max(height, minHeight)); + setTempNotes(savedNotes); + setIsEditing(true); + }; + + const handleCancel = () => { + setIsEditing(false); + setTempNotes(savedNotes); + }; + + const handleSave = async () => { + setIsSaving(true); + + try { + const response = await fetch('/api/editAbsence', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: absenceId, + notes: tempNotes || null, + }), + }); + + if (!response.ok) { + throw new Error('Failed to save notes'); + } + + setSavedNotes(tempNotes); + toast({ + title: 'Notes saved', + description: 'Your notes were successfully updated.', + status: 'success', + isClosable: true, + }); + + setIsEditing(false); + } catch (err) { + toast({ + title: 'Error', + description: + err instanceof Error ? err.message : 'Failed to save notes', + status: 'error', + isClosable: true, + }); + } finally { + setIsSaving(false); + fetchAbsences(); + } + }; + + useEffect(() => { + if (isEditing && textAreaRef.current) { + const textLength = textAreaRef.current.value.length; + textAreaRef.current.setSelectionRange(textLength, textLength); + } + }, [isEditing]); + + return ( + + + Notes + + + {isEditing ? ( + <> +