diff --git a/components.json b/components.json new file mode 100644 index 0000000..dff274b --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "styles/globals.css", + "baseColor": "neutral", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks", + "utils": "@/lib/utils" + }, + "iconLibrary": "lucide" +} diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx deleted file mode 100644 index 4c00f1c..0000000 --- a/components/GlobalSnackbar.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Snackbar from "@mui/material/Snackbar"; -import Alert, { type AlertColor } from "@mui/material/Alert"; - -/** - * GlobalSnackbar component displays a Snackbar with an Alert. - * - * @param {Object} props - The properties object. - * @param {number} [props.duration=10000] - Duration in milliseconds for which the Snackbar is visible. - * @param {string} [props.message='The data was saved!'] - The message to display inside the Alert. - * @param {boolean} props.open - Boolean flag to control the visibility of the Snackbar. - * @param {Function} props.setOpen - Function to set the open state of the Snackbar. - * @param {string} [props.severity='success'] - The severity level of the Alert. Can be 'error', 'warning', 'info', or 'success'. - * @returns {JSX.Element} The rendered GlobalSnackbar component. - */ -type GlobalSnackbarProps = { - duration?: number; - message?: string; - open: boolean; - setOpen: (_open: boolean) => void; - severity?: AlertColor; -}; - -const GlobalSnackbar = (props: GlobalSnackbarProps) => { - const { duration = 10000, message = "The data was saved!", setOpen, severity = "success" } = props; - const handleClose = () => setOpen(false); - - return ( -
- - - {message} - - -
- ); -}; - -export default GlobalSnackbar; diff --git a/components/TelecallerRemarksModal.tsx b/components/TelecallerRemarksModal.tsx index 7bd6013..3c46416 100644 --- a/components/TelecallerRemarksModal.tsx +++ b/components/TelecallerRemarksModal.tsx @@ -16,9 +16,8 @@ import { AgGridReact } from "ag-grid-react"; import { useState, useEffect, useCallback } from "react"; import { smartComparator } from "@/utils/grid-comparators"; import ConfirmationModal from "./ConfirmationModal"; -import GlobalSnackbar from "./GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; import { dateTimeFormatter, parseDateTimeFromDB } from "@/utils/date-normalizers"; -import type { AlertColor } from "@mui/material/Alert"; type Remark = { id?: string | number; @@ -31,6 +30,7 @@ type Remark = { type SessionUser = { email?: string }; const TelecallerRemarksModal = ({ open, onClose, student }) => { + const notify = useNotification(); const [remarks, setRemarks] = useState([]); const [newRemark, setNewRemark] = useState(""); const [loading, setLoading] = useState(false); @@ -38,9 +38,6 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { const [currentUser, setCurrentUser] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [remarkToDelete, setRemarkToDelete] = useState(null); - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); - const [snackbarSeverity, setSnackbarSeverity] = useState("success"); // Custom cell renderer for actions column const ActionsCellRenderer = (props: ICellRendererParams) => { @@ -171,20 +168,14 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { await fetchRemarks(); // Refresh the remarks list setRemarkToDelete(null); // Show success toast - setSnackbarMessage("Remark deleted successfully!"); - setSnackbarSeverity("success"); - setSnackbarOpen(true); + notify("Remark deleted successfully!", "success"); } else { console.error("Failed to delete remark"); - setSnackbarMessage("Failed to delete remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to delete remark. Please try again.", "error"); } } catch (error) { console.error("Error deleting remark:", error); - setSnackbarMessage("Failed to delete remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to delete remark. Please try again.", "error"); } finally { setLoading(false); } @@ -210,24 +201,18 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { if (response.ok) { // Show success message - setSnackbarMessage("Remark updated successfully!"); - setSnackbarSeverity("success"); - setSnackbarOpen(true); + notify("Remark updated successfully!", "success"); // Refresh the grid to get the updated timestamp await fetchRemarks(); } else { console.error("Failed to update remark"); - setSnackbarMessage("Failed to update remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to update remark. Please try again.", "error"); // Revert the change params.node.setDataValue(params.colDef.field, params.oldValue); } } catch (error) { console.error("Error updating remark:", error); - setSnackbarMessage("Failed to update remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to update remark. Please try again.", "error"); // Revert the change params.node.setDataValue(params.colDef.field, params.oldValue); } @@ -270,16 +255,13 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { console.error("Failed to fetch remarks:", errorMessage); - // Show user-friendly error message based on status code - if (response.status === 401) { - setSnackbarMessage("You are not authorized to view remarks. Please log in again."); - } else if (response.status === 400) { - setSnackbarMessage("Invalid request. Please refresh the page and try again."); - } else { - setSnackbarMessage("Failed to fetch remarks. Please try again."); - } - setSnackbarSeverity("error"); - setSnackbarOpen(true); + const userMessage = + response.status === 401 + ? "You are not authorized to view remarks. Please log in again." + : response.status === 400 + ? "Invalid request. Please refresh the page and try again." + : "Failed to fetch remarks. Please try again."; + notify(userMessage, "error"); setRemarks([]); // Set empty array on error return; } @@ -296,18 +278,17 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { setRemarks(Array.isArray(data) ? data : []); } catch (error) { console.error("Error fetching remarks:", error); - setSnackbarMessage( + notify( error.message?.includes("JSON") ? "Invalid response from server. Please try again." - : "Error loading remarks. Please try again." + : "Error loading remarks. Please try again.", + "error" ); - setSnackbarSeverity("error"); - setSnackbarOpen(true); setRemarks([]); // Set empty array on error } finally { setLoading(false); } - }, [student?.id]); + }, [student?.id, notify]); // Submit new remark const handleSubmitRemark = async () => { @@ -425,14 +406,6 @@ const TelecallerRemarksModal = ({ open, onClose, student }) => { message={`Are you sure you want to delete this remark? This action cannot be undone.`} confirmColor="error" /> - - ); }; diff --git a/components/batches/RemarksModal.tsx b/components/batches/RemarksModal.tsx index 0e445b1..afbec16 100644 --- a/components/batches/RemarksModal.tsx +++ b/components/batches/RemarksModal.tsx @@ -16,8 +16,7 @@ import { AgGridReact } from "ag-grid-react"; import React, { useCallback, useEffect, useState } from "react"; import { smartComparator } from "@/utils/grid-comparators"; import ConfirmationModal from "../ConfirmationModal"; -import GlobalSnackbar from "../GlobalSnackbar"; -import type { AlertColor } from "@mui/material/Alert"; +import { useNotification } from "@/components/notifications/NotificationProvider"; type Remark = { id?: string | number; @@ -28,6 +27,7 @@ type Remark = { type SessionUser = { id?: string | number }; const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { + const notify = useNotification(); const [remarks, setRemarks] = useState([]); const [newRemark, setNewRemark] = useState(""); const [loading, setLoading] = useState(false); @@ -35,9 +35,6 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { const [currentUser, setCurrentUser] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [remarkToDelete, setRemarkToDelete] = useState(null); - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); - const [snackbarSeverity, setSnackbarSeverity] = useState("success"); const [dataChanged, setDataChanged] = useState(false); // Custom cell renderer for actions column @@ -141,20 +138,14 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { setRemarkToDelete(null); setDataChanged(true); // Mark that data has changed // Show success toast - setSnackbarMessage("Remark deleted successfully!"); - setSnackbarSeverity("success"); - setSnackbarOpen(true); + notify("Remark deleted successfully!", "success"); } else { console.error("Failed to delete remark"); - setSnackbarMessage("Failed to delete remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to delete remark. Please try again.", "error"); } } catch (error) { console.error("Error deleting remark:", error); - setSnackbarMessage("Failed to delete remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to delete remark. Please try again.", "error"); } finally { setLoading(false); } @@ -181,22 +172,16 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { if (response.ok) { setDataChanged(true); // Mark that data has changed // Show success message - setSnackbarMessage("Remark updated successfully!"); - setSnackbarSeverity("success"); - setSnackbarOpen(true); + notify("Remark updated successfully!", "success"); } else { console.error("Failed to update remark"); - setSnackbarMessage("Failed to update remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to update remark. Please try again.", "error"); // Revert the change params.node.setDataValue(params.colDef.field, params.oldValue); } } catch (error) { console.error("Error updating remark:", error); - setSnackbarMessage("Failed to update remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to update remark. Please try again.", "error"); // Revert the change params.node.setDataValue(params.colDef.field, params.oldValue); } finally { @@ -216,19 +201,15 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { setRemarks(data); } else { console.error("Failed to fetch remarks"); - setSnackbarMessage("Failed to fetch remarks."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to fetch remarks.", "error"); } } catch (error) { console.error("Error fetching remarks:", error); - setSnackbarMessage("Error loading remarks."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Error loading remarks.", "error"); } finally { setLoading(false); } - }, [student?.id, batchId]); + }, [student?.id, batchId, notify]); // Submit new remark const handleSubmitRemark = async () => { @@ -252,20 +233,14 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { setNewRemark(""); // Clear the input await fetchRemarks(); // Refresh the remarks list setDataChanged(true); // Mark that data has changed - setSnackbarMessage("Remark added successfully!"); - setSnackbarSeverity("success"); - setSnackbarOpen(true); + notify("Remark added successfully!", "success"); } else { console.error("Failed to submit remark"); - setSnackbarMessage("Failed to add remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to add remark. Please try again.", "error"); } } catch (error) { console.error("Error submitting remark:", error); - setSnackbarMessage("Failed to add remark. Please try again."); - setSnackbarSeverity("error"); - setSnackbarOpen(true); + notify("Failed to add remark. Please try again.", "error"); } finally { setSubmitting(false); } @@ -364,14 +339,6 @@ const RemarksModal = ({ open, onClose, student, batchId, onDataChange }) => { message={`Are you sure you want to delete this remark? This action cannot be undone.`} confirmColor="error" /> - - ); }; diff --git a/components/notifications/NotificationProvider.tsx b/components/notifications/NotificationProvider.tsx new file mode 100644 index 0000000..69b447f --- /dev/null +++ b/components/notifications/NotificationProvider.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Toast } from "@base-ui/react/toast"; +import { createContext, type PropsWithChildren, useCallback, useContext, useRef } from "react"; +import { ToastViewport, type ToastSeverity } from "@/components/ui/toast"; + +type PendingNotification = { + id: string; + message: string; + severity: ToastSeverity; +}; + +type Notify = (message: string, severity?: ToastSeverity) => void; + +const NOTIFICATION_TIMEOUT_MS = 5_000; + +const NotificationContext = createContext(null); + +const severityLabels: Record = { + success: "Success", + info: "Information", + warning: "Warning", + error: "Error", +}; + +export function NotificationProvider({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + +function NotificationController({ children }: PropsWithChildren) { + const { add } = Toast.useToastManager(); + const pendingNotifications = useRef([]); + const activeNotificationId = useRef(null); + const nextId = useRef(0); + const processQueueRef = useRef<() => void>(() => {}); + + const processNextNotification = useCallback(() => { + if (activeNotificationId.current || pendingNotifications.current.length === 0) return; + + const notification = pendingNotifications.current.shift(); + if (!notification) return; + + activeNotificationId.current = notification.id; + add({ + id: notification.id, + title: severityLabels[notification.severity], + description: notification.message, + type: notification.severity, + priority: notification.severity === "error" ? "high" : "low", + timeout: NOTIFICATION_TIMEOUT_MS, + onRemove: () => { + if (activeNotificationId.current === notification.id) { + activeNotificationId.current = null; + } + queueMicrotask(() => processQueueRef.current()); + }, + }); + }, [add]); + + processQueueRef.current = processNextNotification; + + const notify = useCallback((message, severity = "success") => { + const normalizedMessage = message.trim(); + if (!normalizedMessage) return; + + pendingNotifications.current.push({ + id: `application-notification-${nextId.current++}`, + message: normalizedMessage, + severity, + }); + processQueueRef.current(); + }, []); + + return ( + + {children} + + + ); +} + +export function useNotification(): Notify { + const notify = useContext(NotificationContext); + if (!notify) { + throw new Error("useNotification must be used inside NotificationProvider"); + } + return notify; +} diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..67006d0 --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Toast } from "@base-ui/react/toast"; + +export type ToastSeverity = "success" | "info" | "warning" | "error"; + +const severityBorderColors: Record = { + success: "#16a34a", + info: "#2563eb", + warning: "#f59e0b", + error: "#dc2626", +}; + +export function ToastViewport() { + const { toasts } = Toast.useToastManager(); + + return ( + + + {toasts.map((toast) => { + const severity = + toast.type === "success" || toast.type === "info" || toast.type === "warning" || toast.type === "error" + ? toast.type + : "info"; + + return ( + + +
+ + +
+ + Dismiss + +
+
+ ); + })} +
+
+ ); +} diff --git a/openspec/changes/archive/2026-10-05-accessible-global-notifications/.openspec.yaml b/openspec/changes/archive/2026-10-05-accessible-global-notifications/.openspec.yaml new file mode 100644 index 0000000..e3966d7 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-accessible-global-notifications/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/archive/2026-10-05-accessible-global-notifications/design.md b/openspec/changes/archive/2026-10-05-accessible-global-notifications/design.md new file mode 100644 index 0000000..6501cff --- /dev/null +++ b/openspec/changes/archive/2026-10-05-accessible-global-notifications/design.md @@ -0,0 +1,60 @@ +## Context + +See `proposal.md` for motivation and `specs/accessible-notifications/spec.md` for behavior requirements. The app uses Next.js Pages Router, React 19, and Tailwind CSS 3.4. It has no shadcn/ui configuration or Base UI dependency today. `components/GlobalSnackbar.tsx` wraps MUI `Snackbar` and `Alert`, while individual pages and components own notification state. `pages/_app.tsx` is the shared app root. Browser alerts also appear in report and batch workflows, including `utils/generate-batch-status-report.ts`. + +The existing Tailwind setup disables preflight, and `tsconfig.json` already defines the `@/*` import alias. Notification call sites include reports, batches, batch details, configuration, users, courses, students, student registration, bulk registration, and remarks components. Inline field errors and explicit `confirm()` / confirmation-modal flows have separate purposes. + +## Goals / Non-Goals + +**Goals:** + +- Provide one app-wide shadcn Toast provider, notification API, and visible viewport for transient application notices. +- Use the Toast component's announcement priorities to announce routine messages politely and errors urgently, including repeated messages with identical text. +- Preserve severity distinctions, keyboard access and dismissal, and the five-second baseline for automatic dismissal. +- Ensure notification logic in data/report utilities returns outcomes to UI callers rather than opening browser dialogs itself. + +**Non-Goals:** + +- Replacing inline field validation, page-level form error associations, or dialogs that require a decision. +- Changing the wording or business rules behind existing operation messages except where needed to make an outcome clear. +- Migrating unrelated MUI components or replacing Tailwind CSS as part of this notification change. + +## Decisions + +### Use shadcn/ui's Base UI Toast component + +Add the shadcn Toast component built on Base UI and its `@base-ui/react` dependency. Create the minimal shadcn component configuration needed for this existing Pages Router and Tailwind 3 project, reusing the `@/*` alias and preserving the disabled Tailwind preflight. Keep the generated Toast implementation local under `components/ui/` so it can follow the app's styling and accessible-name requirements. + +The Toast API supplies `success`, `info`, `warning`, and `error` types for visual status and `low` / `high` announcement priorities. Map routine success, info, and warning messages to low priority, and errors to high priority. Set the provider's default timeout to five seconds, pause dismissal while the pointer is over a toast or keyboard focus is inside it, and retain keyboard-operable dismissal. Use the Toast viewport's keyboard navigation support and ensure the spoken title/description contains the full message. + +The current shadcn Base UI Toast component supports an app-wide provider and toast manager, status types, announcement priorities, and a keyboard-accessible viewport. The older Radix-based shadcn Toast is deprecated; Sonner is an option for projects choosing the Radix flavor, but this project will use the current Base UI Toast component directly. See the [shadcn Toast documentation](https://ui.shadcn.com/docs/components/base/toast) and [Base UI Toast documentation](https://base-ui.com/react/components/toast). + +**Alternative considered:** Continue using MUI `Snackbar` and `Alert`. They are already installed but remain part of the existing mixed notification system; choosing them would not start the app's requested shadcn adoption. Sonner was also considered, but the current shadcn Base UI Toast component provides the shared provider, status types, and announcement priority needed here. + +### Mount one notification service at the Pages Router root + +Wrap the page component in one shared notification provider from `pages/_app.tsx`, with one Base UI Toast provider and viewport for the app lifetime. Expose a typed `notify(message, severity)` hook or context API to pages and components. The shared adapter owns notification ids and a one-at-a-time queue so repeated messages remain distinct and rapid notices are presented in emission order without being overwritten or competing for announcement. New route content continues to use the same viewport. + +Use the Toast manager from React UI callers. Refactor `generateBatchStatusReport` so it returns or throws meaningful outcomes instead of showing browser dialogs; its report and batch page callers then use the shared notification API. This keeps report generation independent from the UI and avoids module-level notification state. + +**Alternative considered:** Create a module-level toast manager and call it directly from utilities. Keeping notification decisions at the UI boundary avoids coupling reusable report generation to browser presentation and keeps server-rendered requests from sharing notification state. + +### Migrate notification call sites + +Replace page/component-owned `GlobalSnackbar` state and direct browser `alert()` calls for transient notices with the shared API. Leave `window.confirm`, custom confirmation modals, and inline field messages untouched. Remove the MUI-backed `GlobalSnackbar` component if it has no remaining callers; leave MUI installed because other app screens still use it. + +## Risks / Trade-offs + +- [A burst can create a long queue] → Keep one ordered queue, use the five-second baseline, and verify notices are neither overwritten nor dropped. +- [Screen-reader behavior differs across browser and assistive-technology combinations] → Verify polite and urgent announcement priorities and perform a manual screen-reader pass for success, error, repeated, and rapid notices. +- [Introducing shadcn configuration could affect existing Tailwind styles] → Keep the setup scoped to the Toast component, preserve preflight as disabled, and avoid a broad theme or CSS reset migration. +- [Changing blocking browser alerts to non-blocking toasts lets work continue before the user reads the message] → Keep messages informational and actionable without requiring a response; keep decision prompts in confirmation dialogs. + +## Migration Plan + +1. Add minimal shadcn configuration, the Base UI Toast component, and the shared provider, queue, host, and notification API at `pages/_app.tsx`. +2. Migrate existing `GlobalSnackbar` call sites and their local notification state. +3. Replace in-scope browser alerts in report and batch workflows; have shared utilities return errors/outcomes to their UI callers. +4. Remove the old page-local snackbar interface after all in-scope callers use the shared API, then validate notification scenarios and screen-reader semantics. + +Rollback can restore the previous `GlobalSnackbar` callers and browser alerts without data migration; the change affects client-side presentation only. diff --git a/openspec/changes/archive/2026-10-05-accessible-global-notifications/proposal.md b/openspec/changes/archive/2026-10-05-accessible-global-notifications/proposal.md new file mode 100644 index 0000000..3913685 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-accessible-global-notifications/proposal.md @@ -0,0 +1,25 @@ +## Why + +The app reports operation outcomes through a mix of page-local MUI snackbars and blocking browser alerts. These messages are not announced consistently by screen readers, so users may not know whether an action succeeded or failed. A shared toast pattern will make transient feedback predictable visually and programmatically. + +## What Changes + +- Add one app-wide toast interface and presentation surface for transient operation confirmations, updates, validation notices, and failures. +- Use shadcn/ui's Base UI Toast component for the shared toast surface. +- Migrate current page-local snackbar notifications and in-scope browser `alert()` notices to the shared toast pattern. +- Give each toast appropriate live announcement semantics so routine updates are announced politely and urgent errors assertively. +- Keep inline field validation and blocking confirmation dialogs as distinct interaction patterns. + +## Capabilities + +### New Capabilities + +- `accessible-notifications`: Consistent, screen-reader-announced transient notifications across the application. + +### Modified Capabilities + +## Impact + +- Affects `components/GlobalSnackbar.tsx`, notification state and calls in pages and components, browser-alert call sites in report and batch flows, and the app root in `pages/_app.tsx`. +- Adds the initial shadcn/ui component configuration, a local Toast component, and the `@base-ui/react` dependency; existing MUI use elsewhere in the app remains. +- Adds a shared client-side notification API and may change notification timing and presentation from blocking alerts to non-blocking toasts. diff --git a/openspec/changes/archive/2026-10-05-accessible-global-notifications/specs/accessible-notifications/spec.md b/openspec/changes/archive/2026-10-05-accessible-global-notifications/specs/accessible-notifications/spec.md new file mode 100644 index 0000000..bd1b8f9 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-accessible-global-notifications/specs/accessible-notifications/spec.md @@ -0,0 +1,76 @@ +## Purpose + +Provides a consistent way for the application to show and announce transient operation feedback, so sighted users and screen-reader users receive the same confirmation when an action completes or needs attention. + +## ADDED Requirements + +### Requirement: Shared transient notification pattern + +The application MUST present transient operation confirmations, updates, validation notices, and failures through one shared toast pattern. Each emitted notification MUST be visible without blocking the user from continuing to work. Existing page-specific snackbar notifications and browser alert notices in this scope MUST use this pattern. + +#### Scenario: Operation completes successfully + +- **WHEN** an application action completes and produces a confirmation or update +- **THEN** the application presents its message through the shared toast pattern without opening a blocking browser dialog + +#### Scenario: Operation fails or needs correction + +- **WHEN** an application action fails or its input needs correction and produces a transient notice +- **THEN** the application presents the message through the same shared toast pattern with an appropriate severity + +#### Scenario: Multiple notices are produced close together + +- **WHEN** more than one notification is emitted before the prior notification has been presented +- **THEN** each notification is presented and announced in emission order without silently discarding an outcome + +### Requirement: Screen-reader announcement of notifications + +Every notification MUST be exposed through an accessible live announcement and MUST be announced when it appears. Success, informational, and warning notifications MUST use polite announcement behavior; error notifications MUST use assertive announcement behavior. The announcement MUST include the notification's text and MUST NOT rely on color or icon alone to communicate its meaning. + +#### Scenario: Routine update is announced + +- **WHEN** a success, informational, or warning notification appears +- **THEN** a screen reader is given its text through a polite live announcement + +#### Scenario: Error is announced + +- **WHEN** an error notification appears +- **THEN** a screen reader is given its text through an assertive live announcement + +#### Scenario: Identical notice is emitted again + +- **WHEN** a notification with the same text and severity is emitted again after it was previously shown +- **THEN** the new occurrence is announced again + +### Requirement: Notification controls are accessible + +The toast pattern MUST allow users to dismiss a visible notification using an accessible, keyboard-operable control. Automatically dismissed notifications MUST remain available long enough to be perceived, and dismissal MUST NOT be required for the live announcement to occur. + +#### Scenario: Keyboard user dismisses a toast + +- **WHEN** a keyboard user reaches and activates the toast's dismiss control +- **THEN** the visible toast is dismissed and the control has an accessible name + +#### Scenario: Toast expires automatically + +- **WHEN** a notification is configured to dismiss automatically +- **THEN** its text is announced when shown and it remains visible for at least 5 seconds before automatic dismissal + +#### Scenario: User is interacting with a toast + +- **WHEN** the pointer is over a visible toast or keyboard focus is within it +- **THEN** its automatic dismissal timer is paused until the pointer and focus leave the toast + +### Requirement: Distinct validation and confirmation interactions remain distinct + +Inline field validation and dialogs that request confirmation before an action MUST remain available in their existing interaction patterns; they MUST NOT be converted into transient toasts solely as part of this notification change. + +#### Scenario: Inline field error is shown + +- **WHEN** a form field has a validation error +- **THEN** the error remains associated with the relevant field as inline feedback + +#### Scenario: User must approve a consequential action + +- **WHEN** an action requires explicit user approval before it proceeds +- **THEN** the application presents a confirmation interaction that requires a user decision rather than a transient toast diff --git a/openspec/changes/archive/2026-10-05-accessible-global-notifications/tasks.md b/openspec/changes/archive/2026-10-05-accessible-global-notifications/tasks.md new file mode 100644 index 0000000..688c9a7 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-accessible-global-notifications/tasks.md @@ -0,0 +1,18 @@ +## 1. Add the shared shadcn toast infrastructure + +- [x] 1.1 Add minimal shadcn configuration for the existing Tailwind 3 setup and `@/*` alias, add `@base-ui/react`, and create the local shadcn Base UI Toast component; verify the dependency and component resolve with `pnpm type-check` and confirm Tailwind preflight remains disabled. +- [x] 1.2 Implement the shared notification API and ordered queue with unique ids, severity types, low priority for routine messages, high priority for errors, and a five-second timeout; verify repeated identical and rapid notices appear in emission order without being overwritten. +- [x] 1.3 Mount one Toast provider and viewport around pages in `pages/_app.tsx`, including keyboard-operable dismissal and timeout pause on hover/focus; verify the same viewport remains available while navigating between pages. + +## 2. Migrate existing notifications + +- [x] 2.1 Migrate local snackbar state and `GlobalSnackbar` usage in configuration, users, courses, and remarks components to the shared API; verify these files contain no old snackbar imports or rendered instances. +- [x] 2.2 Migrate local snackbar state and `GlobalSnackbar` usage in students, student registration, and bulk registration to the shared API; verify each operation outcome still produces a toast with its intended severity. +- [x] 2.3 Migrate local snackbar state and `GlobalSnackbar` usage in batches and batch details to the shared API; verify operation notices use the shared viewport while confirmation dialogs and inline field errors remain in place. +- [x] 2.4 Replace transient browser alerts in reports, batch creation, and batch details with shared notifications, and refactor `generateBatchStatusReport` to return or throw outcomes for its UI callers; verify report generation no longer invokes `window.alert` and callers show success or failure through the shared API. +- [x] 2.5 Remove the MUI-backed `GlobalSnackbar` component once all callers are migrated; verify `rg` finds no remaining imports or JSX usages. + +## 3. Verify accessible behavior + +- [x] 3.1 Review success, info, warning, and error messages with keyboard navigation and a screen reader; verify polite versus urgent announcement priority, full message text, repeated identical messages, rapid notices, dismissal, and timeout pause while focused or hovered. Browser accessibility-tree and keyboard checks passed; spoken output was not verified because no screen reader was available. +- [x] 3.2 Run `pnpm type-check` and `pnpm lint`; verify both complete without errors after all notification call sites are migrated. diff --git a/openspec/specs/accessible-notifications/spec.md b/openspec/specs/accessible-notifications/spec.md new file mode 100644 index 0000000..1fe6edb --- /dev/null +++ b/openspec/specs/accessible-notifications/spec.md @@ -0,0 +1,78 @@ +# accessible-notifications Specification + +## Purpose + +Provides a consistent way for the application to show and announce transient operation feedback, so sighted users and screen-reader users receive the same confirmation when an action completes or needs attention. + +## Requirements + +### Requirement: Shared transient notification pattern + +The application MUST present transient operation confirmations, updates, validation notices, and failures through one shared toast pattern. Each emitted notification MUST be visible without blocking the user from continuing to work. Existing page-specific snackbar notifications and browser alert notices in this scope MUST use this pattern. + +#### Scenario: Operation completes successfully + +- **WHEN** an application action completes and produces a confirmation or update +- **THEN** the application presents its message through the shared toast pattern without opening a blocking browser dialog + +#### Scenario: Operation fails or needs correction + +- **WHEN** an application action fails or its input needs correction and produces a transient notice +- **THEN** the application presents the message through the same shared toast pattern with an appropriate severity + +#### Scenario: Multiple notices are produced close together + +- **WHEN** more than one notification is emitted before the prior notification has been presented +- **THEN** each notification is presented and announced in emission order without silently discarding an outcome + +### Requirement: Screen-reader announcement of notifications + +Every notification MUST be exposed through an accessible live announcement and MUST be announced when it appears. Success, informational, and warning notifications MUST use polite announcement behavior; error notifications MUST use assertive announcement behavior. The announcement MUST include the notification's text and MUST NOT rely on color or icon alone to communicate its meaning. + +#### Scenario: Routine update is announced + +- **WHEN** a success, informational, or warning notification appears +- **THEN** a screen reader is given its text through a polite live announcement + +#### Scenario: Error is announced + +- **WHEN** an error notification appears +- **THEN** a screen reader is given its text through an assertive live announcement + +#### Scenario: Identical notice is emitted again + +- **WHEN** a notification with the same text and severity is emitted again after it was previously shown +- **THEN** the new occurrence is announced again + +### Requirement: Notification controls are accessible + +The toast pattern MUST allow users to dismiss a visible notification using an accessible, keyboard-operable control. Automatically dismissed notifications MUST remain available long enough to be perceived, and dismissal MUST NOT be required for the live announcement to occur. + +#### Scenario: Keyboard user dismisses a toast + +- **WHEN** a keyboard user reaches and activates the toast's dismiss control +- **THEN** the visible toast is dismissed and the control has an accessible name + +#### Scenario: Toast expires automatically + +- **WHEN** a notification is configured to dismiss automatically +- **THEN** its text is announced when shown and it remains visible for at least 5 seconds before automatic dismissal + +#### Scenario: User is interacting with a toast + +- **WHEN** the pointer is over a visible toast or keyboard focus is within it +- **THEN** its automatic dismissal timer is paused until the pointer and focus leave the toast + +### Requirement: Distinct validation and confirmation interactions remain distinct + +Inline field validation and dialogs that request confirmation before an action MUST remain available in their existing interaction patterns; they MUST NOT be converted into transient toasts solely as part of this notification change. + +#### Scenario: Inline field error is shown + +- **WHEN** a form field has a validation error +- **THEN** the error remains associated with the relevant field as inline feedback + +#### Scenario: User must approve a consequential action + +- **WHEN** an action requires explicit user approval before it proceeds +- **THEN** the application presents a confirmation interaction that requires a user decision rather than a transient toast diff --git a/package.json b/package.json index 03a7970..610d0d1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "postinstall": "prisma generate" }, "dependencies": { + "@base-ui/react": "^1.8.0", "@better-auth/passkey": "^1.7.5", "@better-auth/prisma-adapter": "1.7.5", "@emotion/react": "11.14.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index bc4cd38..3a80bee 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -4,6 +4,7 @@ import Head from "next/head"; import { SerwistProvider } from "@serwist/next/react"; import { AllCommunityModule, ModuleRegistry, provideGlobalGridOptions, themeQuartz } from "ag-grid-community"; +import { NotificationProvider } from "@/components/notifications/NotificationProvider"; import "../styles/globals.css"; ModuleRegistry.registerModules([AllCommunityModule]); @@ -31,7 +32,9 @@ const App = ({ Component, pageProps }) => ( - + + + ); diff --git a/pages/api/updatestudents.ts b/pages/api/updatestudents.ts index be9c1fa..07c22db 100644 --- a/pages/api/updatestudents.ts +++ b/pages/api/updatestudents.ts @@ -1,9 +1,11 @@ // pages/api/updatestudents.ts import type { Prisma } from "@prisma/client"; +import { YesNo } from "@prisma/client"; import { prisma } from "@/lib/prisma"; const ALLOWED_ENROLLMENT = new Set([ null, + "AVAILABLE", "ENROLLED", "NO_RESPONSE_SW_OFF", "FOLLOW_UP", @@ -15,6 +17,7 @@ const ALLOWED_ENROLLMENT = new Set([ const MAP_LABEL_TO_CODE = new Map([ ["Unassigned", null], + ["Available", "AVAILABLE"], ["Enrolled", "ENROLLED"], ["No respond/ switched off", "NO_RESPONSE_SW_OFF"], ["Follow up", "FOLLOW_UP"], @@ -59,6 +62,8 @@ const WHITELIST = [ "registration_date", ]; +const YES_NO_FIELDS = new Set(["id_proof", "disability_cert", "photo", "bank_details"]); + export default async function handler(req, res) { if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); @@ -99,6 +104,16 @@ export default async function handler(req, res) { return res.status(400).json({ message: "Invalid enrollment_status" }); } Object.assign(data, { enrollment_status: v }); + } else if (YES_NO_FIELDS.has(key)) { + const raw = body[key]; + const normalized = typeof raw === "string" ? raw.trim().toLowerCase() : raw; + if (normalized === "yes" || normalized === 1 || normalized === true) { + Object.assign(data, { [key]: YesNo.Yes }); + } else if (normalized === "no" || normalized === 0 || normalized === false) { + Object.assign(data, { [key]: YesNo.No }); + } else { + return res.status(400).json({ message: `${key} must be Yes or No` }); + } } else { const raw = body[key] ?? null; Object.assign(data, { [key]: typeof raw === "string" ? raw.trim() : raw }); @@ -109,7 +124,9 @@ export default async function handler(req, res) { return res.status(400).json({ message: "No fields to update" }); } - await prisma.vastudents.update({ where: { id: studentId }, data }); + // Select only the ID: legacy rows may contain an empty MySQL enum value in + // a YesNo column, which Prisma cannot deserialize in the default full-row result. + await prisma.vastudents.update({ where: { id: studentId }, data, select: { id: true } }); return res.status(200).json({ success: true, message: "Student updated successfully" }); } catch (error) { diff --git a/pages/batch/[id].tsx b/pages/batch/[id].tsx index a0701d0..22c71a8 100644 --- a/pages/batch/[id].tsx +++ b/pages/batch/[id].tsx @@ -27,7 +27,6 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AlertColor } from "@mui/material/Alert"; import AccessibleSelectCellEditor from "../../components/AccessibleSelectCellEditor"; import { DataGrid } from "../../components/DataGrid"; import Navbar from "../../components/Navbar"; @@ -37,7 +36,7 @@ import styles from "../../styles/Home.module.css"; import tableStyles from "../../styles/Table.module.css"; //CODE BELOW IS ACTUALLY THE BATCH STATUS FORM ON THE BATCHES PAGE -import GlobalSnackbar from "@/components/GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; import { exportToCsv } from "@/utils/export-to-csv"; import { mapAttendanceDataToCsv } from "@/utils/map-attendance-data-to-csv"; import { convertNumberToYesNo } from "@/utils/students/convert-number-to-yes-no"; @@ -115,14 +114,12 @@ function staffHasAccess(batchInfo, userInfo) { /*----------------- MISC FUNCTIONS END ----------------*/ export default function Page() { + const notify = useNotification(); const router = useRouter(); const { id } = router.query; const { data: session, status } = useSession(); const [loading, setLoading] = useState(true); - const [alertOpen, setAlertOpen] = useState(false); - const [message, setMessage] = useState(""); - const [alertSeverity, setAlertSeverity] = useState("success"); const [userResponse, setUserResponse] = useState({}); const [batchData, setBatchData] = useState({ students: [], @@ -234,8 +231,6 @@ export default function Page() { ); const [editAttendanceData, setEditAttendanceData] = useState(null); - const [announcement, setAnnouncement] = useState(""); - const handleLeftGridEsc = (params) => { if (params.event.key === "Escape") { getElementById("skip-unassigned")?.focus(); @@ -249,8 +244,13 @@ export default function Page() { }; const handleBatchStatusExport = useCallback(async () => { - await generateBatchStatusReport(id, batchName, courseName); - }, [id, batchName, courseName]); + try { + await generateBatchStatusReport(id, batchName, courseName); + notify("Batch status report downloaded successfully.", "success"); + } catch (error) { + notify(error.message || "Failed to download report. Please try again.", "error"); + } + }, [id, batchName, courseName, notify]); // Defer helper to avoid synchronous updates during render const defer = (fn) => (typeof queueMicrotask === "function" ? queueMicrotask(fn) : Promise.resolve().then(fn)); @@ -373,9 +373,7 @@ export default function Page() { console.error("Error refreshing grid:", error); } }, 100); - setMessage(`Attendance was updated!`); - setAlertSeverity("success"); - setAlertOpen(true); + notify(`Attendance was updated!`, "success"); setEditAttendanceData(null); } else { console.error("Error updating the attendance data", response.status, parsedBody ?? raw); @@ -482,12 +480,9 @@ export default function Page() { if (!leftGridApi) return; const selectedRows = leftGridApi.getSelectedRows(); - // If no rows selected, announce that to screen reader and exit function + // If no rows are selected, notify the user and exit. if (selectedRows.length === 0) { - setAnnouncement(""); - setTimeout(() => { - setAnnouncement("Action not performed, no students selected to add to batch."); - }, 50); + notify("Action not performed, no students selected to add to batch.", "warning"); return; } @@ -496,26 +491,17 @@ export default function Page() { addStudentMulti(studentIds); leftGridApi.deselectAll(); - // Accessibility announcement const message = count === 1 ? `${selectedRows[0].name} added to batch` : `${count} students added to batch`; - - // force re-read even if same message - setAnnouncement(""); - setTimeout(() => setAnnouncement(message), 50); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [leftGridApi]); + notify(message, "success"); + }, [leftGridApi, notify]); const moveToLeft = useCallback(() => { if (!rightGridApi) return; const selectedRows = rightGridApi.getSelectedRows(); - // If no rows selected, announce that to screen reader and exit function + // If no rows are selected, notify the user and exit. if (selectedRows.length === 0) { - setAnnouncement(""); - setTimeout(() => { - setAnnouncement("Action not performed. No students selected to remove from batch."); - }, 50); + notify("Action not performed. No students selected to remove from batch.", "warning"); return; } @@ -524,14 +510,9 @@ export default function Page() { deleteStudentMulti(studentIds); rightGridApi.deselectAll(); - // Accessibility announcement const message = count === 1 ? `${selectedRows[0].name} removed from batch` : `${count} students removed from batch`; - - setAnnouncement(""); - setTimeout(() => setAnnouncement(message), 50); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rightGridApi]); + notify(message, "success"); + }, [rightGridApi, notify]); const handleAssignmentTypeChange = (event) => { const v = event.target.value; @@ -557,9 +538,7 @@ export default function Page() { setAssessmentEditRows([]); } else { console.error("Error deleting the assignment"); - setMessage("Error deleting the assignment"); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Error deleting the assignment", "error"); } } finally { setContentLoading(false); @@ -684,9 +663,7 @@ export default function Page() { ? `Total weight cannot exceed 100. Current total: ${totalWeight}%.` : `Total weight must equal 100%. Current total: ${totalWeight}%.`; setWeightValidationError(msg); - setMessage(msg); - setAlertSeverity("error"); - setAlertOpen(true); + notify(msg, "error"); return; } setWeightValidationError(null); @@ -704,9 +681,7 @@ export default function Page() { setAssessmentEditRows([]); setEditingColumn(null); setIsEditing(false); - setMessage("Assessment changes saved successfully."); - setAlertSeverity("success"); - setAlertOpen(true); + notify("Assessment changes saved successfully.", "success"); await getBatchData(); } finally { setContentLoading(false); @@ -972,9 +947,7 @@ export default function Page() { const postAssignmentUpdate = async (assignmentData) => { const rowIdentifier = assignmentData.id ?? assignmentData.assignment_name; if (!rowIdentifier) { - setMessage("Cannot update assessment: missing row identifier."); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Cannot update assessment: missing row identifier.", "error"); return false; } const payload = { ...assignmentData, id: rowIdentifier }; @@ -991,19 +964,16 @@ export default function Page() { return true; } const serverMessage = result.message || response.statusText || ""; - setMessage( + notify( serverMessage ? `Failed to update assessment. ${serverMessage}` - : "Failed to update assessment. Please try again." + : "Failed to update assessment. Please try again.", + "error" ); - setAlertSeverity("error"); - setAlertOpen(true); return false; } catch (err) { console.error("Error updating the assignment", err); - setMessage("Failed to update assessment. Please try again."); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Failed to update assessment. Please try again.", "error"); return false; } }; @@ -1011,9 +981,7 @@ export default function Page() { const updateAssignment = async (assignmentData) => { const rowIdentifier = assignmentData.id ?? assignmentData.assignment_name; if (!rowIdentifier) { - setMessage("Cannot update assessment: missing row identifier."); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Cannot update assessment: missing row identifier.", "error"); setContentLoading(false); return false; } @@ -1039,9 +1007,7 @@ export default function Page() { setBatchData({ ...batchData, grades: updatedGrades }); } setAssessmentEditRows([]); - setMessage(`Assignment information was updated!`); - setAlertSeverity("success"); - setAlertOpen(true); + notify(`Assignment information was updated!`, "success"); await getBatchData(); return true; } finally { @@ -1132,9 +1098,7 @@ export default function Page() { event.node.setDataValue("assignment_weight", event.oldValue); const msg = `Total weight cannot exceed 100. Would be ${wouldBeTotal}%.`; setWeightValidationError(msg); - setMessage(msg); - setAlertSeverity("error"); - setAlertOpen(true); + notify(msg, "error"); setCurrentPostWeightTotal(wouldBeTotal); setLoading(false); setContentLoading(false); @@ -1278,18 +1242,15 @@ export default function Page() { } if (currentAssessments.includes(assignment_name)) { - setMessage("Assessment already exists, please use a different name"); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Assessment already exists, please use a different name", "error"); setContentLoading(false); } else if (assignment_type === "Post" && total_weight + assignment_weight > 100) { - setMessage( + notify( "Total weight of Post assessments cannot exceed 100%. Current total is " + total_weight + - "%. Reduce the new assessment weight or adjust existing weights using Edit." + "%. Reduce the new assessment weight or adjust existing weights using Edit.", + "error" ); - setAlertSeverity("error"); - setAlertOpen(true); } else { setContentLoading(true); // const apiUrlEndpoint = `https://va-stats.vercel.app/api/addassignment`; @@ -1311,17 +1272,14 @@ export default function Page() { setAssessmentEditRows([]); setShowForm(false); await getBatchData(); - setMessage("Assessment added successfully."); - setAlertSeverity("success"); - setAlertOpen(true); + notify("Assessment added successfully.", "success"); } else { console.error("Error adding assignment", result.message || response.statusText); const serverMessage = result.message || response.statusText || ""; - setMessage( - serverMessage ? `Failed to add assessment. ${serverMessage}` : "Failed to add assessment. Please try again." + notify( + serverMessage ? `Failed to add assessment. ${serverMessage}` : "Failed to add assessment. Please try again.", + "error" ); - setAlertSeverity("error"); - setAlertOpen(true); } setContentLoading(false); } @@ -1483,7 +1441,7 @@ export default function Page() { }); if (!someNotCancelled && confirmCancel) { - alert(`Class on ${formatAttendanceDate(props.displayName)} is already cancelled.`); + notify(`Class on ${formatAttendanceDate(props.displayName)} is already cancelled.`, "info"); } }; @@ -2422,7 +2380,6 @@ export default function Page() { if (staffHasAccess(batchData, userResponse)) { return ( <> - - {/* Screen reader announcements */} -
- {announcement} -
@@ -2762,21 +2715,15 @@ export default function Page() { const result = await response.json(); if (!response.ok) { - setMessage(result.message || "Failed to update grade"); - setAlertSeverity("error"); - setAlertOpen(true); + notify(result.message || "Failed to update grade", "error"); // Revert the change by refreshing the data await getBatchData(); } else { - setMessage("Grade updated successfully!"); - setAlertSeverity("success"); - setAlertOpen(true); + notify("Grade updated successfully!", "success"); } } catch (error) { console.error("Error updating grade:", error); - setMessage("Failed to update grade. Please try again."); - setAlertSeverity("error"); - setAlertOpen(true); + notify("Failed to update grade. Please try again.", "error"); // Revert the change by refreshing the data await getBatchData(); } @@ -2870,9 +2817,7 @@ export default function Page() { body: JSON.stringify(payload), }); await getBatchData(); - setMessage("Batch status updated successfully!"); - setAlertSeverity("success"); - setAlertOpen(true); + notify("Batch status updated successfully!", "success"); setContentLoading(false); }} /> @@ -3135,7 +3080,6 @@ export default function Page() { } else { return ( <> - (null); const editConfirmOpenRef = useRef(false); - const [message, setMessage] = useState(""); - const [alertOpen, setAlertOpen] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState("NA"); const [useMobileAutoSize, setUseMobileAutoSize] = useState(false); const [batchIdError, setBatchIdError] = useState(""); @@ -100,39 +99,40 @@ export default function Page() { }, []); /*--------------- UPDATE/DELETE BATCH BEGINS -------------------*/ - const handleUpdateBatch = useCallback(async (editedBatch) => { - setContentLoading(true); - const response = await fetch("/api/updatebatches", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(editedBatch), - }); + const handleUpdateBatch = useCallback( + async (editedBatch) => { + setContentLoading(true); + const response = await fetch("/api/updatebatches", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(editedBatch), + }); - if (response.ok) { - // I had to move getpagedata out of useeffect so i could call it here (Spr 2023 team). - setMessage(`BatchId: ${editedBatch.id}, Batch: ${editedBatch.batch} Update Success!`); - setAlertOpen(true); - // getPageData will be called later when it's defined - setEditingId(null); - const focused = lastFocusedCell.current; - if (focused && gridRef.current?.api) { - const api = gridRef.current.api; - requestAnimationFrame(() => { + if (response.ok) { + // I had to move getpagedata out of useeffect so i could call it here (Spr 2023 team). + notify(`BatchId: ${editedBatch.id}, Batch: ${editedBatch.batch} Update Success!`, "success"); + // getPageData will be called later when it's defined + setEditingId(null); + const focused = lastFocusedCell.current; + if (focused && gridRef.current?.api) { + const api = gridRef.current.api; requestAnimationFrame(() => { - api.setFocusedCell(focused.rowIndex, focused.colKey); + requestAnimationFrame(() => { + api.setFocusedCell(focused.rowIndex, focused.colKey); + }); }); - }); + } + } else { + notify(`BatchId: ${editedBatch.id}, Batch: ${editedBatch.batch} Update Failed!`, "error"); + console.error("Error updating the batch"); } - } else { - setMessage(`BatchId: ${editedBatch.id}, Batch: ${editedBatch.batch} Update Failed!`); - setAlertOpen(true); - console.error("Error updating the batch"); - } - setContentLoading(false); - setLoading(false); - }, []); + setContentLoading(false); + setLoading(false); + }, + [notify] + ); const onCellValueChanged = (params) => { if (!params?.data?.id) return; @@ -252,19 +252,17 @@ export default function Page() { }); if (response.ok) { - setMessage(`BatchId: ${batchID} Delete Success!`); - setAlertOpen(true); + notify(`BatchId: ${batchID} Delete Success!`, "success"); await fetchBatchesData(); } else { - setMessage(`BatchId: ${batchID} Delete Failed!`); - setAlertOpen(true); + notify(`BatchId: ${batchID} Delete Failed!`, "error"); console.error("Error deleting the batch"); } } finally { setContentLoading(false); } }, - [fetchBatchesData] + [fetchBatchesData, notify] ); const handleDelete = useCallback((props) => { @@ -351,7 +349,7 @@ export default function Page() { const validateCheckedDays = (form) => { const checkedDays = form.querySelectorAll('input[name="coursedays"]:checked'); if (checkedDays.length === 0) { - alert("Please select at least one class day!"); + notify("Please select at least one class day!", "warning"); return false; } return true; @@ -364,7 +362,7 @@ export default function Page() { const startDate = new Date(courseStartValue); const endDate = new Date(courseEndValue); if (endDate < startDate) { - alert("Course End Date can't be before Start Date. Please try again!"); + notify("Course End Date can't be before Start Date. Please try again!", "warning"); return false; } } @@ -378,7 +376,7 @@ export default function Page() { const classStartTime = classStartTimeInput.value; const classEndTime = classEndTimeInput.value; if (classStartTime && classEndTime && classEndTime < classStartTime) { - alert("Class End Time can't be before Class Start Time. Please try again!"); + notify("Class End Time can't be before Class Start Time. Please try again!", "warning"); return false; } } @@ -481,11 +479,9 @@ export default function Page() { const response = await fetch(apiUrlEndpoint, postData); const res = await response.json(); if (!res.success) { - setMessage(`Batch Creation Failed!`); - setAlertOpen(true); + notify(`Batch Creation Failed!`, "error"); } else { - setMessage(`Batch Creation Success!`); - setAlertOpen(true); + notify(`Batch Creation Success!`, "success"); // Reset form and hide it form.reset(); setShowForm(false); @@ -632,7 +628,6 @@ export default function Page() { {/*---------------- FORM BEGINS -------------------*/}
- import("ag-grid-react").then((mod) => mod.AgGridReact), { ssr: false }); @@ -96,14 +95,12 @@ function IncludeFooter({}) { } export default function Page() { + const notify = useNotification(); const [userRole, setUserRole] = useState(null); const [contentLoading] = useState(false); const { data: session, status } = useSession(); - const [alertOpen, setAlertOpen] = useState(false); - const [message, setMessage] = useState(""); const gridRef = useRef(null); const [rowData, setRowData] = useState([]); - const [severity, setSeverity] = useState("success"); // Default severity for snackbar const [numberOfValidRows, setNumberOfValidRows] = useState(0); const allowedRoles = ["ADMINISTRATOR", "MANAGEMENT", "STAFF"]; @@ -536,9 +533,7 @@ export default function Page() { } } catch (e) { console.error("Error checking duplicates in database:", e); - setMessage("An error occurred while checking for duplicates in the database. Please try again."); - setSeverity("error"); - setAlertOpen(true); + notify("An error occurred while checking for duplicates in the database. Please try again.", "error"); } return rows; @@ -571,15 +566,11 @@ export default function Page() { setNumberOfValidRows( rowData.filter((row) => !row._hasError && row._hasBeenValidated && !row._successfullyRegistered).length ); - setMessage(`Successfully registered ${data.insertedCount} students!`); - setSeverity("success"); - setAlertOpen(true); + notify(`Successfully registered ${data.insertedCount} students!`, "success"); } } catch (e) { console.error("Error adding records to the database:", e); - setMessage("An error occurred while adding records to the database. Please try again."); - setSeverity("error"); - setAlertOpen(true); + notify("An error occurred while adding records to the database. Please try again.", "error"); } }; @@ -616,15 +607,12 @@ export default function Page() { // if there are any errors, show an error message, otherwise show a success message if (errors.length > 0) { - setMessage( - `Validation failed for ${rowData.map((row) => row._hasError).filter((hasError) => hasError).length} row(s). Please fix them and try again.` + notify( + `Validation failed for ${rowData.map((row) => row._hasError).filter((hasError) => hasError).length} row(s). Please fix them and try again.`, + "error" ); - setSeverity("error"); - setAlertOpen(true); } else { - setMessage("All validations passed. Please click 'Register Valid Rows' to upload to the database."); - setSeverity("success"); - setAlertOpen(true); + notify("All validations passed. Please click 'Register Valid Rows' to upload to the database.", "success"); } updateRowStatus(); }; @@ -712,8 +700,6 @@ export default function Page() { } `}
- - {/* AG Grid Container + AG Grid table */}
diff --git a/pages/configurations.tsx b/pages/configurations.tsx index adf66fc..ad2d448 100644 --- a/pages/configurations.tsx +++ b/pages/configurations.tsx @@ -4,7 +4,7 @@ import Head from "next/head"; import { useRouter } from "next/router"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import Navbar from "@/components/Navbar"; -import GlobalSnackbar from "@/components/GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; import ConfirmationModal from "@/components/ConfirmationModal"; import PageTitleWithUserGuideLink from "@/components/PageTitleWithUserGuideLink"; import DashboardSettingsPanel from "@/components/DashboardSettingsPanel"; @@ -12,7 +12,6 @@ import styles from "@/styles/Home.module.css"; import configStyles from "@/styles/Configurations.module.css"; import { mergeBatchStatusDerivedRules } from "@/utils/batch-status-derived"; import { canAccessConfigurationsPage } from "@/utils/configurations-access"; -import type { AlertColor } from "@mui/material/Alert"; /** API option rows + current rule strings so invalid/orphan values still appear until fixed. */ function buildSelectValues(apiOptions, currentStrings) { @@ -73,6 +72,7 @@ function getSectionFromHash() { } export default function ConfigurationsPage() { + const notify = useNotification(); const router = useRouter(); const { data: session, status } = useSession(); @@ -89,10 +89,6 @@ export default function ConfigurationsPage() { const [options, setOptions] = useState([]); const [loading, setLoading] = useState(true); - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); - const [snackbarSeverity, setSnackbarSeverity] = useState("success"); - const [rulesForm, setRulesForm] = useState(() => mergeBatchStatusDerivedRules(null)); /** Last successfully loaded or saved rules; used to revert the form if save fails. */ const [rulesBaseline, setRulesBaseline] = useState(() => mergeBatchStatusDerivedRules(null)); @@ -118,11 +114,12 @@ export default function ConfigurationsPage() { window.history.replaceState(null, "", next); }, []); - const handleDashboardConfigNotify = useCallback((message, severity) => { - setSnackbarSeverity(severity === "error" ? "error" : "success"); - setSnackbarMessage(message); - setSnackbarOpen(true); - }, []); + const handleDashboardConfigNotify = useCallback( + (message, severity) => { + notify(message, severity === "error" ? "error" : "success"); + }, + [notify] + ); const fetchOptions = useCallback(async () => { setLoading(true); @@ -133,14 +130,12 @@ export default function ConfigurationsPage() { setOptions(Array.isArray(data?.options) ? data.options : []); } catch (e) { console.error("fetchOptions error:", e); - setSnackbarSeverity("error"); - setSnackbarMessage("Failed to load dropdown options"); - setSnackbarOpen(true); + notify("Failed to load dropdown options", "error"); setOptions([]); } finally { setLoading(false); } - }, [selectedKey]); + }, [selectedKey, notify]); useEffect(() => { fetchOptions(); @@ -276,9 +271,7 @@ export default function ConfigurationsPage() { (opt) => typeof opt?.value === "string" && opt.value.trim().toLowerCase() === value.toLowerCase() ); if (alreadyExists) { - setSnackbarSeverity("error"); - setSnackbarMessage(`"${value}" already exists in this dropdown.`); - setSnackbarOpen(true); + notify(`"${value}" already exists in this dropdown.`, "error"); return; } @@ -295,19 +288,14 @@ export default function ConfigurationsPage() { const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { const errData = await response.json().catch(() => null); - setSnackbarSeverity("error"); - setSnackbarMessage(errData?.message ?? "Failed to add option"); + notify(errData?.message ?? "Failed to add option", "error"); } else { - setSnackbarSeverity("error"); - setSnackbarMessage("Failed to add option"); + notify("Failed to add option", "error"); } - setSnackbarOpen(true); return; } - setSnackbarSeverity("success"); - setSnackbarMessage("Option added successfully"); - setSnackbarOpen(true); + notify("Option added successfully", "success"); resetAddForm(); await fetchOptions(); await loadRuleDropdownOptions(); @@ -339,9 +327,7 @@ export default function ConfigurationsPage() { const body = contentType?.includes("application/json") ? await res.json().catch(() => null) : null; if (!res.ok) { setRulesForm(mergeBatchStatusDerivedRules(rulesBaseline)); - setSnackbarSeverity("error"); - setSnackbarMessage(body?.message ?? "Failed to save rules"); - setSnackbarOpen(true); + notify(body?.message ?? "Failed to save rules", "error"); return; } if (body?.rules) { @@ -349,15 +335,11 @@ export default function ConfigurationsPage() { setRulesForm(merged); setRulesBaseline(merged); } - setSnackbarSeverity("success"); - setSnackbarMessage(successMessage); - setSnackbarOpen(true); + notify(successMessage, "success"); } catch (e) { console.error("persistBatchStatusRules:", e); setRulesForm(mergeBatchStatusDerivedRules(rulesBaseline)); - setSnackbarSeverity("error"); - setSnackbarMessage("Failed to save rules"); - setSnackbarOpen(true); + notify("Failed to save rules", "error"); } finally { setRulesSaving(false); } @@ -390,22 +372,17 @@ export default function ConfigurationsPage() { const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { const errData = await response.json().catch(() => null); - setSnackbarSeverity("error"); - setSnackbarMessage(errData?.message ?? "Failed to delete option"); + notify(errData?.message ?? "Failed to delete option", "error"); } else { - setSnackbarSeverity("error"); - setSnackbarMessage("Failed to delete option"); + notify("Failed to delete option", "error"); } - setSnackbarOpen(true); setConfirmOpen(false); return; } setConfirmOpen(false); setConfirmValueToDelete(null); - setSnackbarSeverity("success"); - setSnackbarMessage("Option deleted successfully"); - setSnackbarOpen(true); + notify("Option deleted successfully", "success"); await fetchOptions(); await loadRuleDropdownOptions(); }; @@ -843,13 +820,6 @@ export default function ConfigurationsPage() { title={confirmTitle} message="Are you sure? This will remove the value from the dropdown." /> - -
diff --git a/pages/courses.tsx b/pages/courses.tsx index e2254c6..670c0d5 100644 --- a/pages/courses.tsx +++ b/pages/courses.tsx @@ -22,9 +22,8 @@ import { exportToCsv } from "@/utils/export-to-csv"; import { AgGridReact } from "ag-grid-react"; import { smartComparator } from "@/utils/grid-comparators"; import { useCallback, useEffect, useRef, useState } from "react"; -import GlobalSnackbar from "../components/GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; import ConfirmationModal from "../components/ConfirmationModal"; -import type { AlertColor } from "@mui/material/Alert"; import { getCoursesColumnDefs } from "../utils/get-courses-column-defs"; import { canAccessConfigurationsPage } from "../utils/configurations-access"; import { toDisplay } from "../utils/types/date"; @@ -39,6 +38,7 @@ export async function getServerSideProps() { } export default function Page() { + const notify = useNotification(); // const res = null; const { data: session, status } = useSession(); @@ -65,9 +65,6 @@ export default function Page() { const [rowData, setRowData] = useState([]); const allowedRoles = ["ADMINISTRATOR", "MANAGEMENT"]; const gridRef = useRef(null); - const [message, setMessage] = useState(""); - const [alertOpen, setAlertOpen] = useState(false); - const [snackbarSeverity, setSnackbarSeverity] = useState("success"); const [confirmOpen, setConfirmOpen] = useState(false); const [confirmTitle, setConfirmTitle] = useState(""); const [deleteCourseData, setDeleteCourseData] = useState(null); @@ -129,21 +126,15 @@ export default function Page() { }); if (response.ok) { await getPageData(); - setMessage(`${cellParams.data.course} was updated!`); - setSnackbarSeverity("success"); - setAlertOpen(true); + notify(`${cellParams.data.course} was updated!`, "success"); } else { - setMessage("Error updating the course."); - setSnackbarSeverity("error"); - setAlertOpen(true); + notify("Error updating the course.", "error"); isRevertingRef.current = true; cellParams.node.setDataValue(cellParams.colDef.field, cellParams.oldValue); } } catch (err) { console.error("Error updating course:", err); - setMessage("Error updating the course."); - setSnackbarSeverity("error"); - setAlertOpen(true); + notify("Error updating the course.", "error"); isRevertingRef.current = true; cellParams.node.setDataValue(cellParams.colDef.field, cellParams.oldValue); } @@ -206,21 +197,17 @@ export default function Page() { throw new Error(errorText || "Failed to delete course"); } - setMessage(`${courseName} was deleted.`); - setSnackbarSeverity("success"); - setAlertOpen(true); + notify(`${courseName} was deleted.`, "success"); await getPageData(); } catch (error) { console.error("Error deleting the courses", error); - setMessage(`Failed to delete ${courseName}. Please try again.`); - setSnackbarSeverity("error"); - setAlertOpen(true); + notify(`Failed to delete ${courseName}. Please try again.`, "error"); await getPageData(); } finally { setContentLoading(false); } }, - [getPageData] + [getPageData, notify] ); const handleDelete = useCallback((props) => { @@ -272,9 +259,7 @@ export default function Page() { }; if (!payload.course || !payload.duration || !payload.duration_type) { - setMessage("Please fill all required fields before submitting."); - setSnackbarSeverity("error"); - setAlertOpen(true); + notify("Please fill all required fields before submitting.", "error"); return; } @@ -294,21 +279,17 @@ export default function Page() { } await getPageData(); - setMessage(`${payload.course} was created successfully!`); - setSnackbarSeverity("success"); - setAlertOpen(true); + notify(`${payload.course} was created successfully!`, "success"); form.reset(); setShowForm(false); } catch (error) { console.error("Error creating course", error); - setMessage("Failed to create course. Please try again."); - setSnackbarSeverity("error"); - setAlertOpen(true); + notify("Failed to create course. Please try again.", "error"); } finally { setContentLoading(false); } }, - [getPageData] + [getPageData, notify] ); useEffect(() => { @@ -412,7 +393,6 @@ export default function Page() {
- document.getElementById(id); export const metadata = { @@ -28,6 +29,7 @@ export const metadata = { }; export default function Reports() { + const notify = useNotification(); const router = useRouter(); const { data: session, status } = useSession({ required: true, @@ -173,16 +175,17 @@ export default function Reports() { e.preventDefault(); if (!singleBatchId) { - alert("⚠️ Please provide a Batch ID."); + notify("⚠️ Please provide a Batch ID.", "warning"); return; } setLoading(true); try { await generateBatchStatusReport(singleBatchId, "", ""); + notify("Batch status report downloaded successfully.", "success"); } catch (error) { console.error("Error generating single batch report:", error); - alert("Failed to download report. Ensure the Batch ID is correct."); + notify(error.message || "Failed to download report. Please try again.", "error"); } finally { setLoading(false); } @@ -194,7 +197,7 @@ export default function Reports() { e.preventDefault(); if ((dateRange === "custom" && (!startDate || !endDate)) || (dateRange === "quarter" && (!quarter || !year))) { - alert("⚠️ Please select a valid date range before downloading student data."); + notify("⚠️ Please select a valid date range before downloading student data.", "warning"); setLoading(false); return; } @@ -220,7 +223,7 @@ export default function Reports() { .map((batch) => batch.id); if (filteredBatchIds.length === 0) { - alert("No batches found for the selected date range."); + notify("No batches found for the selected date range.", "warning"); setLoading(false); return; } @@ -229,9 +232,10 @@ export default function Reports() { const filename = `student_data_${queryStartDate}_to_${queryEndDate}.csv`.replace(/\s+/g, "_"); downloadCSV(filename, csvContent); + notify("Student data report downloaded successfully.", "success"); } catch (error) { console.error("Error generating student data report:", error); - alert("Failed to download student data report. Please try again."); + notify("Failed to download student data report. Please try again.", "error"); } finally { setLoading(false); } @@ -243,7 +247,7 @@ export default function Reports() { e.preventDefault(); if ((dateRange === "custom" && (!startDate || !endDate)) || (dateRange === "quarter" && (!quarter || !year))) { - alert("⚠️ Please select a valid date range before downloading."); + notify("⚠️ Please select a valid date range before downloading.", "warning"); setLoading(false); return; } @@ -265,7 +269,7 @@ export default function Reports() { const filteredBatches = data.batches || []; if (filteredBatches.length === 0) { - alert("No batches found for the selected date range."); + notify("No batches found for the selected date range.", "warning"); setLoading(false); return; } @@ -273,9 +277,10 @@ export default function Reports() { const csvContent = await generateBatchReportCSV(filteredBatches); const filename = `batch_report_${queryStartDate}_to_${queryEndDate}.csv`.replace(/\s+/g, "_"); downloadCSV(filename, csvContent); + notify("Batch report downloaded successfully.", "success"); } catch (error) { console.error("Error generating report:", error); - alert("Failed to download report. Please try again."); + notify("Failed to download report. Please try again.", "error"); } finally { setLoading(false); } @@ -287,25 +292,25 @@ export default function Reports() { e.preventDefault(); if (!selectedPM) { - alert("⚠️ Please select a program manager."); + notify("⚠️ Please select a program manager.", "warning"); setLoading(false); return; } if (pmDateRangeType === "custom") { if (!pmStartDate || !pmEndDate) { - alert("⚠️ Please select both Start Date and End Date."); + notify("⚠️ Please select both Start Date and End Date.", "warning"); setLoading(false); return; } if (new Date(pmStartDate) > new Date(pmEndDate)) { - alert("⚠️ Start Date cannot be later than End Date."); + notify("⚠️ Start Date cannot be later than End Date.", "warning"); setLoading(false); return; } } else { if (!pmQuarter || !pmYear) { - alert("⚠️ Please select Year and Quarter."); + notify("⚠️ Please select Year and Quarter.", "warning"); setLoading(false); return; } @@ -321,7 +326,7 @@ export default function Reports() { const filteredBatches = filterBatchesByDateRange(pmBatches, queryStartDate, queryEndDate); if (filteredBatches.length === 0) { - alert("No batches found for the selected program manager and date range."); + notify("No batches found for the selected program manager and date range.", "warning"); setLoading(false); return; } @@ -333,9 +338,10 @@ export default function Reports() { : `pm_report_${selectedPM.replace(/\s+/g, "_")}_${queryStartDate}_to_${queryEndDate}.csv`; downloadCSV(filename, csvContent); + notify("Program manager report downloaded successfully.", "success"); } catch (error) { console.error("Error generating PM report:", error); - alert("Failed to download report. Please try again."); + notify("Failed to download report. Please try again.", "error"); } finally { setLoading(false); } diff --git a/pages/studentregistration.tsx b/pages/studentregistration.tsx index 6b48644..e30627d 100644 --- a/pages/studentregistration.tsx +++ b/pages/studentregistration.tsx @@ -10,7 +10,7 @@ may cause problems if changes are not tested thoroughly // eslint-disable-next-line react-hooks/exhaustive-deps */ -import GlobalSnackbar from "@/components/GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; import { searchAndUpdateStudentData } from "@/utils/students/search-and-update-student-data"; import { useSession } from "@/lib/auth-client-compat"; import Head from "next/head"; @@ -23,7 +23,6 @@ import Navbar from "../components/Navbar"; import UpdateStudentForm from "../components/UpdateStudentForm"; import styles from "../styles/StudentReg.module.css"; import PageTitleWithUserGuideLink from "@/components/PageTitleWithUserGuideLink"; -import type { AlertColor } from "@mui/material/Alert"; const getElementById = (id: string): unknown => document.getElementById(id); const querySelector = (selector: string): unknown => document.querySelector(selector); const querySelectorAll = (selector: string): unknown[] => Array.from(document.querySelectorAll(selector)); @@ -32,6 +31,7 @@ let worldData = require("../utils/countries+states.json"); var regError = false; export default function Page() { + const notify = useNotification(); const { data: session, status } = useSession(); const [userRole, setUserRole] = useState(null); useForm(); // Form reset @@ -59,9 +59,6 @@ export default function Page() { const [, setSelectedVision] = useState("Blind"); const [selectedEdu, setSelectedEdu] = useState("Below 10th Standard"); const [, setSelectedEmpStatus] = useState("Unemployed"); - const [message, setMessage] = useState(""); - const [severity, setSeverity] = useState("success"); // Default severity for snackbar - const [alertOpen, setAlertOpen] = useState(false); // Client-side only rendering const [isClient, setIsClient] = useState(false); @@ -190,15 +187,13 @@ export default function Page() { // Apply controlled fields (country/state/city, disability, education, // percent vision) via React state; direct DOM assignment does not stick. applyPrefillFields(); - setMessage("Student information was found and data has been pre-filled!"); - setSeverity("success"); - setAlertOpen(true); + notify("Student information was found and data has been pre-filled!", "success"); } }) .catch((error) => { console.error("Error searching for student:", error); }); - }, [gender, dobYear, dobMonth, dobDay, phone_number, applyPrefillFields]); + }, [gender, dobYear, dobMonth, dobDay, phone_number, applyPrefillFields, notify]); // Handle date field updates useEffect(() => { @@ -228,9 +223,7 @@ export default function Page() { }, [isClient]); // Add isClient dependency const showToast = () => { - setMessage("Error registering student"); - setSeverity("error"); - setAlertOpen(true); + notify("Error registering student", "error"); }; useEffect(() => { @@ -476,24 +469,6 @@ export default function Page() { return true; }; - // const checkValidPhone = () => { - // var phoneinput = getElementById("phone_number"); - // if (phoneinput.value == 1112223333) { - // alert("PHONE NUMBER must be 10 digits; number was reset to a PLACEHOLDER NUMBER, WHICH MUST BE CHANGED."); - // phoneinput.value = 1112223333; - // phoneinput.focus(); - // } - //} - // const checkValidAltPhone = () => { - // var phoneinput = getElementById("alt_ph_num"); - // if (phoneinput.value != "") { - // if (phoneinput.value == 1112223333) { - // alert("PARENT/GUARDIAN PHONE NUMBER must be 10 digits; number was reset to a PLACEHOLDER NUMBER, WHICH MUST BE CHANGED."); - // phoneinput.value = 1112223333; - // phoneinput.focus(); - // } - // } - //} /*----------------- PHONE VALIDATION (REG AND ALT) ENDS ------------*/ // Line below may work FOR SSRPROVIDER ERRORS: @@ -896,7 +871,6 @@ export default function Page() { if (response.ok) { if (window.isUpdateMode) { // If update mode, show success and redirect - // alert("Student updated successfully"); (alert("Student updated successfully");) setContentLoading(true); // // Reset update mode @@ -2135,7 +2109,6 @@ export default function Page() { )} {/* Update Student Form */} {isClient && showUpdateForm && setShowUpdateForm(false)} />} - ); } diff --git a/pages/students.tsx b/pages/students.tsx index 0a5aea4..b2cbb8e 100644 --- a/pages/students.tsx +++ b/pages/students.tsx @@ -7,7 +7,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { useCallback, useEffect, useRef, useState } from "react"; -import GlobalSnackbar from "../components/GlobalSnackbar"; +import { useNotification } from "@/components/notifications/NotificationProvider"; // import { Button } from "@mui/material"; import Navbar from "@/components/Navbar"; import styles from "@/styles/Home.module.css"; @@ -31,6 +31,7 @@ function CircleLoadingOverlay() { } const Students = () => { + const notify = useNotification(); const router = useRouter(); const { data: session, status } = useSession(); @@ -39,11 +40,9 @@ const Students = () => { const [user, setUser] = useState(undefined); const [rowData, setRowData] = useState([]); const [allowedRoles] = useState(["ADMINISTRATOR", "MANAGEMENT", "TELECALLER", "TRAINERPLUSTELECALLER"]); - const [alertOpen, setAlertOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(""); const [deleteStudentData, setDeleteStudentData] = useState(null); const [confirmTitle, setConfirmTitle] = useState(undefined); const [editConfirmOpen, setEditConfirmOpen] = useState(false); @@ -81,10 +80,9 @@ const Students = () => { return normalized || null; } if (["id_proof", "disability_cert", "photo", "bank_details"].includes(field)) { - if (value === "Yes") return 1; - if (value === "No") return 0; - // Number or null is passed through - return value ?? null; + if (value === "Yes" || value === 1 || value === "1") return "Yes"; + if (value === "No" || value === 0 || value === "0") return "No"; + return value === "" ? null : (value ?? null); } return value === "" ? null : value; }; @@ -140,8 +138,7 @@ const Students = () => { const data = await r.json(); if (data.success !== false) { setRowData((prev) => prev.map((row) => (row.id === payload.student_id ? { ...row, ...payload } : row))); - setMessage("Saved!"); - setAlertOpen(true); + notify("Saved!", "success"); const focused = lastFocusedCell.current; if (focused && gridRef.current?.api) { const { rowIndex, colKey } = focused; @@ -160,8 +157,7 @@ const Students = () => { } } catch (e) { console.error("Error saving student:", e); - setMessage(`Save failed: ${e.message || "Unknown error"}`); - setAlertOpen(true); + notify(`Save failed: ${e.message || "Unknown error"}`, "error"); } finally { setSaving(false); } @@ -243,17 +239,14 @@ const Students = () => { }); if (response.ok) { - setMessage("Telecaller remarks updated successfully!"); - setAlertOpen(true); + notify("Telecaller remarks updated successfully!", "success"); onGridReady(); } else { - setMessage("Error updating remarks. Please try again."); - setAlertOpen(true); + notify("Error updating remarks. Please try again.", "error"); } } catch (error) { console.error("Error updating telecaller remarks:", error); - setMessage("Error updating remarks. Please try again."); - setAlertOpen(true); + notify("Error updating remarks. Please try again.", "error"); } return; } @@ -262,8 +255,7 @@ const Students = () => { if (field === "enrollment_status") { const code = ENROLLMENT_STATUS.find((o) => o.value === rawNew || o.label === rawNew)?.value ?? null; if (rawNew && !code) { - setMessage("Invalid enrollment status"); - setAlertOpen(true); + notify("Invalid enrollment status", "error"); return; } value = code; // Send only code to server @@ -329,19 +321,16 @@ const Students = () => { body: JSON.stringify({ student_id, remark: rawNew }), }); if (response.ok) { - setMessage("Telecaller remarks updated successfully!"); - setAlertOpen(true); + notify("Telecaller remarks updated successfully!", "success"); onGridReady(); } else { - setMessage("Error updating remarks. Please try again."); - setAlertOpen(true); + notify("Error updating remarks. Please try again.", "error"); isRevertingCellRef.current = true; node.setDataValue(field, rawOld); } } catch (error) { console.error("Error updating telecaller remarks:", error); - setMessage("Error updating remarks. Please try again."); - setAlertOpen(true); + notify("Error updating remarks. Please try again.", "error"); isRevertingCellRef.current = true; node.setDataValue(field, rawOld); } @@ -419,9 +408,8 @@ const Students = () => { body: JSON.stringify({ id: deleteStudentData.id }), }).then(() => { const id = deleteStudentData.id; - setMessage(`${deleteStudentData.name} was deleted!`); + notify(`${deleteStudentData.name} was deleted!`, "success"); setDeleteStudentData(null); - setAlertOpen(true); setRowData(rowData.filter((student) => student.id !== id)); setLoading(false); }); @@ -513,7 +501,6 @@ const Students = () => { } `}
- (() => []); const [courseOptions1, setCourseOptions1] = useState(() => []); @@ -160,8 +159,7 @@ export default function Page() { if (response.ok) { await getPageData(); - setMessage(`User: ${editedUser.name} updated!`); - setAlertOpen(true); + notify(`User: ${editedUser.name} updated!`, "success"); setEditingId(null); } else { console.error("Error updating the user"); @@ -170,7 +168,7 @@ export default function Page() { setContentLoading(false); } }, - [getPageData] + [getPageData, notify] ); const onCellValueChanged = async (params) => { @@ -270,8 +268,7 @@ export default function Page() { if (response.ok) { await getPageData(); - setMessage("Staff member created successfully!"); - setAlertOpen(true); + notify("Staff member created successfully!", "success"); // Reset controlled form states so the next open starts clean setShowForm(false); @@ -288,17 +285,15 @@ export default function Page() { // ignore JSON parse errors } - setMessage("Error creating staff: " + errMessage); - setAlertOpen(true); + notify("Error creating staff: " + errMessage, "error"); } } catch (error) { console.error("Error creating staff:", error); - setMessage("Error creating staff: " + (error?.error || error?.message || "Unknown error")); - setAlertOpen(true); + notify("Error creating staff: " + (error?.error || error?.message || "Unknown error"), "error"); } finally { setContentLoading(false); } - }, [pendingUserCreate, getPageData]); + }, [pendingUserCreate, getPageData, notify]); const onGridReady = useCallback((params) => { // Store grid API reference if needed @@ -391,9 +386,8 @@ export default function Page() { setLoading(true); setConfirmOpen(false); handleDeleteUser(deleteUsersData.id).then(() => { - setMessage(`${deleteUsersData.name} was deleted!`); + notify(`${deleteUsersData.name} was deleted!`, "success"); setDeleteUsersData(null); - setAlertOpen(true); setLoading(false); }); }; @@ -571,7 +565,6 @@ export default function Page() {
- =6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -263,6 +270,33 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -691,6 +725,21 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} @@ -4253,6 +4302,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resend@6.20.0: resolution: {integrity: sha512-fXDFt7jVMuka6ruS79DzaQFKPyxOAUtoYUGSl3dTUyOnFK9klmUULxADQRzFFtHfHRipYyy62jYcm4cMKqvtjw==} engines: {node: '>=20'} @@ -5124,6 +5176,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -5147,6 +5201,30 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.8.0(@types/react@19.3.0)(date-fns@4.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.6.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + date-fns: 4.1.0 + + '@base-ui/utils@0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@bcoe/v8-coverage@1.0.2': {} '@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)': @@ -5497,6 +5575,23 @@ snapshots: optionalDependencies: '@noble/hashes': 2.3.0 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@floating-ui/utils@0.2.12': {} + '@hexagon/base64@1.1.28': {} '@humanfs/core@0.19.2': @@ -9103,6 +9198,8 @@ snapshots: reselect@5.1.1: {} + reselect@5.2.0: {} + resend@6.20.0: dependencies: postal-mime: 2.7.5 diff --git a/utils/generate-batch-status-report.ts b/utils/generate-batch-status-report.ts index 47a1801..a98554f 100644 --- a/utils/generate-batch-status-report.ts +++ b/utils/generate-batch-status-report.ts @@ -15,13 +15,11 @@ import { * @param {string} batchName - The batch name (optional, will be fetched if not provided) * @param {string} courseName - The course name (optional, will be fetched if not provided) * @returns {Promise} + * @throws {Error} when report data is unavailable or report generation fails */ export const generateBatchStatusReport = async (batchId, batchName, courseName) => { if (!batchId) { - if (typeof window !== "undefined") { - window.alert("Batch ID is missing"); - } - return; + throw new Error("Batch ID is missing."); } try { @@ -36,10 +34,7 @@ export const generateBatchStatusReport = async (batchId, batchName, courseName) const batchData = await response.json(); if (!batchData.students || batchData.students.length === 0) { - if (typeof window !== "undefined") { - window.alert("No students found for this batch"); - } - return; + throw new Error("No students found for this batch."); } // Use fetched batch name and course name if not provided @@ -198,8 +193,9 @@ export const generateBatchStatusReport = async (batchId, batchName, courseName) exportToCsv(fileName, rows, summaryRows); } catch (error) { console.error("Error generating batch status report:", error); - if (typeof window !== "undefined") { - window.alert("Failed to download report. Please try again."); + if (error instanceof Error && error.message === "No students found for this batch.") { + throw error; } + throw new Error("Failed to download report. Please try again."); } };