diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e587e28..910d483 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(cd:*)" + "Bash(cd:*)", + "Bash(git -C \"C:\\\\Users\\\\Oling\\\\documents\\\\courseconnect\" log --oneline -20)" ] } } diff --git a/.gitignore b/.gitignore index fb3a957..66361ae 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ courseconnect-c6a7b-firebase-adminsdk-dqqis-af57e2e045.json playwright-report test-results playwright/.auth -playwright.accounts.json \ No newline at end of file +playwright.accounts.json +nul diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..724698e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CourseConnect is a web application for managing teaching position applications (TAs, UPIs, Graders) and course information. It serves students (submit/track applications), faculty (review applications, manage courses), and department leaders (manage users/courses, upload data via spreadsheets). + +## Tech Stack + +- **Frontend:** React 19, Next.js 15 (App Router), TypeScript +- **UI:** Material UI (MUI) 6, Tailwind CSS 4 +- **Backend:** Firebase/Firestore, Firebase Cloud Functions +- **State:** React Query (TanStack), React Context +- **Forms:** React Hook Form + +## Common Commands + +```bash +# Development +npm run dev # Start dev server at localhost:3000 +npm run build # Production build +npm run start # Start production server +npm run lint # Run ESLint + +# Testing (Playwright E2E) +npm run test:e2e # Run all E2E tests +npm run test:e2e:ui # Run tests with Playwright UI +npm run test:e2e:debug # Debug tests +npx playwright show-report # View test report after failure + +# Firebase Functions (from /functions directory) +npm run build # Compile TypeScript +npm run serve # Run functions locally with emulator +npm run deploy # Deploy functions to Firebase +``` + +## Architecture + +``` +src/ +├── app/ # Next.js App Router pages +│ ├── admin-applications/ # Admin: manage all applications +│ ├── admincourses/ # Admin: course management +│ ├── announcements/ # Announcements feature +│ ├── applications/ # Student application submission +│ ├── courses/ # Course viewing +│ ├── dashboard/ # Main dashboard +│ ├── faculty/ # Faculty management +│ ├── users/ # Admin: user management +│ └── layout.tsx # Root layout with providers +├── components/ # Reusable React components +├── contexts/ # React contexts (Auth, Announcements) +├── firebase/ # Firebase config and utilities +├── hooks/ # Custom React hooks (useGetItems, etc.) +├── types/ # TypeScript type definitions +└── utils/ # Utility functions + +functions/ # Firebase Cloud Functions (email notifications) +tests/e2e/ # Playwright E2E tests +``` + +## Key Patterns + +- **Path alias:** Use `@/*` to import from `src/*` +- **Providers:** AuthProvider and AnnouncementsProvider wrap the app in `layout.tsx` +- **Data fetching:** React Query hooks in `src/hooks/` for Firestore operations +- **E2E test mode:** Feature flags via `NEXT_PUBLIC_E2E` env var and localStorage + +## Pre-commit Hooks + +Husky runs ESLint and Prettier on staged files automatically. If commits fail, check lint errors with `npm run lint`. + +## Firebase Functions + +Cloud Functions in `functions/src/index.ts` handle email notifications (application confirmations, status updates, faculty notifications). Uses Nodemailer with configured SMTP credentials. diff --git a/src/app/announcements/AnnouncementSections.tsx b/src/app/announcements/AnnouncementSections.tsx index bada238..898fae8 100644 --- a/src/app/announcements/AnnouncementSections.tsx +++ b/src/app/announcements/AnnouncementSections.tsx @@ -1,17 +1,24 @@ /* components/DashboardSections.tsx */ import { PrimaryButton } from '@/components/Buttons/PrimaryButton'; +import { NavbarItem } from '@/types/navigation'; import { Role } from '@/types/User'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import AnnouncementDialog from './AnnouncementDialogue'; import AnnouncementsRow from './AnnouncementsRow'; import { usePostAnnouncement } from '@/hooks/Announcements/usePostAnnouncement'; -import { useAnnouncements } from '@/contexts/AnnouncementsContext'; +import { useFetchAnnouncementsForAccount } from '@/hooks/Announcements/useFetchAnnouncements'; -import { Announcement } from '@/types/announcement'; +import { Announcement, AudienceRole } from '@/types/announcement'; -export default function AnnouncementSections({ role }: { role: Role }) { +export default function AnnouncementSections({ + role, + uemail, +}: { + role: Role; + uemail: string; +}) { const [open, setOpen] = useState(false); const { postAnnouncement, posting, error: postError } = usePostAnnouncement(); @@ -19,9 +26,18 @@ export default function AnnouncementSections({ role }: { role: Role }) { read, unread, loading, + loadingMore, + hasMore, error: fetchError, refresh, - } = useAnnouncements(); + loadMore, + } = useFetchAnnouncementsForAccount({ + userRole: role, + userEmail: uemail, + userDepartment: 'ECE', // TODO: make real + channel: 'inApp', + realtime: true, + }); async function handleSubmit(draft: Announcement) { await postAnnouncement({ @@ -125,6 +141,18 @@ export default function AnnouncementSections({ role }: { role: Role }) { )} )} + + {hasMore ? ( +
+ +
+ ) : null} ); } diff --git a/src/app/announcements/page.tsx b/src/app/announcements/page.tsx index af8be83..5f72826 100644 --- a/src/app/announcements/page.tsx +++ b/src/app/announcements/page.tsx @@ -9,6 +9,7 @@ import { markAnnouncementsSeen } from '@/hooks/Announcements/markAnnouncementAsS const AnnouncementsPage: FC = () => { const [user, role, loading, error] = useUserInfo(); + const uemail = user?.email; // prevent double-call in dev Strict Mode const didMarkRef = useRef(false); @@ -26,7 +27,7 @@ const AnnouncementsPage: FC = () => { return ( - + ); }; diff --git a/src/app/applications/applicationSections.tsx b/src/app/applications/applicationSections.tsx index 487bfce..b660017 100644 --- a/src/app/applications/applicationSections.tsx +++ b/src/app/applications/applicationSections.tsx @@ -7,6 +7,7 @@ import SemesterSelect from '@/components/SemesterSelect/SemesterSelect'; import { useSemesterData } from '@/hooks/Courses/useSemesterData'; import { CoursesGrid } from '@/components/CoursesGrid/CoursesGrid'; import { type SemesterName } from '@/hooks/useSemesterOptions'; +import SchoolIcon from '@mui/icons-material/School'; export default function ApplicationSections({ role, navItems, @@ -45,6 +46,19 @@ export default function ApplicationSections({ ))} + +
+

Supervised Teaching

+
+ +
+
+

Research

No available applications at this time.

diff --git a/src/app/applications/supervised-teaching/page.tsx b/src/app/applications/supervised-teaching/page.tsx new file mode 100644 index 0000000..7e21059 --- /dev/null +++ b/src/app/applications/supervised-teaching/page.tsx @@ -0,0 +1,474 @@ +'use client'; + +import * as React from 'react'; +import Button from '@mui/material/Button'; +import CssBaseline from '@mui/material/CssBaseline'; +import TextField from '@mui/material/TextField'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useAuth } from '@/firebase/auth/auth_context'; +import { Toaster, toast } from 'react-hot-toast'; +import Snackbar from '@mui/material/Snackbar'; +import MuiAlert, { AlertProps } from '@mui/material/Alert'; +import HeaderCard from '@/components/HeaderCard/HeaderCard'; +import firebase from '@/firebase/firebase_config'; +import { useRouter } from 'next/navigation'; +import { fetchClosestSemesters } from '@/hooks/useSemesterOptions'; +import MenuItem from '@mui/material/MenuItem'; +import InputLabel from '@mui/material/InputLabel'; +import FormControl from '@mui/material/FormControl'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; + +export default function SupervisedTeachingApplication() { + const { user } = useAuth(); + const userId = user?.uid || ''; + const router = useRouter(); + + const [success, setSuccess] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + // Teaching choices (dropdowns) + const [teachingFirstChoice, setTeachingFirstChoice] = React.useState(''); + const [teachingSecondChoice, setTeachingSecondChoice] = React.useState(''); + const [teachingThirdChoice, setTeachingThirdChoice] = React.useState(''); + + const Alert = React.forwardRef(function Alert( + props, + ref + ) { + return ; + }); + + const handleSuccess = ( + event?: React.SyntheticEvent | Event, + reason?: string + ) => { + if (reason === 'clickaway') return; + setSuccess(false); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + + const formData = new FormData(event.currentTarget); + + const firstName = (formData.get('firstName') as string) || ''; + const lastName = (formData.get('lastName') as string) || ''; + const ufid = (formData.get('ufid') as string) || ''; + const email = (formData.get('email') as string) || ''; + const confirmEmail = (formData.get('confirmEmail') as string) || ''; + const phdAdmissionTerm = (formData.get('phdAdmissionTerm') as string) || ''; + const phdAdvisor = (formData.get('phdAdvisor') as string) || ''; + const admittedToCandidacy = + (formData.get('admittedToCandidacy') as string) || ''; + const registerTerm = (formData.get('registerTerm') as string) || ''; + const previouslyRegistered = + (formData.get('previouslyRegistered') as string) || ''; + const previousDetails = (formData.get('previousDetails') as string) || ''; + const coursesComfortable = + (formData.get('coursesComfortable') as string) || ''; + // teaching choices come from Select state + const teachingFirst = teachingFirstChoice || ''; + const teachingSecond = teachingSecondChoice || ''; + const teachingThird = teachingThirdChoice || ''; + const captcha = formData.get('captcha') === 'on'; + + // basic validations + if (!email.includes('ufl.edu')) { + toast.error('Please enter a valid GatorLink (ufl.edu) email'); + setLoading(false); + return; + } + if (email !== confirmEmail) { + toast.error('Email and Confirm Email must match'); + setLoading(false); + return; + } + if (!firstName || !lastName || !ufid) { + toast.error('Please complete all required personal fields'); + setLoading(false); + return; + } + if (!registerTerm) { + toast.error('Please select the term you want to register for EEL 6940'); + setLoading(false); + return; + } + if (!coursesComfortable) { + toast.error('Please list at least one course you could teach'); + setLoading(false); + return; + } + if (!captcha) { + toast.error('Please complete the CAPTCHA confirmation'); + setLoading(false); + return; + } + + // date + const current = new Date(); + const current_date = `${ + current.getMonth() + 1 + }-${current.getDate()}-${current.getFullYear()}`; + + const applicationData = { + application_type: 'supervised_teaching', + firstname: firstName, + lastname: lastName, + ufid, + email, + phdAdmissionTerm, + phdAdvisor, + admittedToCandidacy, + registerTerm, + previouslyRegistered, + previousDetails, + coursesComfortable, + teachingFirst, + teachingSecond, + teachingThird, + uid: userId, + date: current_date, + status: 'Submitted', + }; + + try { + const toastId = toast.loading('Submitting application...'); + const response = await fetch( + 'https://us-central1-courseconnect-c6a7b.cloudfunctions.net/processApplicationForm', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(applicationData), + } + ); + + if (response.ok) { + toast.dismiss(toastId); + toast.success('Application submitted!'); + setSuccess(true); + // optional: update role or navigate + router.push('/'); + } else { + toast.dismiss(toastId); + toast.error('Submission failed. Please try again later.'); + } + } catch (err) { + console.error(err); + toast.error('Submission failed. Please try again later.'); + } + + setLoading(false); + }; + + const [visibleSems, setVisibleSems] = React.useState([]); + React.useEffect(() => { + async function load() { + const sems = await fetchClosestSemesters(3); + setVisibleSems(sems); + } + load(); + }, []); + + return ( + + + + + + + ECE Ph.D. students may register for EEL 6940 Supervised Teaching to + fulfill professional development requirements. Deadlines: + +
    +
  • Fall Semester — August 2
  • +
  • Spring Semester — January 2
  • +
  • Summer Semester — April 30
  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Admitted to candidacy? (registered for EEL 7980 hours) + +
+ + + +
+
+ + + + Select term + {visibleSems.map((s) => ( + + {s} + + ))} + + + + + + Previously registered for EEL 6940? + +
+ + + +
+
+ + + + + + + + + + + + + Teaching First Choice + + + + + + + + + Teaching Second Choice + + + + + + + + + Teaching Third Choice + + + + + + + + + + + + +
+
+
+
+ + + + Application submitted successfully! + + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a67f9f..3ec96b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ 'use client'; import { AuthProvider } from '@/firebase/auth/auth_context'; -import { AnnouncementsProvider } from '@/contexts/AnnouncementsContext'; import React, { useEffect } from 'react'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; @@ -68,11 +67,9 @@ export default function RootLayout({ theme={mode === 'dark' ? darkThemeChosen : lightThemeChosen} > - - - {/*
*/} - {children} - + + {/*
*/} + {children} diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index 2c9acf2..952a61c 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -1,94 +1,152 @@ 'use client'; import * as React from 'react'; -import { FC } from 'react'; - +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; import CssBaseline from '@mui/material/CssBaseline'; +import TextField from '@mui/material/TextField'; +import Grid from '@mui/material/Grid'; import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; +import EditNoteIcon from '@mui/icons-material/EditNote'; +import DepartmentSelect from '@/component/FormUtil/DepartmentSelect'; +import GPA_Select from '@/component/FormUtil/GPASelect'; import Typography from '@mui/material/Typography'; - -import { Toaster } from 'react-hot-toast'; - -import PageLayout from '@/components/PageLayout/PageLayout'; -import { getNavItems } from '@/hooks/useGetItems'; - +import Container from '@mui/material/Container'; +import DegreeSelect from '@/component/FormUtil/DegreeSelect'; +import SemesterStatusSelect from '@/component/FormUtil/SemesterStatusSelect'; +import NationalitySelect from '@/component/FormUtil/NationalitySelect'; +import ProficiencySelect from '@/component/FormUtil/ProficiencySelect'; +import PositionSelect from '@/component/FormUtil/PositionSelect'; +import AvailabilityCheckbox from '@/component/FormUtil/AvailabilityCheckbox'; +import SemesterCheckbox from '@/component/FormUtil/SemesterCheckbox'; +import AdditionalSemesterPrompt from '@/component/FormUtil/AddtlSemesterPrompt'; +import UpdateRole from '@/firebase/util/UpdateUserRole'; import { useAuth } from '@/firebase/auth/auth_context'; +import { Toaster, toast } from 'react-hot-toast'; +import { LinearProgress } from '@mui/material'; +import Snackbar from '@mui/material/Snackbar'; +import MuiAlert, { AlertProps } from '@mui/material/Alert'; +import { ApplicationStatusCard } from '@/components/ApplicationStatusCard/ApplicationStatusCard'; +import { useState } from 'react'; +import { TopNavBarSigned } from '@/component/TopNavBarSigned/TopNavBarSigned'; +import { EceLogoPng } from '@/component/EceLogoPng/EceLogoPng'; +import Users from '@/component/Dashboard/Users/Users'; + import GetUserRole from '@/firebase/util/GetUserRole'; -import UserGrid from '@/component/Dashboard/Users/UserGrid'; -import ApprovalGrid from '@/component/Dashboard/Users/ApprovalGrid'; +import GetUserUfid from '@/firebase/util/GetUserUfid'; +import { ApplicationStatusCardDenied } from '@/components/ApplicationStatusCardDenied/ApplicationStatusCardDenied'; -interface PageProps { } +import { ApplicationStatusCardAccepted } from '@/components/ApplicationStatusCardAccepted/ApplicationStatusCardAccepted'; +import styles from './style.module.css'; +import 'firebase/firestore'; -const User: FC = () => { +import firebase from '@/firebase/firebase_config'; +import { read, utils, writeFile, readFile } from 'xlsx'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import HeaderCard from '@/component/HeaderCard/HeaderCard'; + +export default function User() { const { user } = useAuth(); const [role, loading, error] = GetUserRole(user?.uid); + const [activeComponent, setActiveComponent] = React.useState('welcome'); + const [semester, setSemester] = React.useState('Fall 2024'); + + const handleChange = (event: SelectChangeEvent) => { + setSemester(event.target.value as string); + }; + + const readExcelFile = async (e) => { + // https://docs.sheetjs.com/docs/demos/local/file/ + console.log('ACTIVE'); + + try { + const val = e.target.files[0]; + const ab = await val.arrayBuffer(); + let data = []; + var file = read(ab); - // 0 = All Users, 1 = Unapproved Users - const [tab, setTab] = React.useState(0); + const sheets = file.SheetNames; - if (loading) return
Loading…
; + for (let i = 0; i < sheets.length; i++) { + const temp = utils.sheet_to_json(file.Sheets[file.SheetNames[i]]); + + temp.forEach((res) => { + data.push(res); + }); + } + for (let i = 0; i < data.length; i++) { + await firebase + .firestore() + .collection('courses') + .doc( + data[i]['__EMPTY_5'] + + ' (' + + semester + + ') ' + + ': ' + + data[i]['__EMPTY_22'] + ) + .set({ + professor_emails: + data[i]['__EMPTY_23'] == undefined + ? 'undef' + : data[i]['__EMPTY_23'], + professor_names: + data[i]['__EMPTY_22'] == undefined + ? 'undef' + : data[i]['__EMPTY_22'], + code: + data[i]['__EMPTY_5'] == undefined + ? 'undef' + : data[i]['__EMPTY_5'], + credits: + data[i]['__EMPTY_9'] == undefined + ? 'undef' + : data[i]['__EMPTY_9'], + enrollment_cap: + data[i]['__EMPTY_24'] == undefined + ? 'undef' + : data[i]['__EMPTY_24'], + enrolled: + data[i]['__EMPTY_26'] == undefined + ? 'undef' + : data[i]['__EMPTY_26'], + title: + data[i]['__EMPTY_21'] == undefined + ? 'undef' + : data[i]['__EMPTY_21'], + semester: semester, + }); + } + } catch (err) { + console.log(err); + } + }; + if (loading) return
Loadingƒ?İ
; if (error) return
Error loading role
; - if (role !== 'admin') return
Forbidden
; + if (role !== 'admin') return
Forbidden
; return ( - - + <> + - {/* Tabs Container */} - - - setTab(newValue)} - aria-label="users view switch" - TabIndicatorProps={{ - style: { - height: 3, - borderRadius: 2, - }, - }} - > - - - - - - - {/* Content */} - {tab === 0 ? ( - - - - ) : ( - - + + + + - )} - + + ); -}; - -export default User; - +} diff --git a/src/component/Dashboard/Users/ApprovalGrid.tsx b/src/component/Dashboard/Users/ApprovalGrid.tsx index f8cbe9e..d4e24ec 100644 --- a/src/component/Dashboard/Users/ApprovalGrid.tsx +++ b/src/component/Dashboard/Users/ApprovalGrid.tsx @@ -1,13 +1,11 @@ - 'use client'; - import * as React from 'react'; import Box from '@mui/material/Box'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/DeleteOutlined'; +import ThumbUpOffAltIcon from '@mui/icons-material/ThumbUpOffAlt'; import CancelIcon from '@mui/icons-material/Close'; import SaveIcon from '@mui/icons-material/Save'; -import { ThumbDownOffAlt, ThumbUpOffAlt } from '@mui/icons-material'; import { GridRowModesModel, @@ -24,16 +22,17 @@ import { GridRowId, GridRowModel, GridRowEditStopReasons, + useGridApiContext, gridClasses, } from '@mui/x-data-grid'; - -import { LinearProgress } from '@mui/material'; -import { styled } from '@mui/material/styles'; - +import { Button } from '@mui/material'; import { deleteUserHTTPRequest } from '@/firebase/auth/auth_delete_user'; import firebase from '@/firebase/firebase_config'; import 'firebase/firestore'; - +import { LinearProgress } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; +import CheckIcon from '@mui/icons-material/Check'; +import { ThumbDownOffAlt, ThumbUp, ThumbUpOffAlt } from '@mui/icons-material'; interface User { id: string; firstname: string; @@ -55,13 +54,17 @@ interface EditToolbarProps { ) => void; } -function EditToolbar(_props: EditToolbarProps) { - // ✅ match CourseGrid toolbar (default styling) +function EditToolbar(props: EditToolbarProps) { + const { setApplicationData, setRowModesModel } = props; + + // Add state to control the dialog open status + const [open, setOpen] = React.useState(false); + return ( - - - + + + ); } @@ -72,8 +75,6 @@ interface ApprovalGridProps { export default function ApprovalGrid(props: ApprovalGridProps) { const { userRole } = props; - - const [loading, setLoading] = React.useState(false); const [userData, setUserData] = React.useState([]); React.useEffect(() => { @@ -81,15 +82,14 @@ export default function ApprovalGrid(props: ApprovalGridProps) { .firestore() .collection('users') .where('role', '==', 'unapproved'); - const unsubscribe = usersRef.onSnapshot((querySnapshot) => { const data = querySnapshot.docs.map( (doc) => - ({ - id: doc.id, - fullname: `${doc.data().firstname ?? ''} ${doc.data().lastname ?? ''}`, - ...doc.data(), - } as User) + ({ + id: doc.id, + fullname: doc.data().firstname + ' ' + doc.data().lastname, + ...doc.data(), + } as User) ); setUserData(data); @@ -112,127 +112,164 @@ export default function ApprovalGrid(props: ApprovalGridProps) { }; const handleEditClick = (id: GridRowId) => () => { - setLoading(true); setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); - setLoading(false); }; - const handleSaveClick = (id: GridRowId) => async () => { - setLoading(true); - try { - const updatedRow = userData.find((row) => row.id === id); - if (!updatedRow) throw new Error(`No matching user data found for id: ${id}`); - - await firebase.firestore().collection('users').doc(id.toString()).update(updatedRow); - - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View }, - }); - } catch (error) { - console.error('Error updating document: ', error); - } finally { - setLoading(false); + const handleSaveClick = (id: GridRowId) => () => { + const updatedRow = userData.find((row) => row.id === id); + if (updatedRow) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .update(updatedRow) + .then(() => { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View }, + }); + }) + .catch((error) => { + console.error('Error updating document: ', error); + }); + } else { + console.error('No matching user data found for id: ', id); } }; - const handleApproveClick = (id: GridRowId) => async () => { - setLoading(true); - try { - await firebase.firestore().collection('users').doc(id.toString()).update({ role: 'faculty' }); - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View }, - }); - } catch (error) { - console.error('Error updating document: ', error); - } finally { - setLoading(false); + const handleApproveClick = (id: GridRowId) => () => { + const updatedRow = userData.find((row) => row.id === id); + if (updatedRow) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .update({ role: 'faculty' }) + .then(() => { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View }, + }); + }) + .catch((error) => { + console.error('Error updating document: ', error); + }); + } else { + console.error('No matching user data found for id: ', id); } }; - const handleDenyClick = (id: GridRowId) => async () => { - setLoading(true); - try { - await firebase.firestore().collection('users').doc(id.toString()).update({ role: 'denied' }); - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View }, - }); - } catch (error) { - console.error('Error updating document: ', error); - } finally { - setLoading(false); + const handleDenyClick = (id: GridRowId) => () => { + const updatedRow = userData.find((row) => row.id === id); + if (updatedRow) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .update({ role: 'denied' }) + .then(() => { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View }, + }); + }) + .catch((error) => { + console.error('Error updating document: ', error); + }); + } else { + console.error('No matching user data found for id: ', id); } }; - const handleDeleteClick = (id: GridRowId) => async () => { - setLoading(true); - try { - await firebase.firestore().collection('users').doc(id.toString()).delete(); - deleteUserHTTPRequest(id.toString()); - setUserData((prev) => prev.filter((row) => row.id !== id)); - } catch (error) { - console.error('Error removing document: ', error); - } finally { - setLoading(false); - } + const handleDeleteClick = (id: GridRowId) => () => { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .delete() + .then(() => { + deleteUserHTTPRequest(id.toString()); + setUserData(userData.filter((row) => row.id !== id)); + }) + .catch((error) => { + console.error('Error removing document: ', error); + }); }; - const handleCancelClick = (id: GridRowId) => async () => { - setLoading(true); - try { - const editedRow = userData.find((row) => row.id === id); - if (editedRow?.isNew) { - await firebase.firestore().collection('users').doc(id.toString()).delete(); - setUserData((prev) => prev.filter((row) => row.id !== id)); - } else { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true }, + const handleCancelClick = (id: GridRowId) => () => { + const editedRow = userData.find((row) => row.id === id); + if (editedRow!.isNew) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .delete() + .then(() => { + setUserData(userData.filter((row) => row.id !== id)); + }) + .catch((error) => { + console.error('Error removing document: ', error); }); - } - } catch (error) { - console.error('Error removing document: ', error); - } finally { - setLoading(false); + } else { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + }); } }; - const processRowUpdate = async (newRow: GridRowModel) => { - setLoading(true); - try { - const updatedRow = { ...(newRow as User), isNew: false }; - - if ((updatedRow as any).isNew) { - await firebase.firestore().collection('users').add(updatedRow); + const processRowUpdate = (newRow: GridRowModel) => { + const updatedRow = { ...(newRow as User), isNew: false }; + if (updatedRow) { + if (updatedRow.isNew) { + return firebase + .firestore() + .collection('users') + .add(updatedRow) + .then(() => { + setUserData( + userData.map((row) => (row.id === newRow.id ? updatedRow : row)) + ); + return updatedRow; + }) + .catch((error) => { + console.error('Error adding document: ', error); + throw error; + }); } else { - await firebase.firestore().collection('users').doc(updatedRow.id).update(updatedRow); + return firebase + .firestore() + .collection('users') + .doc(updatedRow.id) + .update(updatedRow) + .then(() => { + setUserData( + userData.map((row) => (row.id === newRow.id ? updatedRow : row)) + ); + return updatedRow; + }) + .catch((error) => { + console.error('Error updating document: ', error); + throw error; + }); } - - setUserData((prev) => - prev.map((row) => (row.id === newRow.id ? (updatedRow as User) : row)) + } else { + return Promise.reject( + new Error('No matching user data found for id: ' + newRow.id) ); - - return updatedRow; - } catch (error) { - console.error('Error processing row update: ', error); - throw error; - } finally { - setLoading(false); } }; - const columns: GridColDef[] = [ - { field: 'fullname', headerName: 'Full Name', width: 202, editable: true }, - { field: 'email', headerName: 'Email', width: 215, editable: true }, - { field: 'department', headerName: 'Department', width: 119, editable: true }, - { field: 'role', headerName: 'Role', width: 150, editable: true }, + const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { + setRowModesModel(newRowModesModel); + }; + + let columns: GridColDef[] = [ { field: 'actions', type: 'actions', headerName: 'Actions', - width: 180, + width: 280, cellClassName: 'actions', getActions: ({ id }) => { const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; @@ -240,17 +277,18 @@ export default function ApprovalGrid(props: ApprovalGridProps) { if (isInEditMode) { return [ } label="Save" - sx={{ color: 'primary.main' }} + sx={{ + color: 'primary.main', + }} onClick={handleSaveClick(id)} />, } label="Cancel" - className="textPrimary" onClick={handleCancelClick(id)} color="inherit" />, @@ -258,30 +296,42 @@ export default function ApprovalGrid(props: ApprovalGridProps) { } return [ - } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} + , + + , } label="Approve" onClick={handleApproveClick(id)} color="success" />, } label="Deny" onClick={handleDenyClick(id)} @@ -290,94 +340,104 @@ export default function ApprovalGrid(props: ApprovalGridProps) { ]; }, }, - ]; - - // ✅ Copy CourseGrid UI styling - const StripedDataGrid = styled(DataGrid)(() => ({ - border: 'none', - borderRadius: '16px', - fontFamily: 'Inter, sans-serif', - fontSize: '0.95rem', - - '& .MuiDataGrid-columnHeaders': { - backgroundColor: '#D8C6F8', - color: '#1C003D', - fontWeight: 700, - borderBottom: 'none', + { + field: 'fullname', + headerName: 'Full Name', + width: 202, + editable: true, }, - - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 700, + { field: 'email', headerName: 'Email', width: 215, editable: true }, + { + field: 'department', + headerName: 'Department', + width: 119, + editable: true, }, - - '& .MuiDataGrid-columnHeader:first-of-type': { - paddingLeft: '20px', + { + field: 'courses', + headerName: 'Course Code', + width: 300, + editable: true, }, - '& .MuiDataGrid-cell:first-of-type': { - paddingLeft: '25px', + { + field: 'semester', + headerName: 'Semester', + width: 130, + editable: true, }, + { field: 'role', headerName: 'Role', width: 100, editable: true }, + ]; + const ODD_OPACITY = 0.2; + const StripedDataGrid = styled(DataGrid)(({ theme }) => ({ [`& .${gridClasses.row}.even`]: { - backgroundColor: '#FFFFFF', - }, - [`& .${gridClasses.row}.odd`]: { - backgroundColor: '#EEEEEE', - }, - - '& .MuiDataGrid-row:hover': { - backgroundColor: '#EFE6FF', - }, - - '& .MuiDataGrid-cell': { - borderBottom: '1px solid #ECE4FA', - }, - - '& .MuiDataGrid-footerContainer': { - borderTop: 'none', - }, - - '& .MuiTablePagination-root': { - color: '#5D3FC4', - fontWeight: 500, + backgroundColor: '#562EBA1F', + '&:hover, &.Mui-hovered': { + backgroundColor: alpha(theme.palette.primary.main, ODD_OPACITY), + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + '&.Mui-selected': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + theme.palette.action.selectedOpacity + ), + '&:hover, &.Mui-hovered': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + + theme.palette.action.selectedOpacity + + theme.palette.action.hoverOpacity + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + theme.palette.action.selectedOpacity + ), + }, + }, + }, }, })); return ( - {loading ? : null} - setRowModesModel(m)} + onRowModesModelChange={handleRowModesModelChange} onRowEditStop={handleRowEditStop} processRowUpdate={processRowUpdate} - onProcessRowUpdateError={(error) => - console.error('Error processing row update: ', error) - } - slots={{ toolbar: EditToolbar }} - slotProps={{ toolbar: { setApplicationData: setUserData, setRowModesModel } }} initialState={{ pagination: { paginationModel: { pageSize: 25 } }, }} getRowClassName={(params) => params.indexRelativeToCurrentPage % 2 === 0 ? 'even' : 'odd' } + sx={{ borderRadius: '16px' }} /> ); } - diff --git a/src/component/Dashboard/Users/UserGrid.tsx b/src/component/Dashboard/Users/UserGrid.tsx index 7fe1f69..dcd31d9 100644 --- a/src/component/Dashboard/Users/UserGrid.tsx +++ b/src/component/Dashboard/Users/UserGrid.tsx @@ -1,5 +1,4 @@ 'use client'; - import * as React from 'react'; import Box from '@mui/material/Box'; import EditIcon from '@mui/icons-material/Edit'; @@ -21,6 +20,7 @@ import { GridRowId, GridRowModel, GridRowEditStopReasons, + useGridApiContext, gridClasses, } from '@mui/x-data-grid'; import { @@ -29,14 +29,13 @@ import { DialogContent, DialogContentText, DialogTitle, - LinearProgress, - Button, + TextField, } from '@mui/material'; -import { styled } from '@mui/material/styles'; - +import { deleteUserHTTPRequest } from '@/firebase/auth/auth_delete_user'; import firebase from '@/firebase/firebase_config'; import 'firebase/firestore'; -import { deleteUserHTTPRequest } from '@/firebase/auth/auth_delete_user'; +import { LinearProgress, Button } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; import { isE2EMode } from '@/utils/featureFlags'; interface User { @@ -59,13 +58,18 @@ interface EditToolbarProps { ) => void; } -function EditToolbar(_props: EditToolbarProps) { - // match CourseGrid toolbar look (default MUI icons/colors) +function EditToolbar(props: EditToolbarProps) { + const { setUserData, setRowModesModel } = props; + + // Add state to control the dialog open status + const [open, setOpen] = React.useState(false); + return ( - - - + {/* Include your Dialog component here and pass the open state and setOpen function as props */} + + + ); } @@ -77,35 +81,35 @@ interface UserGridProps { export default function UserGrid(props: UserGridProps) { const { userRole } = props; const e2e = isE2EMode(); - - const [loading, setLoading] = React.useState(false); const [userData, setUserData] = React.useState([]); - + const [open, setOpen] = React.useState(false); const [delDia, setDelDia] = React.useState(false); - const [delId, setDelId] = React.useState(null); + const [delId, setDelId] = React.useState(); React.useEffect(() => { if (e2e) { setUserData([]); return; } - const usersRef = firebase.firestore().collection('users'); const unsubscribe = usersRef.onSnapshot((querySnapshot) => { const data = querySnapshot.docs.map( (doc) => - ({ - id: doc.id, - fullname: `${doc.data().firstname ?? ''} ${doc.data().lastname ?? ''}`, - ...doc.data(), - } as unknown as User) + ({ + id: doc.id, + fullname: doc.data().firstname + ' ' + doc.data().lastname, + ...doc.data(), + } as unknown as User) ); + setUserData(data); }); return () => unsubscribe(); }, [e2e]); - + const handleDeleteDiagClose = () => { + setDelDia(false); + }; const [rowModesModel, setRowModesModel] = React.useState( {} ); @@ -120,27 +124,32 @@ export default function UserGrid(props: UserGridProps) { }; const handleEditClick = (id: GridRowId) => () => { - setLoading(true); setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); - setLoading(false); }; - const handleSaveClick = (id: GridRowId) => async () => { - setLoading(true); - try { - const updatedRow = userData.find((row) => row.id === id); - if (!updatedRow) throw new Error(`No matching user data for id: ${id}`); - - await firebase.firestore().collection('users').doc(id.toString()).update(updatedRow); - - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View }, - }); - } catch (err) { - console.error('Error updating document: ', err); - } finally { - setLoading(false); + const handleSaveClick = (id: GridRowId) => () => { + console.log('Clicked Save for ID:', id); + console.log('Current userData:', userData); + + const updatedRow = userData.find((row) => row.id === id); + if (updatedRow) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .update(updatedRow) + .then(() => { + console.log('Document successfully updated!'); + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View }, + }); + }) + .catch((error) => { + console.error('Error updating document: ', error); + }); + } else { + console.error('No matching user data found for id: ', id); } }; @@ -149,89 +158,111 @@ export default function UserGrid(props: UserGridProps) { setDelDia(true); }; - const handleDeleteDiagClose = () => { - setDelDia(false); - setDelId(null); - }; - - const handleDeleteClick = async (id: GridRowId) => { - setLoading(true); - try { - await firebase.firestore().collection('users').doc(id.toString()).delete(); - deleteUserHTTPRequest(id.toString()); - setUserData((prev) => prev.filter((row) => row.id !== id)); - } catch (err) { - console.error('Error removing document: ', err); - } finally { - setLoading(false); - } + const handleDeleteClick = (id: GridRowId) => { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .delete() + .then(() => { + deleteUserHTTPRequest(id.toString()); + setUserData(userData.filter((row) => row.id !== id)); + }) + .catch((error) => { + console.error('Error removing document: ', error); + }); }; - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e) => { e.preventDefault(); - if (delId == null) return; - await handleDeleteClick(delId); + console.log(delId.toString()); + handleDeleteClick(delId); setDelDia(false); - setDelId(null); }; - - const handleCancelClick = (id: GridRowId) => async () => { - setLoading(true); - try { - const editedRow = userData.find((row) => row.id === id); - if (editedRow?.isNew) { - await firebase.firestore().collection('users').doc(id.toString()).delete(); - setUserData((prev) => prev.filter((row) => row.id !== id)); - } else { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true }, + function CustomToolbar() { + const apiRef = useGridApiContext(); + + return ( + + + + ); + } + + const handleCancelClick = (id: GridRowId) => () => { + const editedRow = userData.find((row) => row.id === id); + if (editedRow!.isNew) { + firebase + .firestore() + .collection('users') + .doc(id.toString()) + .delete() + .then(() => { + setUserData(userData.filter((row) => row.id !== id)); + }) + .catch((error) => { + console.error('Error removing document: ', error); }); - } - } catch (err) { - console.error('Error canceling edit: ', err); - } finally { - setLoading(false); + } else { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + }); } }; - const processRowUpdate = async (newRow: GridRowModel) => { - setLoading(true); - try { - const updatedRow = { ...(newRow as User), isNew: false }; - - // new rows not really used in your flow, but keep parity with CourseGrid - if ((updatedRow as any).isNew) { - await firebase.firestore().collection('users').add(updatedRow); + const processRowUpdate = (newRow: GridRowModel) => { + const updatedRow = { ...(newRow as User), isNew: false }; + if (updatedRow) { + if (updatedRow.isNew) { + return firebase + .firestore() + .collection('users') + .add(updatedRow) + .then(() => { + setUserData( + userData.map((row) => (row.id === newRow.id ? updatedRow : row)) + ); + return updatedRow; + }) + .catch((error) => { + console.error('Error adding document: ', error); + throw error; + }); } else { - await firebase.firestore().collection('users').doc(updatedRow.id).update(updatedRow); + return firebase + .firestore() + .collection('users') + .doc(updatedRow.id) + .update(updatedRow) + .then(() => { + setUserData( + userData.map((row) => (row.id === newRow.id ? updatedRow : row)) + ); + return updatedRow; + }) + .catch((error) => { + console.error('Error updating document: ', error); + throw error; + }); } - - setUserData((prev) => - prev.map((row) => (row.id === newRow.id ? (updatedRow as User) : row)) + } else { + return Promise.reject( + new Error('No matching user data found for id: ' + newRow.id) ); - - return updatedRow; - } catch (err) { - console.error('Error processing row update: ', err); - throw err; - } finally { - setLoading(false); } }; + const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { + setRowModesModel(newRowModesModel); + }; + const columns: GridColDef[] = [ - { field: 'firstname', headerName: 'First Name', width: 150, editable: true }, - { field: 'lastname', headerName: 'Last Name', width: 150, editable: true }, - { field: 'email', headerName: 'Email', width: 250, editable: true }, - { field: 'department', headerName: 'Department', width: 130, editable: true }, - { field: 'role', headerName: 'Role', width: 150, editable: true }, - { field: 'id', headerName: 'User ID', width: 290, editable: true }, { field: 'actions', type: 'actions', headerName: 'Actions', - width: 130, + width: 200, cellClassName: 'actions', getActions: ({ id }) => { const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; @@ -239,14 +270,16 @@ export default function UserGrid(props: UserGridProps) { if (isInEditMode) { return [ } label="Save" - sx={{ color: 'primary.main' }} + sx={{ + color: '#562EBA', + }} onClick={handleSaveClick(id)} />, } label="Cancel" className="textPrimary" @@ -257,119 +290,100 @@ export default function UserGrid(props: UserGridProps) { } return [ - } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} + , + + , ]; }, }, - ]; - - // ✅ Copy CourseGrid UI styling - const StripedDataGrid = styled(DataGrid)(() => ({ - border: 'none', - borderRadius: '16px', - fontFamily: 'Inter, sans-serif', - fontSize: '0.95rem', - - '& .MuiDataGrid-columnHeaders': { - backgroundColor: '#D8C6F8', - color: '#1C003D', - fontWeight: 700, - borderBottom: 'none', - }, - - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 700, - }, - - '& .MuiDataGrid-columnHeader:first-of-type': { - paddingLeft: '20px', + { + field: 'firstname', + headerName: 'First Name', + width: 150, + editable: true, }, - '& .MuiDataGrid-cell:first-of-type': { - paddingLeft: '25px', + { field: 'lastname', headerName: 'Last Name', width: 150, editable: true }, + { field: 'email', headerName: 'Email', width: 250, editable: true }, + { + field: 'department', + headerName: 'Department', + width: 130, + editable: true, }, + { field: 'role', headerName: 'Role', width: 150, editable: true }, + { field: 'id', headerName: 'User ID', width: 290, editable: true }, + ]; + const ODD_OPACITY = 0.2; + const StripedDataGrid = styled(DataGrid)(({ theme }) => ({ [`& .${gridClasses.row}.even`]: { - backgroundColor: '#FFFFFF', - }, - [`& .${gridClasses.row}.odd`]: { - backgroundColor: '#EEEEEE', - }, - - '& .MuiDataGrid-row:hover': { - backgroundColor: '#EFE6FF', - }, - - '& .MuiDataGrid-cell': { - borderBottom: '1px solid #ECE4FA', - }, - - '& .MuiDataGrid-footerContainer': { - borderTop: 'none', - }, - - '& .MuiTablePagination-root': { - color: '#5D3FC4', - fontWeight: 500, + backgroundColor: '#562EBA1F', + '&:hover, &.Mui-hovered': { + backgroundColor: alpha(theme.palette.primary.main, ODD_OPACITY), + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + '&.Mui-selected': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + theme.palette.action.selectedOpacity + ), + '&:hover, &.Mui-hovered': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + + theme.palette.action.selectedOpacity + + theme.palette.action.hoverOpacity + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: alpha( + theme.palette.primary.main, + ODD_OPACITY + theme.palette.action.selectedOpacity + ), + }, + }, + }, }, })); - return ( - <> - - {loading ? : null} - - setRowModesModel(m)} - onRowEditStop={handleRowEditStop} - processRowUpdate={processRowUpdate} - onProcessRowUpdateError={(error) => - console.error('Error processing row update: ', error) - } - slots={{ - toolbar: EditToolbar, - }} - slotProps={{ - toolbar: { setUserData, setRowModesModel }, - }} - initialState={{ - pagination: { paginationModel: { pageSize: 25 } }, - }} - getRowClassName={(params) => - params.indexRelativeToCurrentPage % 2 === 0 ? 'even' : 'odd' - } - /> - - - {/* keep your confirm-delete dialog (CourseGrid doesn't have it, but UI matches your app style) */} + @@ -392,7 +408,7 @@ export default function UserGrid(props: UserGridProps) { > Delete User -
+ handleSubmit(e)}>
- + + params.indexRelativeToCurrentPage % 2 === 0 ? 'even' : 'odd' + } + sx={{ borderRadius: '16px' }} + /> +
); } - diff --git a/src/components/TopBar/TopBar.tsx b/src/components/TopBar/TopBar.tsx index dec24ba..f44897e 100644 --- a/src/components/TopBar/TopBar.tsx +++ b/src/components/TopBar/TopBar.tsx @@ -2,17 +2,37 @@ import { useUserInfo } from '@/hooks/User/useGetUserInfo'; import { AppBar } from '@mui/material'; import Toolbar from '@mui/material/Toolbar'; import IconButton from '@mui/material/IconButton'; +import Badge from '@mui/material/Badge'; import NotificationsNoneOutlinedIcon from '@mui/icons-material/NotificationsNoneOutlined'; import NotificationsActiveOutlinedIcon from '@mui/icons-material/NotificationsActiveOutlined'; import AccountCircleTwoToneIcon from '@mui/icons-material/AccountCircleTwoTone'; import { EceLogoPng } from '@/component/EceLogoPng/EceLogoPng'; import Link from 'next/link'; import { Role, roleMapping } from '@/types/User'; -import { useAnnouncements } from '@/contexts/AnnouncementsContext'; +import { useFetchAnnouncementsForAccount } from '@/hooks/Announcements/useFetchAnnouncements'; +// type TopNavProps = { +// notification?: boolean; +// }; export default function TopNav() { const [user, role, loading, error] = useUserInfo(); - const { unread } = useAnnouncements(); + const uemail = user?.email; + const { + read, + unread, + loading: announcementLoading, + loadingMore, + hasMore, + error: fetchError, + refresh, + loadMore, + } = useFetchAnnouncementsForAccount({ + userRole: role, + userEmail: uemail, + userDepartment: 'ECE', + channel: 'inApp', + realtime: true, + }); const onNotifications = () => {}; const display = (v: unknown, fallback = 'Not listed'): string => { if (v == null) return fallback; // null/undefined diff --git a/src/contexts/AnnouncementsContext.tsx b/src/contexts/AnnouncementsContext.tsx deleted file mode 100644 index 3ba4682..0000000 --- a/src/contexts/AnnouncementsContext.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'use client'; - -import React, { - createContext, - useContext, - useEffect, - useState, - useMemo, - useCallback, - ReactNode, -} from 'react'; -import firebase from '@/firebase/firebase_config'; -import 'firebase/firestore'; -import { useAuth } from '@/firebase/auth/auth_context'; -import GetUserRole from '@/firebase/util/GetUserRole'; -import GetAnnouncementTimestamp from '@/firebase/util/GetAnnouncementTimestamp'; -import { - Announcement, - Audience, - AudienceDepartment, - AudienceRole, -} from '@/types/announcement'; -import { Role } from '@/types/User'; - -type AnnouncementsContextType = { - read: Announcement[]; - unread: Announcement[]; - loading: boolean; - error: Error | null; - refresh: () => void; -}; - -const AnnouncementsContext = createContext( - null -); - -function convertRole(role: string): AudienceRole { - switch (role) { - case 'admin': - return 'admin'; - case 'faculty': - return 'faculty'; - default: - return 'student'; - } -} - -function toDate(v: any): Date | null { - if (!v) return null; - if (v instanceof Date) return v; - if (typeof v.toDate === 'function') return v.toDate(); - return null; -} - -function mapDoc(doc: firebase.firestore.QueryDocumentSnapshot): Announcement { - const d = doc.data() as any; - - return { - id: doc.id, - title: d.title ?? '', - bodyMd: d.bodyMd ?? '', - pinned: !!d.pinned, - - createdAt: toDate(d.createdAt), - updatedAt: toDate(d.updatedAt), - scheduledAt: toDate(d.scheduledAt), - expiresAt: toDate(d.expiresAt), - - senderId: d.senderId ?? '', - senderName: d.senderName ?? null, - audienceTokens: Array.isArray(d.audienceTokens) ? d.audienceTokens : [], - channels: Array.isArray(d.channels) ? d.channels : [], - audience: (d.audience ?? { type: 'all' }) as Audience, - - dispatchStatus: d.dispatchStatus ?? 'unknown', - }; -} - -type AnnouncementsProviderProps = { - children: ReactNode; -}; - -export function AnnouncementsProvider({ - children, -}: AnnouncementsProviderProps) { - const { user } = useAuth(); - const [roleData, roleLoading] = GetUserRole(user?.uid); - const [timestamp] = GetAnnouncementTimestamp(user?.uid); - - const [announcements, setAnnouncements] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [refreshTrigger, setRefreshTrigger] = useState(0); - - const userEmail = user?.email ?? ''; - const userDepartment: AudienceDepartment = 'ECE'; - const channel = 'inApp'; - - const userRole = useMemo(() => { - return convertRole(roleData ?? ''); - }, [roleData]); - - const buildQuery = useCallback(() => { - const col = firebase.firestore().collection('announcements'); - let q: firebase.firestore.Query = col; - - q = q.where(`channels.${channel}`, '==', true); - q = q.where('dispatchStatus', '==', 'completed'); - q = q.orderBy('pinned', 'desc').orderBy('createdAt', 'desc'); - - if (userRole === 'admin') { - return q; - } - - const isNonEmpty = (v: string | null | undefined): v is string => - typeof v === 'string' && v.trim().length > 0; - - const norm = (s: string) => s.trim().toLowerCase(); - - const tokens = [ - 'all', - userRole ? `role:${norm(userRole)}` : null, - userRole === 'faculty' ? `role:student` : null, - userDepartment ? `dept:${norm(userDepartment)}` : null, - userEmail ? `user:${norm(userEmail)}` : null, - ].filter(isNonEmpty); - - q = q.where('audienceTokens', 'array-contains-any', tokens); - return q; - }, [userRole, userDepartment, userEmail, channel]); - - useEffect(() => { - if (roleLoading || !user) { - return; - } - - setLoading(true); - setError(null); - - const q = buildQuery().limit(50); - - const unsub = q.onSnapshot( - (snap) => { - const docs = snap.docs; - const items = docs.map(mapDoc); - setAnnouncements(items); - setLoading(false); - }, - (err) => { - console.error('Announcements listener error:', err); - setError(err); - setLoading(false); - } - ); - - return () => { - unsub(); - }; - }, [buildQuery, roleLoading, user, refreshTrigger]); - - const refresh = useCallback(() => { - setRefreshTrigger((prev) => prev + 1); - }, []); - - const { read, unread } = useMemo(() => { - return announcements.reduce( - (acc, a) => { - const tMs = a.scheduledAt ?? a.createdAt ?? new Date(Date.now()); - const isUnread = timestamp === null ? true : tMs > timestamp; - - (isUnread ? acc.unread : acc.read).push(a); - return acc; - }, - { unread: [] as Announcement[], read: [] as Announcement[] } - ); - }, [announcements, timestamp]); - - const value = useMemo( - () => ({ - read, - unread, - loading: loading || roleLoading, - error, - refresh, - }), - [read, unread, loading, roleLoading, error, refresh] - ); - - return ( - - {children} - - ); -} - -export function useAnnouncements(): AnnouncementsContextType { - const context = useContext(AnnouncementsContext); - if (!context) { - throw new Error( - 'useAnnouncements must be used within an AnnouncementsProvider' - ); - } - return context; -}