diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d33cffb..91d922c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cn: + specifier: ^0.4.0 + version: 0.4.0 dtos: specifier: workspace:* version: link:../dtos @@ -2927,7 +2930,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: @@ -2937,7 +2939,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: @@ -2947,7 +2948,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: @@ -2957,7 +2957,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: @@ -2967,7 +2966,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: @@ -2977,7 +2975,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: @@ -3116,7 +3113,6 @@ packages: engines: { node: '>= 20' } cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: @@ -3126,7 +3122,6 @@ packages: engines: { node: '>= 20' } cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: @@ -3136,7 +3131,6 @@ packages: engines: { node: '>= 20' } cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: @@ -3146,7 +3140,6 @@ packages: engines: { node: '>= 20' } cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: @@ -3665,7 +3658,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: @@ -3674,7 +3666,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: @@ -3683,7 +3674,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: @@ -3692,7 +3682,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: @@ -3701,7 +3690,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: @@ -3710,7 +3698,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: @@ -3719,7 +3706,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: @@ -3728,7 +3714,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: @@ -4493,6 +4478,14 @@ packages: } engines: { node: '>=6' } + cn@0.4.0: + resolution: + { + integrity: sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==, + } + engines: { node: '>=20' } + hasBin: true + co@4.6.0: resolution: { @@ -5706,7 +5699,7 @@ packages: { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, } - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: @@ -6575,7 +6568,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: @@ -6585,7 +6577,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: @@ -6595,7 +6586,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: @@ -6605,7 +6595,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: @@ -11987,6 +11976,8 @@ snapshots: clsx@2.1.1: {} + cn@0.4.0: {} + co@4.6.0: {} code-block-writer@13.0.3: {} diff --git a/src/dashboard/package.json b/src/dashboard/package.json index 3ca097c5..35d30a7a 100644 --- a/src/dashboard/package.json +++ b/src/dashboard/package.json @@ -15,6 +15,7 @@ "axios": "^1.19.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", "dtos": "workspace:*", "lucide-react": "^1.16.0", "radix-ui": "^1.4.3", diff --git a/src/dashboard/src/components/StudentUpdateForm.tsx b/src/dashboard/src/components/StudentUpdateForm.tsx deleted file mode 100644 index 120cf6bf..00000000 --- a/src/dashboard/src/components/StudentUpdateForm.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Field, FieldDescription, FieldGroup, FieldLabel } from './ui/field'; -import { Input } from './ui/input'; -import { Button } from './ui/button'; -import { useState } from 'react'; -import { studentService } from '@/services/student.service'; -import type { StudentDetailsDto } from 'dtos'; -import axios from 'axios'; - -type StudentUpdateFormProps = { - onCancel: () => void; - student?: StudentDetailsDto; - onUpdateSuccess: (updatedStudent: StudentDetailsDto) => void; -}; - -export function StudentUpdateForm({ - onCancel, - student, - onUpdateSuccess, -}: StudentUpdateFormProps) { - const [studentData, setStudentData] = useState({ - firstName: student?.firstName ?? '', - lastName: student?.lastName ?? '', - email: student?.email ?? '', - phoneNumber: student?.phoneNumber ?? '', - }); - - const [updateError, setUpdateError] = useState(null); - const [isUpdating, setIsUpdating] = useState(false); - - return ( -
{ - event.preventDefault(); - if (!student) { - return; - } - - setUpdateError(null); - setIsUpdating(true); - studentService - .updateStudentById(student.id, studentData) - .then((updatedStudent) => { - setIsUpdating(false); - onUpdateSuccess(updatedStudent); - onCancel(); - }) - .catch((error) => { - setIsUpdating(false); - console.log('Failed to update student:', error); - if (axios.isAxiosError(error)) { - const message = error.response?.data?.message; - if (Array.isArray(message)) { - setUpdateError(message.join(', ')); - return; - } - - if (typeof message === 'string') { - setUpdateError(message); - return; - } - } - setUpdateError('Failed to update student. Please try again later.'); - }); - }} - > - - - - First Name - - setStudentData({ ...studentData, firstName: data.target.value }) - } - /> - - - - - - Last Name - - setStudentData({ ...studentData, lastName: data.target.value }) - } - /> - - - - - - Email - - setStudentData({ ...studentData, email: data.target.value }) - } - /> - - The primary email used for academic notifications. - - - - - - Phone Number - - Include country code if appilcable. - - - setStudentData({ ...studentData, phoneNumber: data.target.value }) - } - /> - - - {updateError &&

{updateError}

} -
- - -
-
-
- ); -} diff --git a/src/dashboard/src/components/teacher-form.tsx b/src/dashboard/src/components/teacher-form.tsx new file mode 100644 index 00000000..daaa01e7 --- /dev/null +++ b/src/dashboard/src/components/teacher-form.tsx @@ -0,0 +1,195 @@ +import { Field, FieldDescription, FieldGroup, FieldLabel } from './ui/field'; +import type { TeacherDetailsDto, TeacherDto } from 'dtos'; +import { teacherService } from '@/services/teacher.service'; +import { useState } from 'react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { getApiErrorMessage } from '@/lib/api-error.util'; + +type TeacherFormProps = { + onCancel: () => void; + teacher?: TeacherDetailsDto; + onUpdateSuccess: (updatedTeacher: TeacherDetailsDto) => void; + onCreateSuccess: (createdTeacher: TeacherDto) => void; +}; + +export function TeacherForm({ + onCancel, + teacher, + onUpdateSuccess, + onCreateSuccess, +}: TeacherFormProps) { + const [teacherData, setTeacherData] = useState({ + firstName: teacher?.firstName ?? '', + lastName: teacher?.lastName ?? '', + email: teacher?.email ?? '', + phoneNumber: teacher?.phoneNumber ?? '', + degree: teacher?.degree ?? '', + password: '', + }); + + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + return ( +
{ + event.preventDefault(); + + setFormError(null); + setIsSubmitting(true); + + if (teacher) { + teacherService + .updateTeacherById(teacher.userId, teacherData) + .then((updatedTeacher) => { + setIsSubmitting(false); + onUpdateSuccess(updatedTeacher); + onCancel(); + }) + .catch((error) => { + setIsSubmitting(false); + console.log('Failed to update teacher:', error); + const message = getApiErrorMessage(error); + setFormError( + message ?? 'Failed to update teacher. Please try again later.', + ); + }); + + return; + } + + teacherService + .createTeacher({ + firstName: teacherData.firstName, + lastName: teacherData.lastName, + email: teacherData.email, + phoneNumber: teacherData.phoneNumber, + hashedPassword: teacherData.password, + degree: teacherData.degree, + }) + .then((createdTeacher) => { + setIsSubmitting(false); + onCreateSuccess(createdTeacher); + onCancel(); + }) + .catch((error) => { + setIsSubmitting(false); + console.log('Failed to create teacher:', error); + const message = getApiErrorMessage(error); + setFormError( + message ?? 'Failed to create teacher. Please try again later.', + ); + }); + }} + > + + + + First Name + + setTeacherData({ ...teacherData, firstName: data.target.value }) + } + /> + + + + + + Last Name + + setTeacherData({ ...teacherData, lastName: data.target.value }) + } + /> + + + + + + Email + + setTeacherData({ ...teacherData, email: data.target.value }) + } + /> + + The primary email used for academic notifications. + + + + + + Phone Number + + Include country code if applicable. + + + setTeacherData({ ...teacherData, phoneNumber: data.target.value }) + } + /> + + + + + Degree + + setTeacherData({ ...teacherData, degree: data.target.value }) + } + /> + + The degree or qualification of the teacher. + + + + + {!teacher && ( + + + Password + + setTeacherData({ + ...teacherData, + password: data.target.value, + }) + } + /> + + + )} + + {formError &&

{formError}

} +
+ + +
+
+
+ ); +} diff --git a/src/dashboard/src/components/teacher-modal.tsx b/src/dashboard/src/components/teacher-modal.tsx new file mode 100644 index 00000000..b2f4f4c5 --- /dev/null +++ b/src/dashboard/src/components/teacher-modal.tsx @@ -0,0 +1,39 @@ +import type { TeacherDetailsDto, TeacherDto } from 'dtos'; +import { TeacherForm } from './teacher-form'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; + +type TeacherModalProps = { + open: boolean; + teacher?: TeacherDetailsDto; + onUpdateSuccess: (updatedTeacher: TeacherDetailsDto) => void; + onCreateSuccess: (createdTeacher: TeacherDto) => void; + onClose: () => void; +}; + +export default function TeacherModal({ + open, + teacher, + onUpdateSuccess, + onCreateSuccess, + onClose, +}: TeacherModalProps) { + if (!open) { + return null; + } + + return ( + !isOpen && onClose()}> + +

+ {teacher ? 'Update Teacher' : 'Create Teacher'} +

+ +
+
+ ); +} diff --git a/src/dashboard/src/components/ui/dialog.tsx b/src/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 00000000..c838cb70 --- /dev/null +++ b/src/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Dialog as DialogPrimitive } from 'radix-ui'; + +import { Button } from '@/components/ui/button'; +import { XIcon } from 'lucide-react'; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/dashboard/src/lib/api-error.util.ts b/src/dashboard/src/lib/api-error.util.ts new file mode 100644 index 00000000..d2bb23e8 --- /dev/null +++ b/src/dashboard/src/lib/api-error.util.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +export function getApiErrorMessage(error: unknown): string | null { + if (!axios.isAxiosError(error)) { + return null; + } + + const message = error.response?.data?.message; + + if (Array.isArray(message)) { + return message.join(', '); + } + if (typeof message === 'string') { + return message; + } + return null; +} diff --git a/src/dashboard/src/pages/teacher/teachers-details-page.tsx b/src/dashboard/src/pages/teacher/teachers-details-page.tsx index f1e4b161..fdcc5b09 100644 --- a/src/dashboard/src/pages/teacher/teachers-details-page.tsx +++ b/src/dashboard/src/pages/teacher/teachers-details-page.tsx @@ -12,6 +12,7 @@ import { import { teacherService } from '@/services/teacher.service'; import { useNumericParam } from '@/hooks/use-numeric-param'; import type { TeacherDetailsDto } from 'dtos'; +import TeacherModal from '@/components/teacher-modal'; export default function TeachersDetailsPage() { const teacherId = useNumericParam('id'); @@ -19,6 +20,7 @@ export default function TeachersDetailsPage() { const [teacher, setTeacher] = useState(null); const [isLoading, setIsLoading] = useState(true); const [fetchError, setFetchError] = useState(null); + const [isTeacherModalOpen, setIsTeacherModalOpen] = useState(false); useEffect(() => { const fetchTeacher = async () => { @@ -105,12 +107,28 @@ export default function TeachersDetailsPage() { {/* TODO: Use update form here */} - + {/* TODO: Use Generic Delete Modal */} + + { + setTeacher(updatedTeacher); + setIsTeacherModalOpen(false); + }} + onCreateSuccess={() => {}} + onClose={() => setIsTeacherModalOpen(false)} + />
); } diff --git a/src/dashboard/src/pages/teacher/teachers-page.tsx b/src/dashboard/src/pages/teacher/teachers-page.tsx index c757f931..f7c82aee 100644 --- a/src/dashboard/src/pages/teacher/teachers-page.tsx +++ b/src/dashboard/src/pages/teacher/teachers-page.tsx @@ -13,7 +13,9 @@ import { import DeleteModal from '@/components/ui/delete-modal'; import { teacherService } from '@/services/teacher.service'; -import type { TeacherDto } from 'dtos'; +import type { TeacherDetailsDto, TeacherDto } from 'dtos'; +import { Button } from '@/components/ui/button'; +import TeacherModal from '@/components/teacher-modal'; export default function TeachersPage() { const navigate = useNavigate(); @@ -28,6 +30,9 @@ export default function TeachersPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [teacherToEditData, setTeacherToEditData] = + useState(null); + const [isTeacherModalOpen, setIsTeacherModalOpen] = useState(false); useEffect(() => { const fetchTeachers = async () => { @@ -89,9 +94,32 @@ export default function TeachersPage() { } }; + const handleUpdateSuccess = (updatedTeacher: TeacherDetailsDto) => { + setTeachers((previousTeachers) => + previousTeachers.map((teacher) => + teacher.userId === updatedTeacher.userId ? updatedTeacher : teacher, + ), + ); + }; + + const handleCreateSuccess = (createdTeacher: TeacherDto) => { + setTeachers((previousTeachers) => [...previousTeachers, createdTeacher]); + }; + return (
+
+ +
A list of registered teachers. @@ -144,6 +172,29 @@ export default function TeachersPage() { {teacher.degree} + +