From 701c1c8b4d47d6f40704743332c7486267d1dc12 Mon Sep 17 00:00:00 2001 From: Jared Edge <160628492+Edge-J@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:05:46 -0400 Subject: [PATCH 1/5] All team members added required files for User Story 4, Includes functionalities and styling for an alert notification feature (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat/userstory4/styles (#74) * chore: git pull main * Add Supabase dependency and resolve TypeScript compilation issues * Implement comprehensive alert system with UI components and utilities - Add alertUtils.ts with 6 core utility functions for alert management - Create AlertBadge component with severity-based styling and variants - Add ThresholdSlider with interactive threshold setting and sensitivity indicators - Implement AlertIcon with SVG icons for different alert types - Build DismissButton with confirmation dialogs and loading states - Create NotificationIndicator with badges, dots, and positioning system - Add comprehensive CSS styling with accessibility and responsive design - Update utilities index to export all alert functionality Features: - TypeScript interfaces for AlertSeverity, AlertType, PriorityLevel, AttendanceAlert - Accessibility support with ARIA labels and keyboard navigation - Responsive design with mobile-friendly layouts - High contrast and reduced motion support - Print styles optimization - CSS custom properties for easy theming - Comprehensive component variants and presets * Delete package.json --------- Co-authored-by: Your Name Co-authored-by: bscott519 <113139519+bscott519@users.noreply.github.com> * feat: adds alert configuration modals for user story 4 (#78) * feat: add useAlertModal hook * feat: add ThresholdSettings Modal * feat: add alertDropdown * feat: add confirmation dialog for dismissing alerts * feat: update individual css files for alertdetailsmodal, alertworkflow integration, and notification settings * feat: update files to remove redundancies * feat: remove duplicates from useAlertModals file * feat: remove redundant css files * feat: remove parent notification settings * feat: update useAlertModal hook to remove notificationmodal states * User story 4 data validation (#76) * feat: add simple alert and threshold type definitions - Add SimpleAlertData interface for basic alert display - Add SimpleThresholdFormData for setting absence/lateness thresholds - Add SimpleAlertCalculationResult for threshold monitoring - Add alert filtering, sorting, and summary statistics interfaces - Add default threshold values and validation constants - Extend AttendanceAlert and AlertThreshold domains with supporting interfaces - Implement consistent ValidationResult pattern for all validation functions * feat: implement basic threshold and alert validation - Add alertValidation.ts with functions for alert data validation - Add thresholdValidation.ts with threshold form validation logic - Implement validateAlertData() for alert structure validation - Implement validateThresholdForm() with comprehensive field checking - Add sanitizeThresholdInput() for cleaning user input - Add validateTimeframe() for date range validation - Include cross-field validation for logical threshold relationships - Use consistent ValidationResult pattern throughout * feat: add simple alert calculation utilities - Add alertCalculations.ts with core attendance counting logic - Implement calculateStudentAlerts() for threshold monitoring - Add countAbsencesInPeriod() and countLatenessInPeriod() for rolling window calculations - Add countAbsencesCumulative() and countLatenessCumulative() for total counts - Implement calculateDaysOverThreshold() for duration tracking - Add batch calculation support for multiple students - Include attendance trend analysis and threshold proximity checking - Add date range filtering and validation utilities * test: add comprehensive validation and calculation tests - Add alertValidation.test.ts with validation function tests - Add thresholdValidation.test.ts with threshold form tests - Add alertCalculations.test.ts with calculation logic tests - Ensure 80% code coverage for alert system utilities - Test edge cases and error conditions for robust validation - Support core attendance threshold monitoring requirements * docs: add comprehensive alert system documentation - Add docs/alert-system.md with complete usage guide - Include JSDoc comments for key validation functions - Document integration patterns and error handling - Provide code examples for common use cases - Cover testing strategy and performance considerations - Support core attendance threshold monitoring requirements * fix: remove AlertSeverity reference for simplified alert system - Change getSeverityLevel() return type from AlertSeverity to string - Maintain compatibility with simplified alert architecture - Ensures clean TypeScript compilation without build errors * fix: resolve ESLint issues in User Story 4 files - Remove unused imports of AlertType and AlertPeriod - Replace 'any' types with 'unknown' for better type safety - Ensure clean lint compliance for alert validation system - Maintain code quality standards for GitHub CI/CD pipeline * chore: add ESLint configuration file - Auto-generated ESLint config from npm run lint - Ensures consistent code style across the project * feat: implemented alerts page (#75) * created files for user story 3 requirements * implementing interactive calendar components * finished created interactive calendar and redesigned dashboard * implemented alerts dashboard * edited css for the intervention form --------- Co-authored-by: Benjamin Scott Co-authored-by: Jared Edge <160628492+Edge-J@users.noreply.github.com> * Story 4 dev (#79) * feat(validation): implement User Story 3 schedule data validation foundation (#66) • Add comprehensive Zod validation schemas for schedule and day-off operations • Implement date validation utilities with business rule enforcement • Create reason validation with smart suggestion system • Build centralized validation orchestration with conflict detection • Establish type-safe interfaces for team integration • Achieve 81% test coverage with 64 comprehensive unit tests * Feat: adds schedule API endpoints including days off and excused days * Feat: adds Delete endpoint and API validation * Fix: Ensure data directory and schedule.json file exist for Vercel deployment * Feat: adds domain models and database schema for the alert system * Feat: adds Alerting logic and API endpoints for alerts * Chore: fix API endpoints and failed vercel build * Implement RAG for alert explanations (User Story 4) * Feat: adds combines modals into one page to improve UX * Feat: fixes reports page UI * Feat: adds environment Setup & dependencies for openAI API * Feat: adds fix vercel build issues with dotenv and OpenAI dependencies * Feat: adds system prompts, LLM to support context, updayed RAG to use prompts, and added tests * Feat: adds OpenAI client configuration, Query Processing and Sanitization, Response Handling and Validation, Additional Improvements * Feat: adds Response Formatting & Validation Implementation. Output Structure Standardization, Error Handling , Configuration Updates, Integration Improvements * Feat: adapter between domains, Fixed type compatibility, Fixed date formatting and adjusted filter handling * Feat: added test files for rag and llm. * Feat: adds corrected endpoints, proper logic and formating for LLM response, DATA formatiing for response fix * fix: response not appearing after user prompt --------- Co-authored-by: lquinoa252 * chore: resolves merge confilct * chore: removes strict lint rules for deployment * feat: adds removes build blocks * chore: fixes duplicate methods * feat: removes mention of supabase and fixes student mapping --------- Co-authored-by: tboyle252-sudo Co-authored-by: Your Name Co-authored-by: bscott519 <113139519+bscott519@users.noreply.github.com> Co-authored-by: imo-252 Co-authored-by: lquinoa252 Co-authored-by: Benjamin Scott --- .env.example | 15 + .eslintrc.json | 17 + .gitignore | 6 + .vscode/settings.json | 3 + app/alerts/page.tsx | 39 + app/api/alerts/notifications/route.ts | 139 +++ app/api/alerts/route.ts | 143 +++ app/api/alerts/thresholds/route.ts | 185 ++++ app/page.tsx | 183 ++-- app/reports/page.tsx | 195 +--- components/RAGQueryBox.css | 52 + components/RAGQueryBox.tsx | 213 ++-- components/alerts/AlertCard.tsx | 248 +++++ components/alerts/AlertDetailsModal.tsx | 136 +++ components/alerts/AlertDropdown.tsx | 91 ++ components/alerts/AlertSummaryStats.tsx | 239 +++++ .../alerts/AlertWorkflowIntegration.tsx | 183 ++++ components/alerts/Alerts.css | 965 ++++++++++++++++++ components/alerts/AlertsDashboard.tsx | 121 +++ components/alerts/AlertsList.tsx | 300 ++++++ components/alerts/DismissAlertModal.tsx | 122 +++ .../alerts/StudentInterventionPanel.tsx | 504 +++++++++ components/alerts/ThresholdSettingsModal.tsx | 77 ++ components/reports/FilterPanel.css | 9 +- components/reports/FilterPanel.tsx | 4 +- components/schedule/ScheduledEventsList.tsx | 262 +---- components/ui/AlertBadge.tsx | 134 +++ components/ui/AlertIcon.tsx | 303 ++++++ components/ui/CalendarDay.tsx | 1 + components/ui/DismissButton.tsx | 266 +++++ components/ui/NotificationIndicator.tsx | 290 ++++++ components/ui/ThresholdSlider.tsx | 256 +++++ components/utilities/alertUtils.ts | 244 +++++ components/utilities/index.ts | 18 + docs/LLM-INTEGRATION.md | 98 ++ docs/alert-system.md | 261 +++++ hooks/useAlertModals.ts | 159 +++ next.config.js | 21 + package-lock.json | 426 +++++--- package.json | 11 +- public/free-bell-icon-860-thumb.png | Bin 0 -> 23618 bytes scripts/ensure-dependencies.js | 39 + src/constants/alertConstants.ts | 52 + src/domains/AlertThreshold.ts | 40 + src/domains/AttendanceAlert.ts | 70 ++ src/scripts/test-llm-enhanced.ts | 96 ++ src/scripts/test-openai-env.ts | 29 + src/scripts/test-rag-llm-integration.ts | 68 ++ src/scripts/test-system-prompts.ts | 92 ++ src/services/AlertService.ts | 341 +++++++ src/services/LLMResponseValidator.ts | 106 ++ src/services/LLMService.ts | 656 ++++++++++++ src/services/LLMServiceConfig.ts | 59 ++ src/services/QueryProcessor.ts | 185 ++++ src/services/QuerySanitizer.ts | 96 ++ src/services/RAGIntegration.ts | 446 ++++++++ src/services/RAGService.ts | 685 +++++++++++++ src/services/README.md | 0 src/services/hooks/useAlerts.ts | 151 +++ src/services/hooks/useScheduleCalendar.ts | 90 +- src/types/alerts.ts | 102 ++ src/types/env.d.ts | 10 + src/types/thresholds.ts | 42 + src/utils/alertCalculations.ts | 267 +++++ src/utils/alertValidation.ts | 208 ++++ src/utils/attendance-output-validator.ts | 363 +++++++ src/utils/context-management.ts | 256 +++++ src/utils/db-context-formatter.ts | 386 +++++++ src/utils/embeddings.ts | 55 + src/utils/environment.ts | 100 ++ src/utils/llm-error-handler.ts | 243 +++++ src/utils/rag-validator.ts | 109 ++ src/utils/response-adapter.ts | 384 +++++++ src/utils/response-formatter.ts | 152 +++ src/utils/retry.ts | 90 ++ src/utils/system-prompts.ts | 320 ++++++ src/utils/terminal-formatter.ts | 332 ++++++ src/utils/thresholdValidation.ts | 207 ++++ test-api.js | 38 + tests/integrations/rag-integration.test.ts | 149 +++ tests/integrations/story5_AlertSystem.test.ts | 197 ++++ tests/unit/alertCalculations.test.ts | 334 ++++++ tests/unit/alertValidation.test.ts | 259 +++++ .../unit/attendance-output-validator.test.ts | 90 ++ tests/unit/db-context-formatter.test.ts | 182 ++++ tests/unit/llm-error-handler.test.ts | 118 +++ tests/unit/llm-service.test.ts | 210 ++++ tests/unit/response-formatter.test.ts | 158 +++ tests/unit/thresholdValidation.test.ts | 270 +++++ vercel.json | 2 + 90 files changed, 15108 insertions(+), 765 deletions(-) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .vscode/settings.json create mode 100644 app/alerts/page.tsx create mode 100644 app/api/alerts/notifications/route.ts create mode 100644 app/api/alerts/route.ts create mode 100644 app/api/alerts/thresholds/route.ts create mode 100644 components/RAGQueryBox.css create mode 100644 components/alerts/AlertCard.tsx create mode 100644 components/alerts/AlertDetailsModal.tsx create mode 100644 components/alerts/AlertDropdown.tsx create mode 100644 components/alerts/AlertSummaryStats.tsx create mode 100644 components/alerts/AlertWorkflowIntegration.tsx create mode 100644 components/alerts/Alerts.css create mode 100644 components/alerts/AlertsDashboard.tsx create mode 100644 components/alerts/AlertsList.tsx create mode 100644 components/alerts/DismissAlertModal.tsx create mode 100644 components/alerts/StudentInterventionPanel.tsx create mode 100644 components/alerts/ThresholdSettingsModal.tsx create mode 100644 components/ui/AlertBadge.tsx create mode 100644 components/ui/AlertIcon.tsx create mode 100644 components/ui/DismissButton.tsx create mode 100644 components/ui/NotificationIndicator.tsx create mode 100644 components/ui/ThresholdSlider.tsx create mode 100644 components/utilities/alertUtils.ts create mode 100644 docs/LLM-INTEGRATION.md create mode 100644 docs/alert-system.md create mode 100644 hooks/useAlertModals.ts create mode 100644 public/free-bell-icon-860-thumb.png create mode 100644 scripts/ensure-dependencies.js create mode 100644 src/constants/alertConstants.ts create mode 100644 src/scripts/test-llm-enhanced.ts create mode 100644 src/scripts/test-openai-env.ts create mode 100644 src/scripts/test-rag-llm-integration.ts create mode 100644 src/scripts/test-system-prompts.ts create mode 100644 src/services/LLMResponseValidator.ts create mode 100644 src/services/LLMService.ts create mode 100644 src/services/LLMServiceConfig.ts create mode 100644 src/services/QueryProcessor.ts create mode 100644 src/services/QuerySanitizer.ts create mode 100644 src/services/RAGIntegration.ts create mode 100644 src/services/RAGService.ts delete mode 100644 src/services/README.md create mode 100644 src/services/hooks/useAlerts.ts create mode 100644 src/types/alerts.ts create mode 100644 src/types/env.d.ts create mode 100644 src/types/thresholds.ts create mode 100644 src/utils/alertCalculations.ts create mode 100644 src/utils/alertValidation.ts create mode 100644 src/utils/attendance-output-validator.ts create mode 100644 src/utils/context-management.ts create mode 100644 src/utils/db-context-formatter.ts create mode 100644 src/utils/embeddings.ts create mode 100644 src/utils/environment.ts create mode 100644 src/utils/llm-error-handler.ts create mode 100644 src/utils/rag-validator.ts create mode 100644 src/utils/response-adapter.ts create mode 100644 src/utils/response-formatter.ts create mode 100644 src/utils/retry.ts create mode 100644 src/utils/system-prompts.ts create mode 100644 src/utils/terminal-formatter.ts create mode 100644 src/utils/thresholdValidation.ts create mode 100644 test-api.js create mode 100644 tests/integrations/rag-integration.test.ts create mode 100644 tests/integrations/story5_AlertSystem.test.ts create mode 100644 tests/unit/alertCalculations.test.ts create mode 100644 tests/unit/alertValidation.test.ts create mode 100644 tests/unit/attendance-output-validator.test.ts create mode 100644 tests/unit/db-context-formatter.test.ts create mode 100644 tests/unit/llm-error-handler.test.ts create mode 100644 tests/unit/llm-service.test.ts create mode 100644 tests/unit/response-formatter.test.ts create mode 100644 tests/unit/thresholdValidation.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..409d231 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Supabase Configuration +NEXT_PUBLIC_SUPABASE_URL=your_supabase_url_here +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here + +# OpenAI Configuration for RAG features +OPENAI_API_KEY=your_openai_api_key_here + +# OpenAI Model Configuration +OPENAI_MODEL=gpt-4-turbo + +# RAG System Configuration +RAG_SYSTEM_PROMPT="You are an AI assistant for an Attendance Management System. Help users understand attendance data, generate reports, and provide insights about student attendance patterns." + +# Environment Configuration +NODE_ENV=development diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..78f16e8 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-require-imports": "off", + "prefer-const": "off", + "react/no-unescaped-entities": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-empty-object-type": "off", + "react-hooks/exhaustive-deps": "off" + } +} diff --git a/.gitignore b/.gitignore index a38cde4..46d422f 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,9 @@ vite.config.ts.timestamp-* # Ignore all data files in persistence data/ src/persistence/*.json + +# Local environment variables - never commit these +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9221781 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "terminal.integrated.gpuAcceleration": "off" +} \ No newline at end of file diff --git a/app/alerts/page.tsx b/app/alerts/page.tsx new file mode 100644 index 0000000..25582f6 --- /dev/null +++ b/app/alerts/page.tsx @@ -0,0 +1,39 @@ +// FILE: /Users/bscott252/Downloads/cs-25-2-team6-main/cs-25-2-team6/app/alerts/page.tsx +'use client'; + +import React from 'react'; +import AlertsDashboard from '../../components/alerts/AlertsDashboard'; + +export default function AlertsPage() { + return ( +
+
+
+

+ Alerts Dashboard +

+

+ Monitor and manage attendance alerts and student interventions +

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/api/alerts/notifications/route.ts b/app/api/alerts/notifications/route.ts new file mode 100644 index 0000000..d0461c3 --- /dev/null +++ b/app/api/alerts/notifications/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { NotificationService } from '../../../../src/services/NotificationService'; +import { FileAlertRepo } from '../../../../src/persistence/FileAlertRepo'; +import path from 'path'; +import fs from 'fs'; + +// Ensure data directory exists for notifications +const dataPath = path.join(process.cwd(), 'data'); +if (!fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); +} + +// Ensure notifications file exists +const notificationsFilePath = path.join(dataPath, 'notifications.json'); +if (!fs.existsSync(notificationsFilePath)) { + fs.writeFileSync(notificationsFilePath, JSON.stringify([]), 'utf8'); +} + +// Initialize services +const notificationService = new NotificationService(); +const alertRepo = new FileAlertRepo(); + +/** + * GET /api/alerts/notifications + * Get notifications with optional filters + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const parentId = searchParams.get('parentId') || undefined; + const studentId = searchParams.get('studentId') || undefined; + const status = searchParams.get('status') || undefined; + + const notifications = notificationService.getNotifications({ + parentId, + studentId, + status + }); + + return NextResponse.json({ + success: true, + data: notifications + }); + } catch (error) { + console.error('Error getting notifications:', error); + return NextResponse.json( + { success: false, error: 'Failed to get notifications' }, + { status: 500 } + ); + } +} + +/** + * POST /api/alerts/notifications + * Send a notification to parents + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { alertId, parentIds, message, sendEmail = false, sendSMS = false } = body; + + // Validate required fields + if (!alertId || !parentIds || !Array.isArray(parentIds)) { + return NextResponse.json( + { success: false, error: 'Alert ID and parent IDs array are required' }, + { status: 400 } + ); + } + + // Get the alert to notify about + const alert = alertRepo.getAlertById(alertId); + + if (!alert) { + return NextResponse.json( + { success: false, error: `Alert with ID ${alertId} not found` }, + { status: 404 } + ); + } + + // Send the notification + const results = await notificationService.sendNotification({ + alert, + parentIds, + customMessage: message, + sendEmail, + sendSMS + }); + + return NextResponse.json({ + success: true, + data: results, + message: 'Notifications sent successfully', + }); + } catch (error) { + console.error('Error sending notifications:', error); + return NextResponse.json( + { success: false, error: 'Failed to send notifications' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/alerts/notifications + * Update notification status (read/unread) + */ +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { notificationId, status } = body; + + if (!notificationId || !status) { + return NextResponse.json( + { success: false, error: 'Notification ID and status are required' }, + { status: 400 } + ); + } + + const success = notificationService.updateNotificationStatus(notificationId, status); + + if (!success) { + return NextResponse.json( + { success: false, error: `Notification with ID ${notificationId} not found` }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Notification status updated successfully' + }); + } catch (error) { + console.error('Error updating notification status:', error); + return NextResponse.json( + { success: false, error: 'Failed to update notification status' }, + { status: 500 } + ); + } +} diff --git a/app/api/alerts/route.ts b/app/api/alerts/route.ts new file mode 100644 index 0000000..4af333b --- /dev/null +++ b/app/api/alerts/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AlertService } from '../../../src/services/AlertService'; +import { AlertFilters } from '../../../src/domains/AttendanceAlert'; +import path from 'path'; +import fs from 'fs'; + +// Ensure data directory exists for alerts +const dataPath = path.join(process.cwd(), 'data'); +if (!fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); +} + +// Ensure alert files exist +const alertsFilePath = path.join(dataPath, 'alerts.json'); +const thresholdsFilePath = path.join(dataPath, 'alert_thresholds.json'); + +if (!fs.existsSync(alertsFilePath)) { + fs.writeFileSync(alertsFilePath, JSON.stringify([]), 'utf8'); +} + +if (!fs.existsSync(thresholdsFilePath)) { + fs.writeFileSync(thresholdsFilePath, JSON.stringify([]), 'utf8'); +} + +// Initialize services +const alertService = new AlertService(); + +/** + * GET /api/alerts + * Get alerts with optional filters + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + + // Parse filters from query params + const filters: AlertFilters = {}; + + const studentId = searchParams.get('studentId'); + if (studentId) { + filters.studentId = studentId; + } + + const status = searchParams.get('status'); + if (status) { + filters.status = status.split(',') as any[]; + } + + const type = searchParams.get('type'); + if (type) { + filters.type = type.split(',') as any[]; + } + + const period = searchParams.get('period'); + if (period) { + filters.period = period as any; + } + + const dateFrom = searchParams.get('dateFrom'); + if (dateFrom) { + filters.dateFrom = new Date(dateFrom); + } + + const dateTo = searchParams.get('dateTo'); + if (dateTo) { + filters.dateTo = new Date(dateTo); + } + + // Get alerts requiring intervention + const alerts = await alertService.getAlertsRequiringIntervention(filters); + + return NextResponse.json({ + success: true, + data: alerts, + }); + } catch (error) { + console.error('Error getting alerts:', error); + return NextResponse.json( + { success: false, error: 'Failed to get alerts' }, + { status: 500 } + ); + } +} + +/** + * POST /api/alerts/process + * Process automatic alerts + */ +export async function POST(request: NextRequest) { + try { + const result = await alertService.processAutomaticAlerts(); + + return NextResponse.json({ + success: result.errors.length === 0, + data: result, + message: `Processed ${result.processed} alerts, triggered ${result.triggered}, sent ${result.notificationsSent} notifications`, + }); + } catch (error) { + console.error('Error processing alerts:', error); + return NextResponse.json( + { success: false, error: 'Failed to process alerts' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/alerts + * Dismiss an alert + */ +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const alertId = searchParams.get('id'); + + if (!alertId) { + return NextResponse.json( + { success: false, error: 'Alert ID parameter is required' }, + { status: 400 } + ); + } + + const success = alertService.dismissAlert(alertId); + + if (!success) { + return NextResponse.json( + { success: false, error: 'Alert not found or could not be dismissed' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Alert dismissed successfully', + }); + } catch (error) { + console.error('Error dismissing alert:', error); + return NextResponse.json( + { success: false, error: 'Failed to dismiss alert' }, + { status: 500 } + ); + } +} diff --git a/app/api/alerts/thresholds/route.ts b/app/api/alerts/thresholds/route.ts new file mode 100644 index 0000000..70d3a7a --- /dev/null +++ b/app/api/alerts/thresholds/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AlertService } from '../../../../src/services/AlertService'; +import { AlertThreshold, AlertType, AlertPeriod } from '../../../../src/domains/AlertThreshold'; +import { FileAlertRepo } from '../../../../src/persistence/FileAlertRepo'; +import path from 'path'; +import fs from 'fs'; + +// Ensure data directory exists for thresholds +const dataPath = path.join(process.cwd(), 'data'); +if (!fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); +} + +// Ensure thresholds file exists +const thresholdsFilePath = path.join(dataPath, 'alert_thresholds.json'); +if (!fs.existsSync(thresholdsFilePath)) { + fs.writeFileSync(thresholdsFilePath, JSON.stringify([]), 'utf8'); +} + +// Initialize services +const alertService = new AlertService(); +const alertRepo = new FileAlertRepo(); + +/** + * GET /api/alerts/thresholds + * Get alert thresholds with optional filters + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + const typeStr = searchParams.get('type'); + const type = typeStr ? typeStr as AlertType : null; + + let thresholds; + + if (studentId) { + // Get thresholds for a specific student + thresholds = alertRepo.getThresholdsByStudent(studentId); + } else if (type) { + // Get thresholds by type + thresholds = alertRepo.getThresholdsByType(type); + } else { + // Get all thresholds + thresholds = alertRepo.getAllThresholds(); + } + + return NextResponse.json({ + success: true, + data: thresholds, + }); + } catch (error) { + console.error('Error getting thresholds:', error); + return NextResponse.json( + { success: false, error: 'Failed to get thresholds' }, + { status: 500 } + ); + } +} + +/** + * POST /api/alerts/thresholds + * Create or update an alert threshold + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type, count, period, studentId, notifyParents, id } = body; + + // Validate required fields + if (!type || count === undefined || !period) { + return NextResponse.json( + { success: false, error: 'Type, count, and period are required' }, + { status: 400 } + ); + } + + // Check if valid values for type and period + if (!Object.values(AlertType).includes(type)) { + return NextResponse.json( + { success: false, error: `Invalid alert type: ${type}` }, + { status: 400 } + ); + } + + if (!Object.values(AlertPeriod).includes(period)) { + return NextResponse.json( + { success: false, error: `Invalid period: ${period}` }, + { status: 400 } + ); + } + + let threshold; + + if (id) { + // Update existing threshold + threshold = alertRepo.getThresholdById(id); + + if (!threshold) { + return NextResponse.json( + { success: false, error: `Threshold with ID ${id} not found` }, + { status: 404 } + ); + } + + threshold.update({ + count, + notifyParents: notifyParents ?? threshold.notifyParents + }); + } else { + // Create new threshold + threshold = AlertThreshold.createNew( + type, + count, + period, + studentId || null, + notifyParents || false + ); + } + + // Validate and save threshold + const valid = alertService.saveThreshold(threshold); + + if (!valid) { + return NextResponse.json( + { success: false, error: 'Invalid threshold settings' }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + data: threshold, + message: id ? 'Threshold updated successfully' : 'Threshold created successfully', + }); + } catch (error) { + console.error('Error saving threshold:', error); + return NextResponse.json( + { success: false, error: 'Failed to save threshold' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/alerts/thresholds + * Delete an alert threshold + */ +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { success: false, error: 'Threshold ID parameter is required' }, + { status: 400 } + ); + } + + // Check if threshold exists + const threshold = alertRepo.getThresholdById(id); + + if (!threshold) { + return NextResponse.json( + { success: false, error: `Threshold with ID ${id} not found` }, + { status: 404 } + ); + } + + // Delete threshold + const success = alertRepo.deleteThreshold(id); + + return NextResponse.json({ + success, + message: success ? 'Threshold deleted successfully' : 'Failed to delete threshold', + }); + } catch (error) { + console.error('Error deleting threshold:', error); + return NextResponse.json( + { success: false, error: 'Failed to delete threshold' }, + { status: 500 } + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 20e60fc..a428d96 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,20 +2,17 @@ import { useState } from 'react' import DashboardLayout from '@/components/DashboardLayout' import AttendanceForm from '@/components/AttendanceForm' -import { useScheduleModals } from '@/hooks/useScheduleModals' -import ScheduleDayOffModal from '@/components/schedule/ScheduleDayOffModal' -import ReasonSelectionModal from '@/components/schedule/ReasonSelectionModal' -import ScheduleConfirmationModal from '@/components/schedule/ScheduleConfirmationModal' import ScheduleDashboard from '@/components/schedule/ScheduleDashboard' import ScheduleCalendar from '@/components/schedule/ScheduleCalendar' import ScheduledEventsList from '@/components/schedule/ScheduledEventsList' +import AlertsDashboard from '@/components/alerts/AlertsDashboard' +import Image from 'next/image' export default function Home() { const [showAttendanceModal, setShowAttendanceModal] = useState(false) + const [showAlertsModal, setShowAlertsModal] = useState(false) const [selectedDate, setSelectedDate] = useState(new Date()) const [view, setView] = useState<'calendar' | 'list'>('calendar') - const scheduleModals = useScheduleModals() - // Removed activeTab since we only have Schedule Management now const handleDateClick = (day: number) => { if (day > 0) { @@ -34,22 +31,52 @@ export default function Home() { return (
- {/* Header */} -
-

- 👋 Welcome, User! -

-

- Manage schedules, events, and attendance from one central location. Click any date to record attendance. -

+ {/* Header with Alerts Button */} +
+
+

+ 👋 Welcome, User! +

+

+ Manage schedules, events, and attendance from one central location. +

+
+ + {/* Alerts Button */} +
- {/* Schedule Management Content */} + {/* Schedule Content */}
{/* Schedule Dashboard Overview */}
@@ -90,7 +117,6 @@ export default function Home() { > 📋 List View - {/* Space for future button */}
{/* Schedule Content */} @@ -130,16 +156,6 @@ export default function Home() { onDateSelect={handleDateSelect} />
- - {/* Schedule Day Off Button - positioned underneath mock calendar */} -
- -
@@ -149,38 +165,79 @@ export default function Home() { onClose={() => setShowAttendanceModal(false)} /> - {/* Schedule Day Off Modal System */} - scheduleModals.handleScheduleSubmit(scheduleModals.formData)} - onCancel={() => scheduleModals.setScheduleModal(false)} - onReasonSelect={() => scheduleModals.setReasonModal(true)} - /> - scheduleModals.setReasonModal(false)} - onConfirm={() => scheduleModals.setReasonModal(false)} - /> - { - scheduleModals.setConfirmationModal(false); - // Reset form data after confirmation - scheduleModals.setFormData({ - date: '', - reason: '', - customReason: '', - affectedStudentCount: 0 - }); - }} - /> + {/* Alerts Modal */} + {showAlertsModal && ( +
setShowAlertsModal(false)} + > +
e.stopPropagation()} + > + {/* Modal Header */} +
+

+ 🚨 Alerts Dashboard +

+ +
+ + {/* Modal Content */} +
+ +
+
+
+ )}
) -} +} \ No newline at end of file diff --git a/app/reports/page.tsx b/app/reports/page.tsx index 2c56f6e..6d79a92 100644 --- a/app/reports/page.tsx +++ b/app/reports/page.tsx @@ -1,194 +1,43 @@ 'use client'; -import { useState } from 'react'; import DashboardLayout from '@/components/DashboardLayout'; -import RAGQueryBox from '@/components/RAGQueryBox'; -import QuerySuggestions from '@/components/QuerySuggestions'; import FilterPanel from '@/components/reports/FilterPanel'; -import AttendanceDataTable, { type AttendanceRecord, type ExportFormat } from '@/components/reports/AttendanceDataTable'; -import AttendanceChart, { type ChartType } from '@/components/reports/AttendanceChart'; -import ReportSummaryCards from '@/components/reports/ReportSummaryCards'; +import RAGQueryBox from '@/components/RAGQueryBox'; export default function Reports() { - const [selectedQuery, setSelectedQuery] = useState(''); - const [queryResults, setQueryResults] = useState(null); - const [showTraditionalFilters, setShowTraditionalFilters] = useState(false); - const [chartType, setChartType] = useState('bar'); - const handleChartTypeChange = (type: ChartType) => { - setChartType(type); + const handleQueryResults = (results: any) => { + console.log('Query results:', results); }; - // Mock data for development - replace with actual data later - const mockData = { - totalStudents: 150, - presentCount: 120, - absentCount: 20, - lateCount: 10, - attendanceRate: 85.5, - records: Array(25).fill(null).map((_, i) => ({ - id: i.toString(), - studentId: `s${i + 1}`, - studentName: `Student ${i + 1}`, - className: `Class ${Math.floor(i / 5) + 1}`, - date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), - status: ['present', 'absent', 'late', 'excused'][Math.floor(Math.random() * 4)] as 'present' | 'absent' | 'late' | 'excused', - timeIn: '09:00', - timeOut: '15:00', - notes: `Note for student ${i + 1}`, - earlyDismissal: false - })) - }; - - const handleSelectQuery = (query: string) => { - setSelectedQuery(query); - }; - - const handleQueryResults = (results: any) => { - setQueryResults(results); + // Add error boundary handling + const handleRagError = (error: Error) => { + console.error("RAG query error:", error); + // Could implement a UI notification here }; return (
-
-

📊 Attendance Reports

-

- Ask questions about attendance data or use traditional filters to analyze patterns. +

+

📊 Attendance Reports

+

+ Use natural language to query attendance data

-
- - {/* Mode Toggle */} -
- - -
- - {/* Natural Language Query Interface */} - {!showTraditionalFilters && ( -
- + - - {!queryResults && ( - - )} -
- )} - - {/* Traditional Filters Interface */} - {showTraditionalFilters && ( -
- - - - -
-
-

Attendance Trends

-
- {(['bar', 'line', 'area'] as ChartType[]).map(type => ( - - ))} -
-
- -
- -
-
-

Attendance Records

-

Detailed view of all attendance records

-
- { - console.log('Sort:', column, direction); - }} - onPageChange={(page, pageSize) => { - console.log('Page change:', page, pageSize); - }} - onExport={(format) => { - console.log('Export:', format); - }} - totalRecords={mockData.records.length} - currentPage={1} - pageSize={25} - /> -
+

+ Pro tip: Try asking specific questions about attendance patterns, alerts, or individual students +

- )} - - {/* Query Results Display */} - {queryResults && !showTraditionalFilters && ( -
-

Detailed Results

-
-
-

Data Summary

-
-

- Additional detailed data visualization and tables will be displayed here based on your query results. -

- {/* Future: Add charts, tables, and detailed breakdowns */} -
-
-
-
- )} - - {/* Help Section */} -
-

🚀 New AI-Powered Queries

-

- Try asking questions in plain English! Our AI can understand queries like "Show me students with low attendance" - or "Which students were absent yesterday?" and automatically generate the right reports for you. -

+ + {/* Filter Panel without the RAG Query Box */} +
); diff --git a/components/RAGQueryBox.css b/components/RAGQueryBox.css new file mode 100644 index 0000000..f807371 --- /dev/null +++ b/components/RAGQueryBox.css @@ -0,0 +1,52 @@ +/* RAGQueryBox Terminal Styling */ +.terminal-output { + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + padding: 0.5rem; +} + +/* JSON syntax highlighting */ +.json-key { + color: #59a5d8; + font-weight: 600; +} + +.json-string { + color: #78b13f; +} + +.json-number { + color: #d18f52; +} + +.json-boolean { + color: #ca60ca; +} + +/* Table styling */ +.terminal-table { + margin: 0.5rem 0; + border-collapse: separate; + border-spacing: 0; + width: 100%; +} + +.terminal-table th { + font-weight: bold; + text-align: left; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid #d1d1d1; + background-color: #f5f5f5; +} + +.terminal-table td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid #eaeaea; +} + +.terminal-table tr:last-child td { + border-bottom: none; +} + +.terminal-table tr:nth-child(even) { + background-color: #f9f9f9; +} diff --git a/components/RAGQueryBox.tsx b/components/RAGQueryBox.tsx index fab4c94..fd1ca19 100644 --- a/components/RAGQueryBox.tsx +++ b/components/RAGQueryBox.tsx @@ -1,21 +1,33 @@ 'use client'; import { useState } from 'react'; +import './RAGQueryBox.css'; -interface RAGQueryResult { +export interface RAGQueryResult { query: string; - interpretation: string; - summary: string; - insights: string[]; + answer: string; // Updated from interpretation data?: any; + formattedData?: string; // Formatted version of data for terminal display + suggestedActions?: Array<{ + type: string; + label: string; + params?: Record; + }>; + confidence?: number; + success: boolean; } interface RAGQueryBoxProps { onResults?: (results: RAGQueryResult) => void; className?: string; + placeholder?: string; } -export default function RAGQueryBox({ onResults, className = '' }: RAGQueryBoxProps) { +export default function RAGQueryBox({ + onResults, + className = '', + placeholder = 'Ask about attendance alerts or interventions...' +}: RAGQueryBoxProps) { const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [results, setResults] = useState(null); @@ -29,35 +41,64 @@ export default function RAGQueryBox({ onResults, className = '' }: RAGQueryBoxPr setError(null); try { - const response = await fetch('/api/reports/natural-language', { + console.log("Submitting query:", query.trim()); + + // Add timeout handling for the fetch request + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + setError("Request timed out. Please try again with a simpler query."); + setLoading(false); + }, 30000); // 30 second timeout + + const response = await fetch('/api/ai/query', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query: query.trim() }), + signal: controller.signal }); + + clearTimeout(timeoutId); - if (!response.ok) { - throw new Error('Failed to process query'); - } - + // We'll always expect a 200 status even for error responses now const data = await response.json(); + console.log("API response data:", data); + if (data.success) { const queryResult: RAGQueryResult = { query: query.trim(), - interpretation: data.data.interpretation || 'Query processed successfully', - summary: data.data.summary || 'Results generated based on your query', - insights: data.data.insights || [], - data: data.data + answer: data.answer || 'Query processed successfully', + data: data.data, + formattedData: data.formattedData, + suggestedActions: data.suggestedActions, + confidence: data.confidence, + success: true }; setResults(queryResult); onResults?.(queryResult); } else { - setError(data.error || 'Failed to process query'); + console.error("API returned success=false:", data); + + // Use the friendly error message or answer if provided + const errorMessage = data.answer || data.error || 'Failed to process query'; + + // Instead of setting error, let's create a "fake" successful response that contains the error message + const errorResult: RAGQueryResult = { + query: query.trim(), + answer: errorMessage, + confidence: data.confidence || 0, + success: false + }; + + setResults(errorResult); + onResults?.(errorResult); } } catch (err) { + console.error("Error processing query:", err); setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); @@ -71,84 +112,110 @@ export default function RAGQueryBox({ onResults, className = '' }: RAGQueryBoxPr }; return ( -
-
-

- Ask About Attendance Data -

-

- Ask natural language questions about attendance patterns, student data, or generate reports -

+
+
+
+
+
+
+
+
attendance-query-terminal
+
- -
-
+ + +
+ $ setQuery(e.target.value)} - placeholder="Ask about attendance... (e.g., 'Show students absent this week')" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={placeholder} + className="flex-1 bg-transparent outline-none border-0 text-black placeholder-black placeholder-opacity-60" disabled={loading} + autoFocus /> - + {loading && ( +
+ Processing... +
+ )}
{error && ( -
-

{error}

+
+ ERROR: + {error}
)} {results && ( -
-
-

Query Results

+
+ {/* Show the original query echoed back */} +
+ {`>`} + Query: {results.query} +
+ + {/* Show the answer */} +
+ {results.answer} +
+ + {/* Show data as formatted output */} + {results.data && ( +
+
DATA:
+

+            
+ )} + + {/* Show suggested actions as command suggestions */} + {results.suggestedActions && results.suggestedActions.length > 0 && ( +
+ {/* Show confidence level */} + {results.confidence !== undefined && ( +
0.8 ? 'text-green-600' : + results.confidence > 0.5 ? 'text-yellow-600' : 'text-red-600' + }`}> + Confidence: {Math.round(results.confidence * 100)}% +
+ )} + +
SUGGESTED ACTIONS:
+
+ {results.suggestedActions && results.suggestedActions.map((action, index) => ( +
console.log('Action clicked:', action)} + > + → {action.label} +
+ ))} + {(!results.suggestedActions || results.suggestedActions.length === 0) && ( +
No suggested actions
+ )} +
+
+ )} + + {/* Command prompt for next query */} +
- -
-
-

Your Question:

-

"{results.query}"

-
- -
-

Interpretation:

-

{results.interpretation}

-
- -
-

Summary:

-

{results.summary}

-
- - {results.insights && results.insights.length > 0 && ( -
-

Key Insights:

-
    - {results.insights.map((insight, index) => ( -
  • - - {insight} -
  • - ))} -
-
- )} -
)}
diff --git a/components/alerts/AlertCard.tsx b/components/alerts/AlertCard.tsx new file mode 100644 index 0000000..a7418d1 --- /dev/null +++ b/components/alerts/AlertCard.tsx @@ -0,0 +1,248 @@ +'use client'; + +import React from 'react'; +import { AttendanceAlert } from '../../src/services/hooks/useAlerts'; + +interface AlertCardProps { + alert: AttendanceAlert; + selected: boolean; + onSelect: (selected: boolean) => void; + onStudentSelect: () => void; +} + +export default function AlertCard({ alert, selected, onSelect, onStudentSelect }: AlertCardProps) { +const getSeverityColor = (severity: AttendanceAlert['severity']) => { + switch (severity) { + case 'high': return { border: '1px solid #fecaca', backgroundColor: '#fef2f2', color: '#991b1b' }; + case 'medium': return { border: '1px solid #fde68a', backgroundColor: '#fffbeb', color: '#92400e' }; + case 'low': return { border: '1px solid #bfdbfe', backgroundColor: '#eff6ff', color: '#1e40af' }; + default: return { border: '1px solid #d1d5db', backgroundColor: '#f9fafb', color: '#374151' }; + } +}; const getTypeIcon = (type: string) => { + switch (type) { + case 'absence': return '📅'; + case 'tardy': return '⏰'; + case 'pattern': return '⚠️'; + case 'chronic': return '⚠️'; + default: return '⚠️'; + } + }; + + return ( +
{ + if (!selected) { + e.currentTarget.style.backgroundColor = '#fafbfc'; + } + }} + onMouseLeave={(e) => { + if (!selected) { + e.currentTarget.style.backgroundColor = 'white'; + } + }}> +
+ {/* Alert Content */} +
+
+
+ {/* Checkbox */} + onSelect(e.target.checked)} + style={{ + width: '16px', + height: '16px', + accentColor: '#3b82f6' + }} + /> + + {/* Severity Badge */} + + {alert.severity.toUpperCase()} + + + {/* Type Icon & Label */} +
+ + {getTypeIcon(alert.type)} + + + {alert.type} + +
+
+ + {/* Date */} + + {alert.triggerDate.toLocaleDateString()} + +
+ + {/* Student Name */} +
+ +
+ + {/* Description */} +

+ {alert.description} +

+ + {/* Metadata */} + {alert.metadata && ( +
+ {alert.metadata.absenceCount && ( + + 📅 {alert.metadata.absenceCount} absences + + )} + {alert.metadata.tardyCount && ( + + ⏰ {alert.metadata.tardyCount} tardies + + )} + {alert.metadata.attendanceRate && ( + = 80 ? '#d1fae5' : '#fee2e2', + borderRadius: '12px', + fontWeight: '500' + }}> + 📊 {alert.metadata.attendanceRate}% attendance + + )} +
+ )} + + {/* Status and Interventions */} +
+ + {alert.status === 'active' ? '🔴' : alert.status === 'acknowledged' ? '🟡' : '🟢'} + + {alert.status} + + + + {alert.interventions.length > 0 && ( + + 🛠️ {alert.interventions.length} intervention(s) + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/alerts/AlertDetailsModal.tsx b/components/alerts/AlertDetailsModal.tsx new file mode 100644 index 0000000..0852ccb --- /dev/null +++ b/components/alerts/AlertDetailsModal.tsx @@ -0,0 +1,136 @@ +/* AlertDetailsModal.tsx */ +import React, { useState } from 'react'; +import { AttendanceAlert, getAlertSeverityColor } from '../utilities/alertUtils'; +import './Alerts.css'; + +interface AlertDetailsModalProps { + alert: AttendanceAlert; + isOpen: boolean; + onClose: () => void; + onDismiss: () => void; +} + +export default function AlertDetailsModal({ + alert, + isOpen, + onClose, + onDismiss +}: AlertDetailsModalProps) { + const [activeTab, setActiveTab] = useState<'details' | 'history'>('details'); + + if (!isOpen) return null; + + // Use existing utility function + + const formatDate = (date: Date) => { + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+
+
+
+

Alert Details

+
+ {alert.severity} Priority +
+
+ +
+ +
+
+

{alert.title}

+

Student ID: {alert.studentId}

+
+ +
+ + +
+ +
+ {activeTab === 'details' ? ( +
+
+ Alert Type: + {alert.type} +
+
+ Triggered: + {formatDate(alert.date)} +
+
+ Message: + {alert.message} +
+
+ Attendance Count: + {alert.metadata?.attendanceCount || 'N/A'} occurrences +
+
+ Status: + + {alert.isDismissed ? 'Dismissed' : 'Active'} + +
+
+ ) : ( +
+
+
+
12
+
Total Absences This Month
+
+
+
8
+
Late Arrivals This Month
+
+
+
85%
+
Attendance Rate
+
+
+
+

Recent Events

+
Jan 15, 2025 - Absent (Unexcused)
+
Jan 14, 2025 - Late Arrival (15 minutes)
+
Jan 13, 2025 - Present
+
Jan 12, 2025 - Absent (Excused - Sick)
+
+
+ )} +
+
+ +
+ + {!alert.isDismissed && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/alerts/AlertDropdown.tsx b/components/alerts/AlertDropdown.tsx new file mode 100644 index 0000000..acc88a2 --- /dev/null +++ b/components/alerts/AlertDropdown.tsx @@ -0,0 +1,91 @@ +"use client" + +import React from 'react'; +import './Alerts.css'; +import { AttendanceAlert, getAlertSeverityColor } from '../utilities/alertUtils'; + +interface AlertDropdownProps { + isOpen: boolean; + alerts: AttendanceAlert[]; + onToggle: () => void; + onAlertClick: (alert: AttendanceAlert) => void; + onViewAll: () => void; + onClose: () => void; +} + +// Alert Dropdown - shows recent alerts in header notification +export default function AlertDropdown({ + isOpen, + alerts, + onToggle, + onAlertClick, + onViewAll, + onClose +}: AlertDropdownProps) { + + if (!isOpen) return null; + + // Use existing utility function for consistency + + // Format alert date + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + }; + + return ( + <> +
+
+ +
+

Recent Alerts

+ {alerts.length} +
+ +
+ {alerts.length === 0 ? ( +
+ No alerts at this time +
+ ) : ( + alerts.slice(0, 5).map((alert) => ( +
onAlertClick(alert)} + > +
+
{alert.title}
+
+ {alert.message} • {formatDate(alert.date)} +
+
+
+ {alert.severity} +
+
+ )) + )} +
+ + {alerts.length > 0 && ( +
+ +
+ )} + +
+ + ); +} \ No newline at end of file diff --git a/components/alerts/AlertSummaryStats.tsx b/components/alerts/AlertSummaryStats.tsx new file mode 100644 index 0000000..6feaac7 --- /dev/null +++ b/components/alerts/AlertSummaryStats.tsx @@ -0,0 +1,239 @@ +'use client'; + +import React from 'react'; +import { AttendanceAlert } from '../../src/services/hooks/useAlerts'; + +interface AlertSummaryStatsProps { + alerts: AttendanceAlert[]; + loading: boolean; +} + +export default function AlertSummaryStats({ alerts, loading }: AlertSummaryStatsProps) { + const getStatistics = () => { + if (loading || alerts.length === 0) { + return { + total: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + activeStudents: 0, + interventionsNeeded: 0 + }; + } + + const severityCounts = alerts.reduce((acc, alert) => { + acc[alert.severity] = (acc[alert.severity] || 0) + 1; + return acc; + }, {} as Record); + + const uniqueStudents = new Set(alerts.map(alert => alert.studentId)); + const interventionsNeeded = alerts.filter(alert => + alert.status === 'active' && + (alert.severity === 'critical' || alert.severity === 'high') + ).length; + + return { + total: alerts.length, + critical: severityCounts.critical || 0, + high: severityCounts.high || 0, + medium: severityCounts.medium || 0, + low: severityCounts.low || 0, + activeStudents: uniqueStudents.size, + interventionsNeeded + }; + }; + + const stats = getStatistics(); + + const statCards = [ + { + title: 'Total Alerts', + value: stats.total, + icon: '🚨', + color: '#3b82f6', + bgColor: '#dbeafe' + }, + { + title: 'Critical Alerts', + value: stats.critical, + icon: '⚠️', + color: '#ef4444', + bgColor: '#fee2e2' + }, + { + title: 'Students Affected', + value: stats.activeStudents, + icon: '👥', + color: '#8b5cf6', + bgColor: '#ede9fe' + }, + { + title: 'Interventions Needed', + value: stats.interventionsNeeded, + icon: '⏰', + color: '#f59e0b', + bgColor: '#fef3c7' + } + ]; + + if (loading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+
+ ))} +
+ ); + } + + return ( +
+

+ Alerts Overview +

+ +
+ {statCards.map((stat, index) => ( +
+
+

+ {stat.title} +

+
+ {stat.icon} +
+
+ +
+ {stat.value} +
+ +
+ Active now +
+
+ ))} +
{/* Severity Breakdown */} +
+
+ 📊 +

+ Alert Severity Breakdown +

+
+
+ {[ + { label: 'Critical', count: stats.critical, color: '#ef4444', bgColor: '#fee2e2' }, + { label: 'High', count: stats.high, color: '#f59e0b', bgColor: '#fef3c7' }, + { label: 'Medium', count: stats.medium, color: '#eab308', bgColor: '#fef9c3' }, + { label: 'Low', count: stats.low, color: '#3b82f6', bgColor: '#dbeafe' } + ].map((item, index) => ( +
+
+

+ {item.label} +

+

+ {item.count} +

+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/alerts/AlertWorkflowIntegration.tsx b/components/alerts/AlertWorkflowIntegration.tsx new file mode 100644 index 0000000..13bb139 --- /dev/null +++ b/components/alerts/AlertWorkflowIntegration.tsx @@ -0,0 +1,183 @@ +/* AlertWorkflowIntegration.tsx - Complete integration example */ +'use client'; + +import React from 'react'; +import { useAlertModals } from '../../hooks/useAlertModals'; +import { AttendanceAlert } from '../utilities/alertUtils'; +import ThresholdSettingsModal from './ThresholdSettingsModal'; +import AlertDropdown from './AlertDropdown'; +import DismissAlertModal from './DismissAlertModal'; +import AlertDetailsModal from './AlertDetailsModal'; + +// TODO: Integration with L's utilities when available: +// import { validateAlertThreshold, validateTimeframe } from '../utils/thresholdValidation'; +// import { AlertThreshold, ThresholdFormData } from '../types/thresholds'; +// import { getDefaultThresholds } from '../constants/alertConstants'; + +// Sample alert data for testing using existing AttendanceAlert interface +const sampleAlerts = [ + { + id: '1', + studentId: 'STU001', + type: 'attendance' as const, + severity: 'high' as const, + title: 'John Smith - Excessive Absences', + message: '8 absences this month', + date: new Date(), + isRead: false, + isDismissed: false, + metadata: { + attendanceCount: 8, + absenceStreak: 3, + attendanceRate: 75 + } + }, + { + id: '2', + studentId: 'STU002', + type: 'tardiness' as const, + severity: 'medium' as const, + title: 'Jane Doe - Late Arrivals', + message: '12 late arrivals this month', + date: new Date(Date.now() - 86400000), + isRead: false, + isDismissed: false, + metadata: { + tardinessCount: 12, + attendanceRate: 85 + } + } +]; + +export default function AlertWorkflowIntegration() { + const { + // Modal states + thresholdModal, + alertDropdown, + dismissModal, + detailsModal, + + // Modal controls + setThresholdModal, + + // Form data + thresholdForm, + selectedAlert, + + // Workflow functions + handleThresholdUpdate, + handleAlertDismiss, + showAlertDetails, + handleDropdownToggle, + closeAllModals + } = useAlertModals(); + + const handleDismissFromDetails = () => { + closeAllModals(); + if (selectedAlert) { + showAlertDetails(selectedAlert); + } + }; + + return ( +
+

Alert Management System Integration

+ +
+

Demo Controls

+
+ + + + + +
+
+ +
+

Current Settings

+

Absence Threshold: {thresholdForm.absenceThreshold}

+

Lateness Threshold: {thresholdForm.latenessThreshold}

+

Timeframe: {thresholdForm.timeframe}

+
+ +
+

Sample Alerts

+ {sampleAlerts.map(alert => ( +
+ {alert.title} - {alert.type} ({alert.severity} priority) +
+ +
+
+ ))} +
+ + {/* Modal Components */} + console.log('Form changed:', data)} + onSubmit={handleThresholdUpdate} + onClose={() => setThresholdModal(false)} + /> + + console.log('View all alerts')} + onClose={() => handleDropdownToggle()} + /> + + {selectedAlert && ( + <> + + + + + )} +
+ ); +} \ No newline at end of file diff --git a/components/alerts/Alerts.css b/components/alerts/Alerts.css new file mode 100644 index 0000000..274218b --- /dev/null +++ b/components/alerts/Alerts.css @@ -0,0 +1,965 @@ +/* Alert System Styling - Comprehensive CSS for all alert components */ + +/* CSS Custom Properties */ +:root { + /* Alert Colors */ + --alert-color-low: #10b981; + --alert-color-medium: #f59e0b; + --alert-color-high: #f97316; + --alert-color-critical: #ef4444; + + /* Alert Color Variants */ + --alert-low-bg: #ecfdf5; + --alert-low-border: #a7f3d0; + --alert-low-text: #065f46; + + --alert-medium-bg: #fffbeb; + --alert-medium-border: #fed7aa; + --alert-medium-text: #92400e; + + --alert-high-bg: #fff7ed; + --alert-high-border: #fdba74; + --alert-high-text: #9a3412; + + --alert-critical-bg: #fef2f2; + --alert-critical-border: #fca5a5; + --alert-critical-text: #991b1b; + + /* Component Spacing */ + --alert-spacing-xs: 0.125rem; + --alert-spacing-sm: 0.25rem; + --alert-spacing-md: 0.5rem; + --alert-spacing-lg: 0.75rem; + --alert-spacing-xl: 1rem; + + /* Border Radius */ + --alert-radius-sm: 0.25rem; + --alert-radius-md: 0.375rem; + --alert-radius-lg: 0.5rem; + --alert-radius-full: 9999px; + + /* Transitions */ + --alert-transition: all 0.2s ease-in-out; + --alert-pulse-duration: 2s; + + /* Z-index layers */ + --alert-z-base: 10; + --alert-z-overlay: 50; + --alert-z-modal: 100; + + /* Shadows */ + --alert-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --alert-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --alert-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* AlertBadge Component Styles */ +.alert-badge { + display: inline-flex; + align-items: center; + gap: var(--alert-spacing-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + border-radius: var(--alert-radius-full); + transition: var(--alert-transition); + white-space: nowrap; + position: relative; + overflow: hidden; + border: none; + background: none; + cursor: default; +} + +/* Badge Sizes */ +.alert-badge--sm { + padding: var(--alert-spacing-xs) var(--alert-spacing-md); + font-size: 0.75rem; + line-height: 1rem; +} + +.alert-badge--md { + padding: var(--alert-spacing-sm) var(--alert-spacing-lg); + font-size: 0.875rem; + line-height: 1.25rem; +} + +.alert-badge--lg { + padding: var(--alert-spacing-md) var(--alert-spacing-xl); + font-size: 1rem; + line-height: 1.5rem; +} + +/* Badge Variants */ +.alert-badge--solid { + border: 1px solid transparent; +} + +.alert-badge--outline { + background-color: transparent; + border: 1px solid; +} + +.alert-badge--subtle { + border: 1px solid transparent; +} + +/* Severity Colors for Solid Variant */ +.alert-badge--solid.alert-badge--low { + background-color: var(--alert-color-low); + color: white; +} + +.alert-badge--solid.alert-badge--medium { + background-color: var(--alert-color-medium); + color: white; +} + +.alert-badge--solid.alert-badge--high { + background-color: var(--alert-color-high); + color: white; +} + +.alert-badge--solid.alert-badge--critical { + background-color: var(--alert-color-critical); + color: white; +} + +/* Severity Colors for Outline Variant */ +.alert-badge--outline.alert-badge--low { + color: var(--alert-low-text); + border-color: var(--alert-color-low); +} + +.alert-badge--outline.alert-badge--medium { + color: var(--alert-medium-text); + border-color: var(--alert-color-medium); +} + +.alert-badge--outline.alert-badge--high { + color: var(--alert-high-text); + border-color: var(--alert-color-high); +} + +.alert-badge--outline.alert-badge--critical { + color: var(--alert-critical-text); + border-color: var(--alert-color-critical); +} + +/* Severity Colors for Subtle Variant */ +.alert-badge--subtle.alert-badge--low { + background-color: var(--alert-low-bg); + color: var(--alert-low-text); + border-color: var(--alert-low-border); +} + +.alert-badge--subtle.alert-badge--medium { + background-color: var(--alert-medium-bg); + color: var(--alert-medium-text); + border-color: var(--alert-medium-border); +} + +.alert-badge--subtle.alert-badge--high { + background-color: var(--alert-high-bg); + color: var(--alert-high-text); + border-color: var(--alert-high-border); +} + +.alert-badge--subtle.alert-badge--critical { + background-color: var(--alert-critical-bg); + color: var(--alert-critical-text); + border-color: var(--alert-critical-border); +} + +/* Badge Hover Effects */ +.alert-badge--clickable { + cursor: pointer; +} + +.alert-badge--clickable:hover { + transform: translateY(-1px); + box-shadow: var(--alert-shadow-md); +} + +.alert-badge--clickable:active { + transform: translateY(0); +} + +/* Badge Pulse Animation */ +.alert-badge--pulse { + animation: alert-pulse var(--alert-pulse-duration) infinite; +} + +/* ThresholdSlider Component Styles */ +.threshold-slider { + display: flex; + flex-direction: column; + gap: var(--alert-spacing-md); + width: 100%; +} + +.threshold-slider__header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--alert-spacing-sm); +} + +.threshold-slider__label { + display: flex; + align-items: center; + gap: var(--alert-spacing-sm); + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin: 0; +} + +.threshold-slider__value { + font-weight: 600; + color: var(--alert-color-medium); +} + +.threshold-slider__container { + position: relative; + display: flex; + align-items: center; + gap: var(--alert-spacing-lg); +} + +.threshold-slider__input { + flex: 1; + height: 8px; + border-radius: var(--alert-radius-md); + outline: none; + opacity: 0.7; + transition: var(--alert-transition); + cursor: pointer; + appearance: none; + background: transparent; +} + +.threshold-slider__input:hover { + opacity: 1; +} + +.threshold-slider__input::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + border: 2px solid var(--alert-color-medium); + cursor: pointer; + box-shadow: var(--alert-shadow-md); + transition: var(--alert-transition); +} + +.threshold-slider__input::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: var(--alert-shadow-lg); +} + +.threshold-slider__input::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + border: 2px solid var(--alert-color-medium); + cursor: pointer; + box-shadow: var(--alert-shadow-md); + transition: var(--alert-transition); + appearance: none; +} + +.threshold-slider__indicator { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: var(--alert-z-base); +} + +.threshold-slider__marks { + display: flex; + justify-content: space-between; + margin-top: var(--alert-spacing-sm); + position: relative; +} + +.threshold-slider__mark { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + transform: translateX(-50%); +} + +.threshold-slider__mark-line { + width: 1px; + height: 6px; + background-color: #d1d5db; + margin-bottom: var(--alert-spacing-xs); +} + +.threshold-slider__mark-label { + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; +} + +.threshold-slider__mark--active .threshold-slider__mark-label { + color: var(--alert-color-high); + font-weight: 600; +} + +.threshold-slider__sensitivity { + font-size: 0.75rem; + padding: var(--alert-spacing-xs) var(--alert-spacing-sm); + border-radius: var(--alert-radius-sm); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.threshold-slider__sensitivity--low { + background-color: var(--alert-low-bg); + color: var(--alert-low-text); +} + +.threshold-slider__sensitivity--medium { + background-color: var(--alert-medium-bg); + color: var(--alert-medium-text); +} + +.threshold-slider__sensitivity--high { + background-color: var(--alert-high-bg); + color: var(--alert-high-text); +} + +.threshold-slider__sensitivity--critical { + background-color: var(--alert-critical-bg); + color: var(--alert-critical-text); +} + +.threshold-slider__help { + font-size: 0.75rem; + color: #6b7280; + line-height: 1.4; +} + +.threshold-slider--disabled .threshold-slider__input { + opacity: 0.5; + cursor: not-allowed; +} + +.threshold-slider--disabled .threshold-slider__input::-webkit-slider-thumb { + cursor: not-allowed; +} + +.threshold-slider--disabled .threshold-slider__input::-moz-range-thumb { + cursor: not-allowed; +} + +/* AlertIcon Component Styles */ +.alert-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: var(--alert-transition); +} + +.alert-icon--sm { + width: 1rem; + height: 1rem; +} + +.alert-icon--md { + width: 1.25rem; + height: 1.25rem; +} + +.alert-icon--lg { + width: 1.5rem; + height: 1.5rem; +} + +.alert-icon--xl { + width: 2rem; + height: 2rem; +} + +.alert-icon svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Icon Color Variants */ +.alert-icon--low { + color: var(--alert-color-low); +} + +.alert-icon--medium { + color: var(--alert-color-medium); +} + +.alert-icon--high { + color: var(--alert-color-high); +} + +.alert-icon--critical { + color: var(--alert-color-critical); +} + +.alert-icon--muted { + color: #6b7280; +} + +/* Icon Animation */ +.alert-icon--pulse { + animation: alert-pulse var(--alert-pulse-duration) infinite; +} + +/* Icon with Badge */ +.alert-icon-with-badge { + position: relative; + display: inline-flex; +} + +.alert-icon-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 1.25rem; + height: 1.25rem; + border-radius: var(--alert-radius-full); + background-color: var(--alert-color-critical); + color: white; + font-size: 0.75rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid white; + box-shadow: var(--alert-shadow-sm); + z-index: var(--alert-z-base); +} + +/* Alert Status Icons */ +.alert-status-icon--active { + color: var(--alert-color-critical); +} + +.alert-status-icon--resolved { + color: var(--alert-color-low); +} + +.alert-status-icon--dismissed { + color: #6b7280; +} + +.alert-status-icon--pending { + color: var(--alert-color-medium); +} + +/* DismissButton Component Styles */ +.dismiss-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--alert-spacing-sm); + border: none; + border-radius: var(--alert-radius-md); + font-weight: 500; + cursor: pointer; + transition: var(--alert-transition); + text-decoration: none; + position: relative; + overflow: hidden; + font-family: inherit; +} + +.dismiss-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Button Sizes */ +.dismiss-button--sm { + padding: var(--alert-spacing-sm) var(--alert-spacing-md); + font-size: 0.75rem; + line-height: 1rem; +} + +.dismiss-button--md { + padding: var(--alert-spacing-md) var(--alert-spacing-lg); + font-size: 0.875rem; + line-height: 1.25rem; +} + +.dismiss-button--lg { + padding: var(--alert-spacing-lg) var(--alert-spacing-xl); + font-size: 1rem; + line-height: 1.5rem; +} + +/* Button Variants */ +.dismiss-button--primary { + background-color: var(--alert-color-critical); + color: white; +} + +.dismiss-button--primary:hover:not(:disabled) { + background-color: #dc2626; + transform: translateY(-1px); + box-shadow: var(--alert-shadow-md); +} + +.dismiss-button--secondary { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.dismiss-button--secondary:hover:not(:disabled) { + background-color: #e5e7eb; + border-color: #9ca3af; +} + +.dismiss-button--ghost { + background-color: transparent; + color: #6b7280; +} + +.dismiss-button--ghost:hover:not(:disabled) { + background-color: #f3f4f6; + color: #374151; +} + +.dismiss-button--danger { + background-color: transparent; + color: var(--alert-color-critical); + border: 1px solid var(--alert-color-critical); +} + +.dismiss-button--danger:hover:not(:disabled) { + background-color: var(--alert-color-critical); + color: white; +} + +/* Loading Spinner */ +.dismiss-button__spinner { + width: 1rem; + height: 1rem; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.dismiss-button--loading { + pointer-events: none; +} + +/* Confirmation Modal */ +.dismiss-button__modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--alert-z-modal); + padding: var(--alert-spacing-xl); +} + +.dismiss-button__modal-content { + background: white; + border-radius: var(--alert-radius-lg); + padding: var(--alert-spacing-xl); + max-width: 400px; + width: 100%; + box-shadow: var(--alert-shadow-lg); +} + +.dismiss-button__modal-header { + margin-bottom: var(--alert-spacing-lg); +} + +.dismiss-button__modal-title { + font-size: 1.125rem; + font-weight: 600; + color: #111827; + margin: 0 0 var(--alert-spacing-sm) 0; +} + +.dismiss-button__modal-description { + color: #6b7280; + margin: 0; + line-height: 1.5; +} + +.dismiss-button__modal-actions { + display: flex; + gap: var(--alert-spacing-md); + justify-content: flex-end; +} + +/* NotificationIndicator Component Styles */ +.notification-indicator { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.notification-wrapper { + position: relative; + display: inline-flex; +} + +.notification-dot { + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid white; + position: absolute; + z-index: var(--alert-z-base); +} + +.notification-dot--sm { + width: 6px; + height: 6px; +} + +.notification-dot--md { + width: 8px; + height: 8px; +} + +.notification-dot--lg { + width: 10px; + height: 10px; +} + +.notification-dot--low { + background-color: var(--alert-color-low); +} + +.notification-dot--medium { + background-color: var(--alert-color-medium); +} + +.notification-dot--high { + background-color: var(--alert-color-high); +} + +.notification-dot--critical { + background-color: var(--alert-color-critical); +} + +.notification-dot--pulse { + animation: alert-pulse var(--alert-pulse-duration) infinite; +} + +.notification-badge { + min-width: 1.25rem; + height: 1.25rem; + border-radius: var(--alert-radius-full); + background-color: var(--alert-color-critical); + color: white; + font-size: 0.75rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: var(--alert-z-base); + border: 2px solid white; + box-shadow: var(--alert-shadow-sm); + line-height: 1; +} + +.notification-badge--sm { + min-width: 1rem; + height: 1rem; + font-size: 0.625rem; +} + +.notification-badge--lg { + min-width: 1.5rem; + height: 1.5rem; + font-size: 0.875rem; +} + +.notification-pill { + padding: var(--alert-spacing-xs) var(--alert-spacing-sm); + border-radius: var(--alert-radius-full); + background-color: var(--alert-color-critical); + color: white; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + position: absolute; + z-index: var(--alert-z-base); + border: 2px solid white; + box-shadow: var(--alert-shadow-sm); + line-height: 1; +} + +/* Status Indicators */ +.status-indicator { + display: inline-flex; + align-items: center; + gap: var(--alert-spacing-sm); + padding: var(--alert-spacing-sm) var(--alert-spacing-md); + border-radius: var(--alert-radius-md); + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator--online { + background-color: var(--alert-low-bg); + color: var(--alert-low-text); +} + +.status-indicator--offline { + background-color: #f3f4f6; + color: #6b7280; +} + +.status-indicator--busy { + background-color: var(--alert-high-bg); + color: var(--alert-high-text); +} + +.status-indicator--away { + background-color: var(--alert-medium-bg); + color: var(--alert-medium-text); +} + +.status-indicator__label { + font-size: 0.875rem; + font-weight: 500; +} + +/* Positioning Utilities */ +.notification-indicator--top-left .notification-dot, +.notification-indicator--top-left .notification-badge, +.notification-indicator--top-left .notification-pill { + top: -2px; + left: -2px; +} + +.notification-indicator--top-right .notification-dot, +.notification-indicator--top-right .notification-badge, +.notification-indicator--top-right .notification-pill { + top: -2px; + right: -2px; +} + +.notification-indicator--bottom-left .notification-dot, +.notification-indicator--bottom-left .notification-badge, +.notification-indicator--bottom-left .notification-pill { + bottom: -2px; + left: -2px; +} + +.notification-indicator--bottom-right .notification-dot, +.notification-indicator--bottom-right .notification-badge, +.notification-indicator--bottom-right .notification-pill { + bottom: -2px; + right: -2px; +} + +/* Notification List Item */ +.notification-list-item { + position: relative; + padding: var(--alert-spacing-lg); + border: 1px solid #e5e7eb; + border-radius: var(--alert-radius-md); + background-color: white; + transition: var(--alert-transition); +} + +.notification-list-item--unread { + background-color: #f9fafb; + border-left: 3px solid var(--alert-color-medium); +} + +.notification-list-item--clickable { + cursor: pointer; +} + +.notification-list-item--clickable:hover { + background-color: #f3f4f6; + box-shadow: var(--alert-shadow-sm); +} + +.notification-list-item__content { + margin-left: var(--alert-spacing-lg); +} + +.notification-list-item__title { + font-weight: 600; + color: #111827; + margin-bottom: var(--alert-spacing-xs); +} + +.notification-list-item__message { + color: #6b7280; + font-size: 0.875rem; + margin-bottom: var(--alert-spacing-xs); +} + +.notification-list-item__timestamp { + color: #9ca3af; + font-size: 0.75rem; +} + +/* Animations */ +@keyframes alert-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive Design */ +@media (max-width: 640px) { + .alert-badge--lg { + font-size: 0.875rem; + padding: var(--alert-spacing-sm) var(--alert-spacing-md); + } + + .dismiss-button--lg { + font-size: 0.875rem; + padding: var(--alert-spacing-md) var(--alert-spacing-lg); + } + + .threshold-slider__header { + flex-direction: column; + align-items: stretch; + } + + .dismiss-button__modal { + padding: var(--alert-spacing-md); + } + + .dismiss-button__modal-content { + padding: var(--alert-spacing-lg); + } + + .dismiss-button__modal-actions { + flex-direction: column; + } +} + +/* High Contrast Mode Support */ +@media (prefers-contrast: high) { + .alert-badge { + border-width: 2px; + } + + .notification-dot, + .notification-badge, + .notification-pill { + border-width: 3px; + } + + .threshold-slider__input::-webkit-slider-thumb { + border-width: 3px; + } + + .threshold-slider__input::-moz-range-thumb { + border-width: 3px; + } +} + +/* Reduced Motion Support */ +@media (prefers-reduced-motion: reduce) { + .alert-badge, + .dismiss-button, + .alert-icon, + .threshold-slider__input, + .notification-list-item { + transition: none; + } + + .alert-icon--pulse, + .notification-dot--pulse, + .alert-badge--pulse { + animation: none; + } + + .dismiss-button__spinner { + animation: none; + } +} + +/* Print Styles */ +@media print { + .alert-badge, + .notification-indicator, + .status-indicator { + box-shadow: none; + background-color: transparent !important; + color: black !important; + border: 1px solid black !important; + } + + .dismiss-button { + display: none; + } + + .threshold-slider { + display: none; + } + + .dismiss-button__modal { + display: none; + } +} + +/* Focus Styles for Accessibility */ +.alert-badge:focus-visible, +.dismiss-button:focus-visible, +.threshold-slider__input:focus-visible, +.notification-list-item--clickable:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* Screen Reader Only Content */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} \ No newline at end of file diff --git a/components/alerts/AlertsDashboard.tsx b/components/alerts/AlertsDashboard.tsx new file mode 100644 index 0000000..8056a55 --- /dev/null +++ b/components/alerts/AlertsDashboard.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React, { useState } from 'react'; +import AlertsList from './AlertsList'; +import AlertSummaryStats from './AlertSummaryStats'; +import StudentInterventionPanel from './StudentInterventionPanel'; +import { useAlerts, AlertFilters } from '../../src/services/hooks/useAlerts'; + +export default function AlertsDashboard() { + const [filters, setFilters] = useState({}); + const [selectedStudentId, setSelectedStudentId] = useState(null); + const [showInterventionPanel, setShowInterventionPanel] = useState(false); + + const { + alerts, + loading, + error, + handleAlertSort, + handleAlertFilter, + handleBulkAlertActions, + refreshAlertData + } = useAlerts(filters); + + const handleStudentSelect = (studentId: string) => { + setSelectedStudentId(studentId); + setShowInterventionPanel(true); + }; + + if (error) { + return ( +
+

Error loading alerts: {error}

+ +
+ ); + } + + return ( +
+ {/* Summary Statistics */} +
+ +
+ +
+ {/* Main Alerts List */} +
+ +
+ + {/* Student Intervention Panel */} +
+ {showInterventionPanel && selectedStudentId ? ( + setShowInterventionPanel(false)} + onInterventionAdded={refreshAlertData} + /> + ) : ( +
+
👥
+

+ Student Interventions +

+

+ Select a student from the alerts list to view and manage their interventions. +

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/alerts/AlertsList.tsx b/components/alerts/AlertsList.tsx new file mode 100644 index 0000000..ea8d868 --- /dev/null +++ b/components/alerts/AlertsList.tsx @@ -0,0 +1,300 @@ +'use client'; + +import React, { useState } from 'react'; +import AlertCard from './AlertCard'; +import { AttendanceAlert, SortCriteria, AlertAction } from '../../src/services/hooks/useAlerts'; + +interface AlertsListProps { + alerts: AttendanceAlert[]; + loading: boolean; + onSort: (criteria: SortCriteria) => void; + onFilter: (filterType: string, value: any) => void; + onBulkAction: (alertIds: string[], action: AlertAction) => void; + onStudentSelect: (studentId: string) => void; +} + +export default function AlertsList({ + alerts, + loading, + onSort, + onFilter, + onBulkAction, + onStudentSelect +}: AlertsListProps) { + const [selectedAlerts, setSelectedAlerts] = useState>(new Set()); + const [sortField, setSortField] = useState('severity'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [showFilters, setShowFilters] = useState(false); + + const handleSort = (field: SortCriteria['field']) => { + const newDirection = field === sortField && sortDirection === 'desc' ? 'asc' : 'desc'; + setSortField(field); + setSortDirection(newDirection); + onSort({ field, direction: newDirection }); + }; + + const handleSelectAlert = (alertId: string, selected: boolean) => { + const newSelected = new Set(selectedAlerts); + if (selected) { + newSelected.add(alertId); + } else { + newSelected.delete(alertId); + } + setSelectedAlerts(newSelected); + }; + + const handleSelectAll = (selected: boolean) => { + if (selected) { + setSelectedAlerts(new Set(alerts.map(alert => alert.id))); + } else { + setSelectedAlerts(new Set()); + } + }; + + const handleBulkAction = (actionType: AlertAction['type']) => { + if (selectedAlerts.size > 0) { + onBulkAction(Array.from(selectedAlerts), { type: actionType }); + setSelectedAlerts(new Set()); + } + }; + + if (loading) { + return ( +
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ 🚨 Active Alerts ({alerts.length}) +

+
+ +
+
+ + {/* Bulk Actions */} + {selectedAlerts.size > 0 && ( +
+
+ + ✓ {selectedAlerts.size} alert(s) selected + +
+ + +
+
+
+ )} + + {/* Sort Controls */} +
+ {['severity', 'date', 'student', 'type'].map((field) => ( + + ))} +
+
+ + {/* Alerts List */} +
+ {alerts.length === 0 ? ( +
+
📋
+

+ No alerts found +

+

+ All clear! No alerts are currently active. +

+
+ ) : ( + <> + {/* Select All */} +
+ +
+ + {alerts.map((alert) => ( + handleSelectAlert(alert.id, selected)} + onStudentSelect={() => onStudentSelect(alert.studentId)} + /> + ))} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/alerts/DismissAlertModal.tsx b/components/alerts/DismissAlertModal.tsx new file mode 100644 index 0000000..3a549fd --- /dev/null +++ b/components/alerts/DismissAlertModal.tsx @@ -0,0 +1,122 @@ +"use client" + +import React, { useState } from 'react'; +import './Alerts.css'; +import { AttendanceAlert, getAlertSeverityColor } from '../utilities/alertUtils'; +import DismissButton from '../ui/DismissButton'; + +interface DismissAlertModalProps { + isOpen: boolean; + alert: AttendanceAlert | null; + onConfirm: (alertId: string, reason?: string) => void; + onCancel: () => void; +} + +// Dismiss Alert Modal - confirmation dialog for dismissing alerts +export default function DismissAlertModal({ + isOpen, + alert, + onConfirm, + onCancel +}: DismissAlertModalProps) { + + const [selectedReason, setSelectedReason] = useState(''); + const [customReason, setCustomReason] = useState(''); + + if (!isOpen || !alert) return null; + + // Predefined dismissal reasons + const dismissalReasons = [ + 'Issue resolved', + 'Parent contacted', + 'Student excused', + 'Administrative override', + 'Other' + ]; + + // Handle confirmation + const handleConfirm = () => { + const reason = selectedReason === 'Other' ? customReason : selectedReason; + onConfirm(alert.id, reason || undefined); + setSelectedReason(''); + setCustomReason(''); + }; + + // Use existing utility function + + return ( +
+
+ +
+

Dismiss Alert

+ +
+ +
+ +
+
+
{alert.title}
+
+ {alert.message} +
+
+
+ {alert.severity} +
+
+ +
+ + + + + {selectedReason === 'Other' && ( +