diff --git a/README.md b/README.md index 6f216ff..c8ad27f 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,310 @@ -# Student Management Microservices +# Student Management System — Enterprise Edition (Phases 1–4) -A multi-service backend built with **Java 17 + Spring Boot 3**, modeling a student -data and course-enrollment lifecycle workflow across two independently deployable -services that communicate over REST. +This is the enterprise upgrade of the original `student-management-microservices` +project. It's being built in phases so each part is fully working before the +next is added — see the roadmap below. -## Architecture +## What's in Phase 1 + 2 + 3 + 4 ``` - ┌─────────────────────┐ - │ Postman / Client │ - └──────────┬──────────┘ - │ - ┌────────────────┴────────────────┐ - │ │ - ┌───────▼────────┐ ┌────────▼─────────┐ - │ student-service │ │ enrollment-service│ - │ (port 8081) │◄──────REST────┤ (port 8082) │ - │ Controller │ GET /students │ Controller │ - │ → Service │ /{id}/exists │ → Service │ - │ → Repository │ │ → Repository │ - └───────┬─────────┘ └─────────┬───────────┘ - │ │ - ┌──────▼──────┐ ┌──────▼───────┐ - │ MySQL │ │ MySQL │ - │ student_db │ │ enrollment_db │ - └──────────────┘ └───────────────┘ +student-management-system/ +├── postman/ +│ ├── Phase2-Gateway-Flow.postman_collection.json +│ └── Phase3-Courses-Grades.postman_collection.json +├── frontend/ +│ └── student-management-ui/ <- NEW (Phase 4): React 19 + Vite + MUI SPA +└── backend/ + ├── common-lib/ <- shared DTOs, exceptions, constants, JWT validation + ├── auth-service/ <- registration, login, JWT, roles, user management + ├── api-gateway/ <- single entry point, routing, JWT check + ├── student-service/ <- JWT-secured, ownership enforced + ├── enrollment-service/ <- JWT-secured, calls course-service + student-service + ├── course-service/ <- course catalog (own DB) + ├── grade-service/ <- grades + GPA/CGPA (own DB) + └── docker-compose.yml <- runs everything above in one command ``` -- **student-service** owns the `Student` domain: registration, profile lookups, - and a lightweight `/exists` endpoint for other services to validate a student - without pulling the full profile. -- **enrollment-service** owns `Course` and `Enrollment` domains. Before creating - an enrollment it calls student-service (via a reactive `WebClient`) to confirm - the student exists, enforces course capacity, and prevents duplicate - enrollments — a small workflow-automation pipeline around the enrollment - lifecycle (`PENDING → CONFIRMED → COMPLETED/DROPPED/REJECTED`). -- Each service follows a strict **Controller → Service → Repository** layering, - uses **Spring Data JPA** for persistence, and returns a consistent JSON error - shape via a `@RestControllerAdvice` global exception handler. - -## Tech Stack - -| Concern | Choice | -|-----------------------|--------------------------------------| -| Language / Runtime | Java 17 | -| Framework | Spring Boot 3.3.x | -| Persistence | Spring Data JPA + MySQL 8 | -| Inter-service calls | Spring WebFlux `WebClient` | -| Validation | Jakarta Bean Validation | -| Build | Maven | -| Containerization | Docker + Docker Compose | -| API testing | Postman collection (included) | - -## Project Layout +### api-gateway (port 9000) +Built with Spring Cloud Gateway (reactive). This is the single entry point +every client (Postman, the future React frontend) should call instead of +hitting services directly: -``` -student-management-microservices/ -├── student-service/ -│ ├── src/main/java/com/example/studentservice/ -│ │ ├── controller/StudentController.java -│ │ ├── service/StudentService.java -│ │ ├── repository/StudentRepository.java -│ │ ├── entity/Student.java -│ │ ├── dto/StudentDTO.java -│ │ └── exception/ (custom exceptions + global handler) -│ ├── src/main/resources/application.properties -│ ├── pom.xml -│ └── Dockerfile -├── enrollment-service/ -│ ├── src/main/java/com/example/enrollmentservice/ -│ │ ├── controller/{CourseController, EnrollmentController}.java -│ │ ├── service/{CourseService, EnrollmentService}.java -│ │ ├── repository/{CourseRepository, EnrollmentRepository}.java -│ │ ├── entity/{Course, Enrollment}.java -│ │ ├── dto/{CourseDTO, EnrollmentRequestDTO, EnrollmentResponseDTO, StudentDTO}.java -│ │ ├── client/StudentClient.java ← inter-service REST client -│ │ ├── config/WebClientConfig.java -│ │ └── exception/ (custom exceptions + global handler) -│ ├── src/main/resources/application.properties -│ ├── pom.xml -│ └── Dockerfile -├── postman/Student-Management-Microservices.postman_collection.json -├── docker-compose.yml -└── README.md -``` +| Route prefix | Forwards to | +|-----------------------------------------|----------------------| +| `/api/v1/auth/**`, `/api/v1/admin/users/**` | auth-service (8080) | +| `/api/v1/students/**` | student-service (8081) | +| `/api/v1/enrollments/**` | enrollment-service (8082) | +| `/api/v1/courses/**` | course-service (8083) | +| `/api/v1/grades/**` | grade-service (8084) | + +A `JwtValidationGlobalFilter` runs before routing: public paths +(`register`, `login`, `refresh`, `actuator/health`) pass straight through; +everything else must carry a syntactically valid, unexpired Bearer token +or the gateway rejects it with `401` before it ever reaches a backend +service. This is a coarse check only (signature + expiry) - fine-grained +authorization (roles, ownership) still happens in the owning service. -## Running Locally +### student-service & enrollment-service — JWT-secured, ownership enforced +Both services gained: +- `spring-boot-starter-security` + a `JwtAuthenticationFilter` that + validates the same JWT auth-service issues (shared `jwt.secret`) and + populates `SecurityContextHolder` with a `JwtPrincipal` (userId, email, + role, studentId). +- **Ownership enforcement:** + - A **STUDENT** can only view/update **their own** student profile + (`student-service`) and can only view/drop **their own** enrollments, + and can only ever enroll **themselves** - the JWT's `studentId` claim + is authoritative even if a different id is sent in the request body + (`enrollment-service`). + - An **ADMIN** can do everything: manage all students, courses, and + enrollments, and assign grades. +- **Pass-through auth:** when enrollment-service needs to verify a student + exists (via `StudentClient`), it forwards the *original caller's* bearer + token to student-service rather than using a service-account credential. + This means student-service applies the exact same ownership rule + regardless of whether the call came directly or via enrollment-service. -### Option A — Docker Compose (recommended) +## Building locally (no Docker required) + +`common-lib` must be installed first - every other module depends on it: ```bash -cd student-management-microservices -docker compose up --build +cd backend/common-lib +mvn clean install ``` -This spins up two MySQL instances and both services: +Then run each service in its own terminal (all need `DB_PASSWORD` set; +`JWT_SECRET` is optional locally since all services share the same +built-in default): + +```bash +# Terminal 1 +cd backend/auth-service +export DB_PASSWORD=your_mysql_password +mvn spring-boot:run # port 8080 + +# Terminal 2 +cd backend/student-service +export DB_PASSWORD=your_mysql_password +mvn spring-boot:run # port 8081 + +# Terminal 3 +cd backend/enrollment-service +export DB_PASSWORD=your_mysql_password +mvn spring-boot:run # port 8082 + +# Terminal 4 +cd backend/course-service +export DB_PASSWORD=your_mysql_password +mvn spring-boot:run # port 8083 + +# Terminal 5 +cd backend/grade-service +export DB_PASSWORD=your_mysql_password +mvn spring-boot:run # port 8084 + +# Terminal 6 +cd backend/api-gateway +mvn spring-boot:run # port 9000 +``` -| Service | URL | -|--------------------|-------------------------------| -| student-service | http://localhost:8081/api/v1 | -| enrollment-service | http://localhost:8082/api/v1 | +If you're using IntelliJ: open `common-lib` and run `mvn install` on it +first (Maven tool window), then open/run the other six modules normally, +each with `DB_PASSWORD` (and `allowPublicKeyRetrieval=true`, already in +the JDBC URL) set per the earlier setup notes. -### Option B — Run each service manually +## Testing the full Phase 2 flow -1. Start a local MySQL instance and create `student_db` and `enrollment_db` - (or let `createDatabaseIfNotExist=true` in the JDBC URL handle it). -2. In one terminal: - ```bash - cd student-service - mvn spring-boot:run - ``` -3. In another terminal: - ```bash - cd enrollment-service - mvn spring-boot:run - ``` - Set `STUDENT_SERVICE_URL=http://localhost:8081` if student-service isn't on - its default port. +Import `postman/Phase2-Gateway-Flow.postman_collection.json` - everything +routes through the gateway on port 9000. Suggested order: -## Example Workflow +1. **Register** -> creates a STUDENT account, returns access + refresh tokens +2. Since self-registration only creates STUDENT accounts, **promote your + first admin manually**: insert a row directly into `auth_db.users` with + `role='ADMIN'` (or register a second account, then use MySQL to flip its + `role` column to `ADMIN` - there's no bootstrapping endpoint by design, + since admin creation shouldn't be self-service) +3. **Login as that admin** -> save the `accessToken` as `adminAccessToken` +4. **Admin creates a student profile**, a **course** +5. **Login as the student** -> save the `accessToken` as `studentAccessToken` +6. **Student enrolls themselves**, views their own profile/enrollments +7. Try having the student request **someone else's** student id or + enrollment - confirm you get a clean `403 Forbidden`, not a 500 or a + silent data leak ```bash -# 1. Create a student -curl -X POST http://localhost:8081/api/v1/students \ +# Quick curl smoke test (through the gateway) +curl -X POST http://localhost:9000/api/v1/auth/register \ -H "Content-Type: application/json" \ - -d '{"firstName":"Asha","lastName":"Rao","email":"asha.rao@example.com","dateOfBirth":"2001-05-12"}' + -d '{"fullName":"Asha Rao","email":"asha.rao@example.com","password":"SecurePass123"}' -# 2. Create a course -curl -X POST http://localhost:8082/api/v1/courses \ +curl -X POST http://localhost:9000/api/v1/auth/login \ -H "Content-Type: application/json" \ - -d '{"courseCode":"CS101","title":"Intro to CS","credits":4,"capacity":30}' + -d '{"email":"asha.rao@example.com","password":"SecurePass123"}' +``` -# 3. Enroll the student (enrollment-service validates the student via REST call to student-service) -curl -X POST http://localhost:8082/api/v1/enrollments \ - -H "Content-Type: application/json" \ +## What's new in Phase 3 + +``` +backend/ +├── course-service/ ← NEW: course catalog, split out of enrollment-service +└── grade-service/ ← NEW: grade assignment + GPA/CGPA calculation +``` + +### course-service (port 8083) +Course ownership moved out of enrollment-service into its own bounded +context with its own database (`course_db`). Beyond the original fields +(code, title, credits, capacity), courses now also track **semester**, +**instructor**, **department**, and a **status** (`ACTIVE` / `INACTIVE` / +`COMPLETED` / `CANCELLED`). Any authenticated user can browse courses; +only ADMIN can create/update/delete one. + +### enrollment-service, refactored +`enrollment-service` no longer owns a `Course` entity or table — it holds +only `studentId` + `courseId` references and calls `course-service` (via +a new `CourseClient`, forwarding the caller's own bearer token, same +pattern as `StudentClient`) whenever it needs course details or a +capacity check. The legacy `PATCH /enrollments/{id}/grade` endpoint and +the `grade` column still work for backward compatibility, but they're +now marked `@Deprecated` — **grade-service is the source of truth for +grades going forward.** + +### grade-service (port 8084) +- `POST /api/v1/grades` (ADMIN only) — assigns a grade **against an + existing enrollment id**, not a raw `(studentId, courseId)` pair. This + guarantees a grade can never exist without a real, verified enrollment + behind it (grade-service calls `enrollment-service` to check the + enrollment exists and is `CONFIRMED`/`COMPLETED` before accepting a + grade). `credits` and `semester` are snapshotted from `course-service` + at assignment time, so GPA math stays correct even if a course's credit + value changes later. +- `GET /api/v1/grades/student/{studentId}` — all grades for a student + (ADMIN or the owning student only). +- `GET /api/v1/grades/student/{studentId}/gpa` — **CGPA** (credit-weighted + average across every grade) plus a **semester-wise GPA breakdown** + (ADMIN or the owning student only). + +GPA/CGPA formula: `sum(gradePoints × credits) / sum(credits)`, computed +per semester and overall, on a 0.0–10.0 grade-point scale. + +## Testing the full Phase 3 flow + +```bash +# Continuing from the Phase 2 flow (admin + student already logged in)... + +# Admin creates a richer course +curl -X POST http://localhost:9000/api/v1/courses \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"courseCode":"CS201","title":"Data Structures","credits":4,"capacity":30,"semester":"FALL2026","instructor":"Dr. Iyer","department":"Computer Science"}' + +# Student enrolls (enrollment-service calls course-service internally for capacity) +curl -X POST http://localhost:9000/api/v1/enrollments \ + -H "Authorization: Bearer $STUDENT_TOKEN" -H "Content-Type: application/json" \ -d '{"studentId":1,"courseId":1}' -# 4. View a student's enrollments -curl http://localhost:8082/api/v1/enrollments/student/1 +# Admin assigns a grade against that enrollment (enrollment id, not raw ids) +curl -X POST http://localhost:9000/api/v1/grades \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"enrollmentId":1,"gradePoints":8.7}' + +# Student checks their own CGPA +curl http://localhost:9000/api/v1/grades/student/1/gpa \ + -H "Authorization: Bearer $STUDENT_TOKEN" ``` -## API Reference - -### student-service (`/api/v1/students`) -| Method | Path | Description | -|--------|-------------------------|----------------------------------------| -| POST | `/students` | Create a student | -| GET | `/students/{id}` | Get student by id | -| GET | `/students/email/{email}` | Get student by email | -| GET | `/students?keyword=&page=&size=` | Paginated search | -| GET | `/students?unpaged=true`| Get all students | -| PUT | `/students/{id}` | Update a student | -| DELETE | `/students/{id}` | Delete a student | -| GET | `/students/{id}/exists` | Existence check (used by enrollment-service) | - -### enrollment-service (`/api/v1/courses`, `/api/v1/enrollments`) -| Method | Path | Description | -|--------|--------------------------------------|----------------------------------------| -| POST | `/courses` | Create a course | -| GET | `/courses` / `/courses/{id}` | List / get courses | -| PUT | `/courses/{id}` | Update a course | -| DELETE | `/courses/{id}` | Delete a course | -| POST | `/enrollments` | Enroll a student (validates against student-service, checks capacity & duplicates) | -| GET | `/enrollments/{id}` | Get one enrollment | -| GET | `/enrollments/student/{studentId}` | All enrollments for a student | -| GET | `/enrollments/course/{courseId}` | All enrollments for a course | -| PATCH | `/enrollments/{id}/status` | Update lifecycle status | -| PATCH | `/enrollments/{id}/grade` | Record final grade (marks COMPLETED) | -| DELETE | `/enrollments/{id}` | Drop an enrollment | - -## Postman - -Import `postman/Student-Management-Microservices.postman_collection.json` into -Postman. It's organized into a `Student Service` and `Enrollment Service` -folder with ready-to-run requests, including the enrollment call that -exercises the inter-service REST path end-to-end. - -## Notes on Production-Readiness - -- Global exception handlers return a consistent JSON error body - (`timestamp`, `status`, `error`, `message`, `path`, `validationErrors`). -- `StudentClient` wraps all outbound HTTP calls with a timeout and translates - connectivity failures into a `503 Service Unavailable` rather than leaking - a raw connection exception. -- Indexes are defined on `email` (student-service) and `courseCode` / - `(studentId, course_id)` (enrollment-service) to keep CRUD-heavy queries - fast. -- Each service has its own database (`student_db`, `enrollment_db`) — a - database-per-service pattern, so the two remain independently deployable. +## What's new in Phase 4 + +``` +frontend/student-management-ui/ <- React 19 + Vite + MUI single-page app +``` + +A full frontend against everything built in Phases 1–3, talking only to +`api-gateway` (never to individual services directly). See +`frontend/student-management-ui/README.md` for the detailed breakdown; +the short version: + +- **Login / Register**, with JWT access + refresh tokens persisted in + `localStorage` and an axios interceptor that transparently refreshes + an expired access token (queuing concurrent requests behind a single + refresh call) before retrying. +- **Role-based routing:** `/admin/*` and `/student/*` are separate route + trees; a STUDENT account can't even render an admin page component + (the backend's ownership checks remain the real security boundary — + this is purely a UX guard). +- **Admin:** dashboard with live counts, full CRUD for students and + courses, an enrollment roster browser (by course), a grade-assignment + screen (by course roster, matching grade-service's enrollment-based + contract), and user role/status management. +- **Student:** dashboard with CGPA/credits/course counts, browse + + self-enroll in available courses, view/drop own enrollments, view own + grades and GPA-by-semester, edit own profile. +- Toast notifications (notistack) on every mutation, confirmation dialogs + before destructive actions (delete/drop), loading states throughout. + +### Running the full stack end-to-end + +```bash +# Terminal 1-7: start common-lib (mvn install) + all 6 backend services +# + api-gateway, exactly as described above. + +# Terminal 8: the frontend +cd frontend/student-management-ui +npm install +npm run dev +``` + +Then open the printed local URL, register a student account, promote an +admin the same way as before (flip a row in `auth_db.users`), and log in +as both to see each role's experience. + +This has been verified to `npm run build` and `npm run lint` (0 errors) +successfully in the environment used to generate it. + +## Roadmap (upcoming phases) + +- ~~**Phase 2:** `api-gateway` + wire JWT security into `student-service` + and `enrollment-service`~~ ✅ Done +- ~~**Phase 3:** `course-service` (separated out of enrollment-service, + with semester/instructor/department/status) + `grade-service` + (GPA/CGPA calculation)~~ ✅ Done +- ~~**Phase 4:** React + Vite + Material UI frontend with protected routes, + admin & student dashboards~~ ✅ Done +- **Phase 5:** Swagger across every service, architecture diagram, final + polished README + +## Notes on Production-Readiness (Phase 1 + 2 + 3) + +- Constructor injection only (`@RequiredArgsConstructor`), no field injection. +- Refresh tokens are persisted and individually revocable — a stateless JWT + alone can't support real logout, so the refresh token is opaque + stored. +- Passwords are BCrypt-hashed; plaintext is never stored or logged. +- CORS is configured centrally in each service's `SecurityConfig` (and + again at the gateway) — currently permissive for local dev; tighten + `allowedOriginPatterns` before any real deployment. +- The JWT secret in `application.properties`/`application.yml` is a + **local-dev placeholder** shared identically across auth-service, + student-service, enrollment-service, course-service, grade-service, and + api-gateway (they must all agree on the same secret to validate each + other's tokens). Override `JWT_SECRET` with one strong, randomly + generated value — the same value everywhere — in any real environment. +- **Defense in depth:** the gateway checks JWT validity before routing; + each downstream service independently re-validates the same JWT rather + than trusting the gateway blindly. A request that reaches student-service + or enrollment-service directly (bypassing the gateway) is still fully + protected on its own. +- **Ownership checks live at the service layer, not just the controller:** + in enrollment-service, `EnrollmentService` re-checks ownership even + though the controller does too, so a future new endpoint can't + accidentally skip the check by forgetting a `@PreAuthorize` annotation. +- **Grades can't exist without a real enrollment:** grade-service assigns + a grade against an `enrollmentId`, not a raw `(studentId, courseId)` + pair - it calls enrollment-service to confirm that enrollment exists + and is in a gradable state (`CONFIRMED`/`COMPLETED`) before accepting + the grade. This closes off a whole class of bad states (grades for + enrollments that don't exist, or that were dropped). +- **Snapshotting over live joins:** both `CartItem`-style snapshotting + (from the SmartCart project) and `Grade.credits`/`Grade.semester` here + follow the same principle - copy the value you depend on for a + calculation into your own row at the moment it's used, rather than + re-fetching it live every time. This keeps GPA calculations stable and + correct even if a course's credit value is edited after the fact. diff --git a/frontend/student-management-ui/.env.example b/frontend/student-management-ui/.env.example new file mode 100644 index 0000000..9150844 --- /dev/null +++ b/frontend/student-management-ui/.env.example @@ -0,0 +1,3 @@ +# Base URL of the API Gateway (see backend/api-gateway). Vite only exposes +# variables prefixed with VITE_ to client code. +VITE_API_BASE_URL=http://localhost:9000/api/v1 diff --git a/frontend/student-management-ui/.gitignore b/frontend/student-management-ui/.gitignore new file mode 100644 index 0000000..859ba42 --- /dev/null +++ b/frontend/student-management-ui/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Environment files (see .env.example for the template) +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/student-management-ui/.oxlintrc.json b/frontend/student-management-ui/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/student-management-ui/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/student-management-ui/README.md b/frontend/student-management-ui/README.md new file mode 100644 index 0000000..885ccba --- /dev/null +++ b/frontend/student-management-ui/README.md @@ -0,0 +1,116 @@ +# StudentHub Frontend + +A React 19 + Vite + Material UI single-page app for the Student Management +System, talking to the backend exclusively through `api-gateway` (port 9000). + +## Tech Stack + +| Concern | Choice | +|---------------------|---------------------------------------| +| Framework | React 19 | +| Build tool | Vite 8 | +| UI library | Material UI (MUI) v9 | +| Routing | react-router-dom v7 | +| HTTP client | axios, with JWT auto-refresh interceptor | +| Notifications | notistack (toast snackbars) | + +## Project Layout + +``` +src/ +├── api/ API modules, one per backend service +│ ├── axiosClient.js axios instance + JWT attach/refresh interceptors +│ ├── authApi.js auth-service (register/login/refresh/logout) + admin user management +│ ├── studentApi.js student-service +│ ├── courseApi.js course-service +│ ├── enrollmentApi.js enrollment-service +│ └── gradeApi.js grade-service +├── context/ +│ └── AuthContext.jsx current user, login/register/logout, session persistence +├── components/ +│ ├── ProtectedRoute.jsx redirects to /login if not authenticated +│ ├── RoleRoute.jsx redirects to the user's own home if role doesn't match +│ ├── layout/AppLayout.jsx sidebar + top bar, role-based nav +│ └── common/ LoadingSpinner, ConfirmDialog +├── pages/ +│ ├── Login.jsx, Register.jsx, NotFound.jsx +│ ├── admin/ AdminDashboard, ManageStudents, ManageCourses, +│ │ ManageEnrollments, ManageGrades, ManageUsers +│ └── student/ StudentDashboard, AvailableCourses, MyCourses, +│ MyGrades, Profile +├── theme/theme.js MUI theme customization +└── utils/ constants + localStorage helpers +``` + +## Running Locally + +1. Make sure the backend is running (`api-gateway` on port 9000 at minimum + - see `../../backend/README.md` and its Docker Compose setup). +2. Install dependencies: + ```bash + npm install + ``` +3. Copy the environment template if you haven't already (already done for + you as `.env` in this delivered copy, pointing at the default gateway URL): + ```bash + cp .env.example .env + ``` +4. Start the dev server: + ```bash + npm run dev + ``` +5. Open the printed local URL (typically `http://localhost:5173`). + +## Key Design Decisions + +### JWT auto-refresh (`api/axiosClient.js`) +A response interceptor watches for `401`s. On the first one, it calls +`/auth/refresh` with the stored refresh token, updates both tokens, and +retries the original request. If multiple requests 401 at the same time +(e.g. a dashboard firing several calls at once), they all queue behind a +**single** in-flight refresh call rather than each independently hitting +`/auth/refresh`. If the refresh itself fails, the session is cleared and +the user is redirected to `/login`. + +### Response shape awareness +Not every backend service wraps its responses the same way: +- `auth-service` (including `/admin/users/**`) wraps everything in the + shared `ApiResponse` envelope from `common-lib` -> the API modules + unwrap `response.data.data`. +- `student-service`, `course-service`, `enrollment-service`, and + `grade-service` all return raw DTOs directly -> the API modules use + `response.data`. + +This is called out explicitly in `api/gradeApi.js` since it's the easiest +place to get this backwards. + +### Route protection +`ProtectedRoute` guards everything behind a login check; `RoleRoute` then +splits `/admin/*` from `/student/*` so a STUDENT account can never even +render an admin page component (on top of the backend's own ownership +checks - this is a UX nicety, not the security boundary; the backend +remains the actual enforcement point). + +### Grades workflow +Since grade-service requires an `enrollmentId` (not a raw student/course +pair) to assign a grade, `ManageGrades` lets an admin pick a course, see +its roster (via `enrollmentApi.getByCourse`), and assign/update a grade +per enrollment - mirroring exactly how the backend expects grades to be +created. + +### No "list all enrollments" endpoint +The backend intentionally has no global "all enrollments" endpoint (only +by-student and by-course). `AdminDashboard`'s "Total Enrollments" card and +`ManageEnrollments`/`ManageGrades` therefore work by course selection +rather than a flat global list - this matches the real API surface rather +than assuming an endpoint that doesn't exist. + +## Build + +```bash +npm run build # outputs to dist/ +npm run preview # serve the production build locally +``` + +This has been verified to build cleanly (`npm run build`) and lint cleanly +(`npm run lint`, 0 errors) in this environment. diff --git a/frontend/student-management-ui/index.html b/frontend/student-management-ui/index.html new file mode 100644 index 0000000..131c942 --- /dev/null +++ b/frontend/student-management-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + StudentHub — Student Management System + + +
+ + + diff --git a/frontend/student-management-ui/package-lock.json b/frontend/student-management-ui/package-lock.json new file mode 100644 index 0000000..409ffa7 --- /dev/null +++ b/frontend/student-management-ui/package-lock.json @@ -0,0 +1,2714 @@ +{ + "name": "student-management-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "student-management-ui", + "version": "0.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.2.0", + "@mui/material": "^9.2.0", + "axios": "^1.19.0", + "notistack": "^3.0.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", + "integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz", + "integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz", + "integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.2.0", + "@mui/system": "^9.2.0", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.6", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz", + "integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.2.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", + "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz", + "integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.2.0", + "@mui/styled-engine": "^9.1.1", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", + "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz", + "integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.1.1", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", + "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.76.0.tgz", + "integrity": "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.76.0.tgz", + "integrity": "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.76.0.tgz", + "integrity": "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.76.0.tgz", + "integrity": "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.76.0.tgz", + "integrity": "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.76.0.tgz", + "integrity": "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.76.0.tgz", + "integrity": "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.76.0.tgz", + "integrity": "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.76.0.tgz", + "integrity": "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.76.0.tgz", + "integrity": "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.76.0.tgz", + "integrity": "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.76.0.tgz", + "integrity": "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.76.0.tgz", + "integrity": "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.76.0.tgz", + "integrity": "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.76.0.tgz", + "integrity": "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.76.0.tgz", + "integrity": "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.76.0.tgz", + "integrity": "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.76.0.tgz", + "integrity": "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.76.0.tgz", + "integrity": "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/notistack": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz", + "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oxlint": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.76.0.tgz", + "integrity": "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.76.0", + "@oxlint/binding-android-arm64": "1.76.0", + "@oxlint/binding-darwin-arm64": "1.76.0", + "@oxlint/binding-darwin-x64": "1.76.0", + "@oxlint/binding-freebsd-x64": "1.76.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", + "@oxlint/binding-linux-arm-musleabihf": "1.76.0", + "@oxlint/binding-linux-arm64-gnu": "1.76.0", + "@oxlint/binding-linux-arm64-musl": "1.76.0", + "@oxlint/binding-linux-ppc64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-musl": "1.76.0", + "@oxlint/binding-linux-s390x-gnu": "1.76.0", + "@oxlint/binding-linux-x64-gnu": "1.76.0", + "@oxlint/binding-linux-x64-musl": "1.76.0", + "@oxlint/binding-openharmony-arm64": "1.76.0", + "@oxlint/binding-win32-arm64-msvc": "1.76.0", + "@oxlint/binding-win32-ia32-msvc": "1.76.0", + "@oxlint/binding-win32-x64-msvc": "1.76.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/student-management-ui/package.json b/frontend/student-management-ui/package.json new file mode 100644 index 0000000..e7d4009 --- /dev/null +++ b/frontend/student-management-ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "student-management-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.2.0", + "@mui/material": "^9.2.0", + "axios": "^1.19.0", + "notistack": "^3.0.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "vite": "^8.1.1" + } +} diff --git a/frontend/student-management-ui/public/favicon.svg b/frontend/student-management-ui/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/student-management-ui/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/student-management-ui/public/icons.svg b/frontend/student-management-ui/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/student-management-ui/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/student-management-ui/src/App.jsx b/frontend/student-management-ui/src/App.jsx new file mode 100644 index 0000000..c762d91 --- /dev/null +++ b/frontend/student-management-ui/src/App.jsx @@ -0,0 +1,61 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from './context/AuthContext'; +import ProtectedRoute from './components/ProtectedRoute'; +import RoleRoute from './components/RoleRoute'; +import AppLayout from './components/layout/AppLayout'; + +import Login from './pages/Login'; +import Register from './pages/Register'; +import NotFound from './pages/NotFound'; + +import AdminDashboard from './pages/admin/AdminDashboard'; +import ManageStudents from './pages/admin/ManageStudents'; +import ManageCourses from './pages/admin/ManageCourses'; +import ManageEnrollments from './pages/admin/ManageEnrollments'; +import ManageGrades from './pages/admin/ManageGrades'; +import ManageUsers from './pages/admin/ManageUsers'; + +import StudentDashboard from './pages/student/StudentDashboard'; +import AvailableCourses from './pages/student/AvailableCourses'; +import MyCourses from './pages/student/MyCourses'; +import MyGrades from './pages/student/MyGrades'; +import Profile from './pages/student/Profile'; + +function HomeRedirect() { + const { isAuthenticated, isAdmin } = useAuth(); + if (!isAuthenticated) return ; + return ; +} + +export default function App() { + return ( + + } /> + } /> + } /> + + }> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + + + + + } /> + + ); +} diff --git a/frontend/student-management-ui/src/api/authApi.js b/frontend/student-management-ui/src/api/authApi.js new file mode 100644 index 0000000..34e0bdf --- /dev/null +++ b/frontend/student-management-ui/src/api/authApi.js @@ -0,0 +1,13 @@ +import { apiClient } from './axiosClient'; + +export const authApi = { + register: (payload) => apiClient.post('/auth/register', payload).then((r) => r.data.data), + login: (payload) => apiClient.post('/auth/login', payload).then((r) => r.data.data), + logout: (refreshToken) => apiClient.post('/auth/logout', { refreshToken }).then((r) => r.data), +}; + +export const adminApi = { + listUsers: () => apiClient.get('/admin/users').then((r) => r.data.data), + assignRole: (userId, role) => apiClient.patch(`/admin/users/${userId}/role`, { role }).then((r) => r.data.data), + setEnabled: (userId, enabled) => apiClient.patch(`/admin/users/${userId}/status`, { enabled }).then((r) => r.data.data), +}; diff --git a/frontend/student-management-ui/src/api/axiosClient.js b/frontend/student-management-ui/src/api/axiosClient.js new file mode 100644 index 0000000..14272d7 --- /dev/null +++ b/frontend/student-management-ui/src/api/axiosClient.js @@ -0,0 +1,86 @@ +import axios from 'axios'; +import { tokenStorage } from '../utils/tokenStorage'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000/api/v1'; + +export const apiClient = axios.create({ baseURL: BASE_URL }); + +// --- Request interceptor: attach the current access token --- +apiClient.interceptors.request.use((config) => { + const token = tokenStorage.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// --- Response interceptor: on 401, refresh once and retry --- +// Concurrent requests that all 401 at the same time share a single +// in-flight refresh call instead of each firing their own refresh. +let isRefreshing = false; +let pendingQueue = []; + +function resolveQueue(error, token) { + pendingQueue.forEach(({ resolve, reject }) => { + if (error) reject(error); + else resolve(token); + }); + pendingQueue = []; +} + +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const { config, response } = error; + const isAuthEndpoint = config?.url?.includes('/auth/login') || config?.url?.includes('/auth/register'); + + if (response?.status !== 401 || isAuthEndpoint || config._retry) { + return Promise.reject(error); + } + + const refreshToken = tokenStorage.getRefreshToken(); + if (!refreshToken) { + tokenStorage.clear(); + window.location.href = '/login'; + return Promise.reject(error); + } + + if (isRefreshing) { + // Queue this request until the in-flight refresh finishes. + return new Promise((resolve, reject) => { + pendingQueue.push({ resolve, reject }); + }).then((newToken) => { + config.headers.Authorization = `Bearer ${newToken}`; + return apiClient(config); + }); + } + + config._retry = true; + isRefreshing = true; + + try { + const { data } = await axios.post(`${BASE_URL}/auth/refresh`, { refreshToken }); + const { accessToken, refreshToken: newRefreshToken } = data.data; + tokenStorage.updateTokens({ accessToken, refreshToken: newRefreshToken }); + resolveQueue(null, accessToken); + config.headers.Authorization = `Bearer ${accessToken}`; + return apiClient(config); + } catch (refreshError) { + resolveQueue(refreshError, null); + tokenStorage.clear(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } +); + +/** Extracts a human-readable message from a backend ErrorResponse/ApiResponse. */ +export function extractErrorMessage(error) { + return ( + error?.response?.data?.message || + error?.message || + 'Something went wrong. Please try again.' + ); +} diff --git a/frontend/student-management-ui/src/api/courseApi.js b/frontend/student-management-ui/src/api/courseApi.js new file mode 100644 index 0000000..89c6fd5 --- /dev/null +++ b/frontend/student-management-ui/src/api/courseApi.js @@ -0,0 +1,9 @@ +import { apiClient } from './axiosClient'; + +export const courseApi = { + list: () => apiClient.get('/courses').then((r) => r.data), + getById: (id) => apiClient.get(`/courses/${id}`).then((r) => r.data), + create: (payload) => apiClient.post('/courses', payload).then((r) => r.data), + update: (id, payload) => apiClient.put(`/courses/${id}`, payload).then((r) => r.data), + remove: (id) => apiClient.delete(`/courses/${id}`), +}; diff --git a/frontend/student-management-ui/src/api/enrollmentApi.js b/frontend/student-management-ui/src/api/enrollmentApi.js new file mode 100644 index 0000000..c9ee173 --- /dev/null +++ b/frontend/student-management-ui/src/api/enrollmentApi.js @@ -0,0 +1,10 @@ +import { apiClient } from './axiosClient'; + +export const enrollmentApi = { + enroll: (studentId, courseId) => apiClient.post('/enrollments', { studentId, courseId }).then((r) => r.data), + getById: (id) => apiClient.get(`/enrollments/${id}`).then((r) => r.data), + getByStudent: (studentId) => apiClient.get(`/enrollments/student/${studentId}`).then((r) => r.data), + getByCourse: (courseId) => apiClient.get(`/enrollments/course/${courseId}`).then((r) => r.data), + updateStatus: (id, status) => apiClient.patch(`/enrollments/${id}/status`, { status }).then((r) => r.data), + drop: (id) => apiClient.delete(`/enrollments/${id}`), +}; diff --git a/frontend/student-management-ui/src/api/gradeApi.js b/frontend/student-management-ui/src/api/gradeApi.js new file mode 100644 index 0000000..949fba5 --- /dev/null +++ b/frontend/student-management-ui/src/api/gradeApi.js @@ -0,0 +1,11 @@ +import { apiClient } from './axiosClient'; + +// Note: grade-service (like student/course/enrollment-service) returns raw +// DTOs, not wrapped in the common-lib ApiResponse envelope - only +// auth-service does that. Hence `.data` here, not `.data.data`. +export const gradeApi = { + assign: (enrollmentId, gradePoints) => + apiClient.post('/grades', { enrollmentId, gradePoints }).then((r) => r.data), + getByStudent: (studentId) => apiClient.get(`/grades/student/${studentId}`).then((r) => r.data), + getGpa: (studentId) => apiClient.get(`/grades/student/${studentId}/gpa`).then((r) => r.data), +}; diff --git a/frontend/student-management-ui/src/api/studentApi.js b/frontend/student-management-ui/src/api/studentApi.js new file mode 100644 index 0000000..7e1be45 --- /dev/null +++ b/frontend/student-management-ui/src/api/studentApi.js @@ -0,0 +1,9 @@ +import { apiClient } from './axiosClient'; + +export const studentApi = { + getById: (id) => apiClient.get(`/students/${id}`).then((r) => r.data), + list: (params = {}) => apiClient.get('/students', { params: { unpaged: true, ...params } }).then((r) => r.data), + create: (payload) => apiClient.post('/students', payload).then((r) => r.data), + update: (id, payload) => apiClient.put(`/students/${id}`, payload).then((r) => r.data), + remove: (id) => apiClient.delete(`/students/${id}`), +}; diff --git a/frontend/student-management-ui/src/components/ProtectedRoute.jsx b/frontend/student-management-ui/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..bc81f24 --- /dev/null +++ b/frontend/student-management-ui/src/components/ProtectedRoute.jsx @@ -0,0 +1,13 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +/** Requires the user to be logged in; otherwise redirects to /login. */ +export default function ProtectedRoute() { + const { isAuthenticated } = useAuth(); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + return ; +} diff --git a/frontend/student-management-ui/src/components/RoleRoute.jsx b/frontend/student-management-ui/src/components/RoleRoute.jsx new file mode 100644 index 0000000..052566e --- /dev/null +++ b/frontend/student-management-ui/src/components/RoleRoute.jsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +/** Requires the user to hold a specific role; otherwise redirects to their own home. */ +export default function RoleRoute({ role }) { + const { user } = useAuth(); + + if (user?.role !== role) { + return ; + } + return ; +} diff --git a/frontend/student-management-ui/src/components/common/ConfirmDialog.jsx b/frontend/student-management-ui/src/components/common/ConfirmDialog.jsx new file mode 100644 index 0000000..2e59939 --- /dev/null +++ b/frontend/student-management-ui/src/components/common/ConfirmDialog.jsx @@ -0,0 +1,35 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +export default function ConfirmDialog({ + open, + title = 'Are you sure?', + message, + confirmLabel = 'Confirm', + destructive = false, + onConfirm, + onClose, +}) { + return ( + + {title} + + {message} + + + + + + + ); +} diff --git a/frontend/student-management-ui/src/components/common/LoadingSpinner.jsx b/frontend/student-management-ui/src/components/common/LoadingSpinner.jsx new file mode 100644 index 0000000..ccc5719 --- /dev/null +++ b/frontend/student-management-ui/src/components/common/LoadingSpinner.jsx @@ -0,0 +1,21 @@ +import { Box, CircularProgress, Typography } from '@mui/material'; + +export default function LoadingSpinner({ label = 'Loading...', minHeight = 240 }) { + return ( + + + + {label} + + + ); +} diff --git a/frontend/student-management-ui/src/components/layout/AppLayout.jsx b/frontend/student-management-ui/src/components/layout/AppLayout.jsx new file mode 100644 index 0000000..47611a9 --- /dev/null +++ b/frontend/student-management-ui/src/components/layout/AppLayout.jsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { Link as RouterLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppBar, + Avatar, + Box, + Divider, + Drawer, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Toolbar, + Typography, + useMediaQuery, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import MenuIcon from '@mui/icons-material/Menu'; +import DashboardIcon from '@mui/icons-material/DashboardOutlined'; +import PeopleIcon from '@mui/icons-material/PeopleOutlineOutlined'; +import MenuBookIcon from '@mui/icons-material/MenuBookOutlined'; +import AssignmentIcon from '@mui/icons-material/AssignmentOutlined'; +import GradeIcon from '@mui/icons-material/GradeOutlined'; +import GroupIcon from '@mui/icons-material/GroupOutlined'; +import PersonIcon from '@mui/icons-material/PersonOutlineOutlined'; +import LogoutIcon from '@mui/icons-material/LogoutOutlined'; +import SchoolIcon from '@mui/icons-material/School'; +import { useAuth } from '../../context/AuthContext'; + +const DRAWER_WIDTH = 248; + +const ADMIN_NAV = [ + { label: 'Dashboard', path: '/admin', icon: }, + { label: 'Students', path: '/admin/students', icon: }, + { label: 'Courses', path: '/admin/courses', icon: }, + { label: 'Enrollments', path: '/admin/enrollments', icon: }, + { label: 'Grades', path: '/admin/grades', icon: }, + { label: 'Users', path: '/admin/users', icon: }, +]; + +const STUDENT_NAV = [ + { label: 'Dashboard', path: '/student', icon: }, + { label: 'Available Courses', path: '/student/courses', icon: }, + { label: 'My Courses', path: '/student/my-courses', icon: }, + { label: 'My Grades', path: '/student/grades', icon: }, + { label: 'Profile', path: '/student/profile', icon: }, +]; + +export default function AppLayout() { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const [mobileOpen, setMobileOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const { user, isAdmin, logout } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const navItems = isAdmin ? ADMIN_NAV : STUDENT_NAV; + + const handleLogout = () => { + setAnchorEl(null); + logout(); + navigate('/login'); + }; + + const drawerContent = ( + + + + + StudentHub + + + + + {navItems.map((item) => { + const selected = location.pathname === item.path; + return ( + isMobile && setMobileOpen(false)} + sx={{ + borderRadius: 2, + mb: 0.5, + '&.Mui-selected': { + bgcolor: 'primary.main', + color: 'primary.contrastText', + '& .MuiListItemIcon-root': { color: 'primary.contrastText' }, + '&:hover': { bgcolor: 'primary.dark' }, + }, + }} + > + {item.icon} + + + ); + })} + + + + + Signed in as {isAdmin ? 'Administrator' : 'Student'} + + + + ); + + return ( + + + + setMobileOpen(true)} + sx={{ display: { md: 'none' } }} + > + + + + setAnchorEl(e.currentTarget)} sx={{ gap: 1, borderRadius: 2 }}> + + {(user?.fullName || user?.email || '?').charAt(0).toUpperCase()} + + + setAnchorEl(null)}> + + + {user?.fullName} + + + {user?.email} + + + + + + + + Logout + + + + + + + setMobileOpen(false)} + ModalProps={{ keepMounted: true }} + sx={{ + display: { xs: 'block', md: 'none' }, + '& .MuiDrawer-paper': { width: DRAWER_WIDTH }, + }} + > + {drawerContent} + + + {drawerContent} + + + + + + + + + + + ); +} diff --git a/frontend/student-management-ui/src/context/AuthContext.jsx b/frontend/student-management-ui/src/context/AuthContext.jsx new file mode 100644 index 0000000..5be31f5 --- /dev/null +++ b/frontend/student-management-ui/src/context/AuthContext.jsx @@ -0,0 +1,75 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { authApi } from '../api/authApi'; +import { tokenStorage } from '../utils/tokenStorage'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(() => tokenStorage.getUser()); + const [loading, setLoading] = useState(false); + + // Keep state in sync if another tab logs in/out. + useEffect(() => { + const onStorage = () => setUser(tokenStorage.getUser()); + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const login = async (email, password) => { + setLoading(true); + try { + const data = await authApi.login({ email, password }); + const { accessToken, refreshToken, ...rest } = data; + tokenStorage.setSession({ accessToken, refreshToken, ...rest }); + setUser(rest); + return rest; + } finally { + setLoading(false); + } + }; + + const register = async (payload) => { + setLoading(true); + try { + const data = await authApi.register(payload); + const { accessToken, refreshToken, ...rest } = data; + tokenStorage.setSession({ accessToken, refreshToken, ...rest }); + setUser(rest); + return rest; + } finally { + setLoading(false); + } + }; + + const logout = async () => { + const refreshToken = tokenStorage.getRefreshToken(); + tokenStorage.clear(); + setUser(null); + if (refreshToken) { + // Best-effort - the user is logged out client-side regardless of outcome. + authApi.logout(refreshToken).catch(() => {}); + } + }; + + const value = useMemo( + () => ({ + user, + isAuthenticated: !!user, + isAdmin: user?.role === 'ADMIN', + isStudent: user?.role === 'STUDENT', + loading, + login, + register, + logout, + }), + [user, loading] + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} diff --git a/frontend/student-management-ui/src/main.jsx b/frontend/student-management-ui/src/main.jsx new file mode 100644 index 0000000..1950d30 --- /dev/null +++ b/frontend/student-management-ui/src/main.jsx @@ -0,0 +1,24 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import { SnackbarProvider } from 'notistack'; +import App from './App.jsx'; +import { AuthProvider } from './context/AuthContext.jsx'; +import { theme } from './theme/theme.js'; + +createRoot(document.getElementById('root')).render( + + + + + + + + + + + + +); diff --git a/frontend/student-management-ui/src/pages/Login.jsx b/frontend/student-management-ui/src/pages/Login.jsx new file mode 100644 index 0000000..d4b6d55 --- /dev/null +++ b/frontend/student-management-ui/src/pages/Login.jsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Link, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SchoolIcon from '@mui/icons-material/School'; +import { useAuth } from '../context/AuthContext'; +import { extractErrorMessage } from '../api/axiosClient'; + +export default function Login() { + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [form, setForm] = useState({ email: '', password: '' }); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleChange = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value })); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setSubmitting(true); + try { + const user = await login(form.email, form.password); + const redirectTo = location.state?.from?.pathname || (user.role === 'ADMIN' ? '/admin' : '/student'); + navigate(redirectTo, { replace: true }); + } catch (err) { + setError(extractErrorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + Welcome back + + Sign in to StudentHub + + + + {error && ( + + {error} + + )} + + + + + + + + + + + Don't have an account?{' '} + + Register + + + + + + ); +} diff --git a/frontend/student-management-ui/src/pages/NotFound.jsx b/frontend/student-management-ui/src/pages/NotFound.jsx new file mode 100644 index 0000000..977b1d9 --- /dev/null +++ b/frontend/student-management-ui/src/pages/NotFound.jsx @@ -0,0 +1,27 @@ +import { Box, Button, Typography } from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; + +export default function NotFound() { + return ( + + + 404 + + Page not found + + + ); +} diff --git a/frontend/student-management-ui/src/pages/Register.jsx b/frontend/student-management-ui/src/pages/Register.jsx new file mode 100644 index 0000000..69c911a --- /dev/null +++ b/frontend/student-management-ui/src/pages/Register.jsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Link, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SchoolIcon from '@mui/icons-material/School'; +import { useAuth } from '../context/AuthContext'; +import { extractErrorMessage } from '../api/axiosClient'; + +export default function Register() { + const { register } = useAuth(); + const navigate = useNavigate(); + + const [form, setForm] = useState({ fullName: '', email: '', password: '' }); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleChange = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value })); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setSubmitting(true); + try { + await register(form); + navigate('/student', { replace: true }); + } catch (err) { + setError(extractErrorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + Create your account + + Registers a student account. Your administrator will link your + student profile and can promote accounts to admin as needed. + + + + {error && ( + + {error} + + )} + + + + + + + + + + + + Already have an account?{' '} + + Sign in + + + + + + ); +} diff --git a/frontend/student-management-ui/src/pages/admin/AdminDashboard.jsx b/frontend/student-management-ui/src/pages/admin/AdminDashboard.jsx new file mode 100644 index 0000000..e763705 --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/AdminDashboard.jsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { + Box, + Card, + CardContent, + Grid, + Stack, + Typography, + Chip, + List, + ListItem, + ListItemText, + Divider, +} from '@mui/material'; +import PeopleIcon from '@mui/icons-material/PeopleOutlineOutlined'; +import MenuBookIcon from '@mui/icons-material/MenuBookOutlined'; +import AssignmentIcon from '@mui/icons-material/AssignmentOutlined'; +import GradeIcon from '@mui/icons-material/GradeOutlined'; +import { studentApi } from '../../api/studentApi'; +import { courseApi } from '../../api/courseApi'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; + +const CARD_META = [ + { key: 'students', label: 'Total Students', icon: , color: '#3454D1' }, + { key: 'courses', label: 'Total Courses', icon: , color: '#7C3AED' }, + { key: 'enrollments', label: 'Total Enrollments', icon: , color: '#0EA5A4' }, + { key: 'activeCourses', label: 'Active Courses', icon: , color: '#F59E0B' }, +]; + +export default function AdminDashboard() { + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ students: 0, courses: 0, enrollments: 0, activeCourses: 0 }); + const [recentCourses, setRecentCourses] = useState([]); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + try { + const [students, courses] = await Promise.all([studentApi.list(), courseApi.list()]); + + // No dedicated "all enrollments" admin endpoint exists yet, so the + // total is aggregated client-side across each course. Fine for a + // demo-scale dataset; a dedicated aggregate endpoint would be the + // next step for a larger deployment. + const enrollmentCounts = await Promise.allSettled( + courses.map((c) => enrollmentApi.getByCourse(c.id)) + ); + const totalEnrollments = enrollmentCounts.reduce( + (sum, result) => sum + (result.status === 'fulfilled' ? result.value.length : 0), + 0 + ); + + if (!cancelled) { + setStats({ + students: students.length, + courses: courses.length, + enrollments: totalEnrollments, + activeCourses: courses.filter((c) => c.status === 'ACTIVE').length, + }); + setRecentCourses(courses.slice(-5).reverse()); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, []); + + if (loading) return ; + + return ( + + + Admin Dashboard + + + An overview of students, courses, and enrollment activity. + + + + {CARD_META.map((meta) => ( + + + + + + {meta.icon} + + + {stats[meta.key]} + + {meta.label} + + + + + + + ))} + + + + + + Recently Added Courses + + {recentCourses.length === 0 ? ( + + No courses yet.{' '} + Create one + + ) : ( + + {recentCourses.map((course, idx) => ( + + + } + > + + + {idx < recentCourses.length - 1 && } + + ))} + + )} + + + + ); +} diff --git a/frontend/student-management-ui/src/pages/admin/ManageCourses.jsx b/frontend/student-management-ui/src/pages/admin/ManageCourses.jsx new file mode 100644 index 0000000..522c0b2 --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/ManageCourses.jsx @@ -0,0 +1,287 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Card, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/EditOutlined'; +import DeleteIcon from '@mui/icons-material/DeleteOutlineOutlined'; +import { useSnackbar } from 'notistack'; +import { courseApi } from '../../api/courseApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import ConfirmDialog from '../../components/common/ConfirmDialog'; +import { extractErrorMessage } from '../../api/axiosClient'; +import { COURSE_STATUS } from '../../utils/constants'; + +const emptyForm = { + courseCode: '', + title: '', + description: '', + credits: 3, + capacity: 30, + semester: '', + instructor: '', + department: '', + status: 'ACTIVE', +}; + +const statusColor = { + ACTIVE: 'success', + INACTIVE: 'default', + COMPLETED: 'info', + CANCELLED: 'error', +}; + +export default function ManageCourses() { + const { enqueueSnackbar } = useSnackbar(); + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const load = async () => { + setLoading(true); + try { + setCourses(await courseApi.list()); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openCreateDialog = () => { + setEditingId(null); + setForm(emptyForm); + setDialogOpen(true); + }; + + const openEditDialog = (course) => { + setEditingId(course.id); + setForm({ ...emptyForm, ...course }); + setDialogOpen(true); + }; + + const handleSave = async () => { + setSaving(true); + try { + const payload = { ...form, credits: Number(form.credits), capacity: Number(form.capacity) }; + if (editingId) { + await courseApi.update(editingId, payload); + enqueueSnackbar('Course updated', { variant: 'success' }); + } else { + await courseApi.create(payload); + enqueueSnackbar('Course created', { variant: 'success' }); + } + setDialogOpen(false); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + try { + await courseApi.remove(confirmDeleteId); + enqueueSnackbar('Course deleted', { variant: 'success' }); + setConfirmDeleteId(null); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } + }; + + if (loading) return ; + + return ( + + + + Courses + + {courses.length} total + + + + + + + + + + + Code + Title + Credits + Capacity + Semester + Instructor + Status + Actions + + + + {courses.map((c) => ( + + {c.courseCode} + {c.title} + {c.credits} + {c.capacity} + {c.semester || '—'} + {c.instructor || '—'} + + + + + openEditDialog(c)}> + + + setConfirmDeleteId(c.id)}> + + + + + ))} + {courses.length === 0 && ( + + + No courses yet. + + + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editingId ? 'Edit Course' : 'Add Course'} + + + + setForm((f) => ({ ...f, courseCode: e.target.value }))} + /> + setForm((f) => ({ ...f, credits: e.target.value }))} + /> + + setForm((f) => ({ ...f, title: e.target.value }))} + /> + setForm((f) => ({ ...f, description: e.target.value }))} + /> + + setForm((f) => ({ ...f, capacity: e.target.value }))} + /> + setForm((f) => ({ ...f, semester: e.target.value }))} + /> + + + setForm((f) => ({ ...f, instructor: e.target.value }))} + /> + setForm((f) => ({ ...f, department: e.target.value }))} + /> + + setForm((f) => ({ ...f, status: e.target.value }))} + > + {Object.values(COURSE_STATUS).map((s) => ( + + {s} + + ))} + + + + + + + + + + setConfirmDeleteId(null)} + /> +
+ ); +} diff --git a/frontend/student-management-ui/src/pages/admin/ManageEnrollments.jsx b/frontend/student-management-ui/src/pages/admin/ManageEnrollments.jsx new file mode 100644 index 0000000..17e89d7 --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/ManageEnrollments.jsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Card, + Chip, + MenuItem, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { courseApi } from '../../api/courseApi'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; +import { ENROLLMENT_STATUS } from '../../utils/constants'; + +const statusColor = { + PENDING: 'warning', + CONFIRMED: 'success', + REJECTED: 'error', + DROPPED: 'default', + COMPLETED: 'info', +}; + +export default function ManageEnrollments() { + const { enqueueSnackbar } = useSnackbar(); + const [courses, setCourses] = useState([]); + const [selectedCourseId, setSelectedCourseId] = useState(''); + const [enrollments, setEnrollments] = useState([]); + const [loadingCourses, setLoadingCourses] = useState(true); + const [loadingEnrollments, setLoadingEnrollments] = useState(false); + const [updatingId, setUpdatingId] = useState(null); + + useEffect(() => { + (async () => { + setLoadingCourses(true); + try { + const data = await courseApi.list(); + setCourses(data); + if (data.length > 0) setSelectedCourseId(data[0].id); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoadingCourses(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!selectedCourseId) return; + (async () => { + setLoadingEnrollments(true); + try { + const data = await enrollmentApi.getByCourse(selectedCourseId); + setEnrollments(data); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoadingEnrollments(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCourseId]); + + const handleStatusChange = async (enrollmentId, status) => { + setUpdatingId(enrollmentId); + try { + await enrollmentApi.updateStatus(enrollmentId, status); + enqueueSnackbar('Enrollment status updated', { variant: 'success' }); + const data = await enrollmentApi.getByCourse(selectedCourseId); + setEnrollments(data); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setUpdatingId(null); + } + }; + + if (loadingCourses) return ; + + return ( + + + Enrollments + + + Browse enrollments by course and manage their status. + + + setSelectedCourseId(e.target.value)} + sx={{ mb: 3, minWidth: 320 }} + > + {courses.map((c) => ( + + {c.courseCode} — {c.title} + + ))} + + + {loadingEnrollments ? ( + + ) : ( + + + + + + Student + Email + Status + Enrolled At + Change Status + + + + {enrollments.map((e) => ( + + + {e.student ? `${e.student.firstName} ${e.student.lastName}` : `Student #${e.studentId}`} + + {e.student?.email || '—'} + + + + {e.enrolledAt ? new Date(e.enrolledAt).toLocaleDateString() : '—'} + + handleStatusChange(e.id, ev.target.value)} + sx={{ minWidth: 150 }} + > + {Object.values(ENROLLMENT_STATUS).map((s) => ( + + {s} + + ))} + + + + ))} + {enrollments.length === 0 && ( + + + No students enrolled in this course yet. + + + )} + +
+
+
+ )} +
+ ); +} diff --git a/frontend/student-management-ui/src/pages/admin/ManageGrades.jsx b/frontend/student-management-ui/src/pages/admin/ManageGrades.jsx new file mode 100644 index 0000000..4a0d70e --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/ManageGrades.jsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Card, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { courseApi } from '../../api/courseApi'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import { gradeApi } from '../../api/gradeApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; + +export default function ManageGrades() { + const { enqueueSnackbar } = useSnackbar(); + const [courses, setCourses] = useState([]); + const [selectedCourseId, setSelectedCourseId] = useState(''); + const [roster, setRoster] = useState([]); // [{ enrollment, grade }] + const [loadingCourses, setLoadingCourses] = useState(true); + const [loadingRoster, setLoadingRoster] = useState(false); + + const [dialogEnrollment, setDialogEnrollment] = useState(null); + const [gradePoints, setGradePoints] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + (async () => { + setLoadingCourses(true); + try { + const data = await courseApi.list(); + setCourses(data); + if (data.length > 0) setSelectedCourseId(data[0].id); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoadingCourses(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadRoster = async (courseId) => { + setLoadingRoster(true); + try { + const enrollments = await enrollmentApi.getByCourse(courseId); + // Grade-service has no "grades by enrollment" endpoint, so we fetch + // each student's full grade list and match this course's entry. + const withGrades = await Promise.all( + enrollments.map(async (enrollment) => { + try { + const grades = await gradeApi.getByStudent(enrollment.studentId); + const grade = grades.find((g) => g.courseId === Number(courseId)) || null; + return { enrollment, grade }; + } catch { + return { enrollment, grade: null }; + } + }) + ); + setRoster(withGrades); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoadingRoster(false); + } + }; + + useEffect(() => { + if (selectedCourseId) loadRoster(selectedCourseId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCourseId]); + + const openGradeDialog = (enrollment, existingGrade) => { + setDialogEnrollment(enrollment); + setGradePoints(existingGrade ? String(existingGrade.gradePoints) : ''); + }; + + const handleAssign = async () => { + setSaving(true); + try { + await gradeApi.assign(dialogEnrollment.id, Number(gradePoints)); + enqueueSnackbar('Grade saved', { variant: 'success' }); + setDialogEnrollment(null); + loadRoster(selectedCourseId); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setSaving(false); + } + }; + + if (loadingCourses) return ; + + return ( + + + Grades + + + Assign or update grades for a course's confirmed/completed enrollments. + + + setSelectedCourseId(e.target.value)} + sx={{ mb: 3, minWidth: 320 }} + > + {courses.map((c) => ( + + {c.courseCode} — {c.title} ({c.credits} credits) + + ))} + + + {loadingRoster ? ( + + ) : ( + + + + + + Student + Enrollment Status + Current Grade + Action + + + + {roster.map(({ enrollment, grade }) => { + const gradable = ['CONFIRMED', 'COMPLETED'].includes(enrollment.status); + return ( + + + {enrollment.student + ? `${enrollment.student.firstName} ${enrollment.student.lastName}` + : `Student #${enrollment.studentId}`} + + + + + + {grade ? ( + + ) : ( + + Not graded + + )} + + + + + + ); + })} + {roster.length === 0 && ( + + + No enrollments for this course yet. + + + )} + +
+
+
+ )} + + setDialogEnrollment(null)} maxWidth="xs" fullWidth> + Assign Grade + + + + {dialogEnrollment?.student + ? `${dialogEnrollment.student.firstName} ${dialogEnrollment.student.lastName}` + : `Student #${dialogEnrollment?.studentId}`} + + setGradePoints(e.target.value)} + autoFocus + fullWidth + /> + + + + + + + +
+ ); +} diff --git a/frontend/student-management-ui/src/pages/admin/ManageStudents.jsx b/frontend/student-management-ui/src/pages/admin/ManageStudents.jsx new file mode 100644 index 0000000..47d9d01 --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/ManageStudents.jsx @@ -0,0 +1,258 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, + Chip, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/EditOutlined'; +import DeleteIcon from '@mui/icons-material/DeleteOutlineOutlined'; +import SearchIcon from '@mui/icons-material/Search'; +import { useSnackbar } from 'notistack'; +import { studentApi } from '../../api/studentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import ConfirmDialog from '../../components/common/ConfirmDialog'; +import { extractErrorMessage } from '../../api/axiosClient'; + +const emptyForm = { firstName: '', lastName: '', email: '', phoneNumber: '', dateOfBirth: '' }; + +export default function ManageStudents() { + const { enqueueSnackbar } = useSnackbar(); + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const loadStudents = async () => { + setLoading(true); + try { + const data = await studentApi.list(); + setStudents(data); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadStudents(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return students; + return students.filter((s) => + `${s.firstName} ${s.lastName} ${s.email}`.toLowerCase().includes(q) + ); + }, [students, search]); + + const openCreateDialog = () => { + setEditingId(null); + setForm(emptyForm); + setDialogOpen(true); + }; + + const openEditDialog = (student) => { + setEditingId(student.id); + setForm({ + firstName: student.firstName || '', + lastName: student.lastName || '', + email: student.email || '', + phoneNumber: student.phoneNumber || '', + dateOfBirth: student.dateOfBirth || '', + }); + setDialogOpen(true); + }; + + const handleSave = async () => { + setSaving(true); + try { + if (editingId) { + await studentApi.update(editingId, form); + enqueueSnackbar('Student updated', { variant: 'success' }); + } else { + await studentApi.create(form); + enqueueSnackbar('Student created', { variant: 'success' }); + } + setDialogOpen(false); + loadStudents(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + try { + await studentApi.remove(confirmDeleteId); + enqueueSnackbar('Student deleted', { variant: 'success' }); + setConfirmDeleteId(null); + loadStudents(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } + }; + + if (loading) return ; + + return ( + + + + Students + + {students.length} total + + + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + + + + Name + Email + Phone + Status + Actions + + + + {filtered.map((s) => ( + + {s.firstName} {s.lastName} + {s.email} + {s.phoneNumber || '—'} + + + + + openEditDialog(s)}> + + + setConfirmDeleteId(s.id)}> + + + + + ))} + {filtered.length === 0 && ( + + + No students found. + + + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editingId ? 'Edit Student' : 'Add Student'} + + + + setForm((f) => ({ ...f, firstName: e.target.value }))} + /> + setForm((f) => ({ ...f, lastName: e.target.value }))} + /> + + setForm((f) => ({ ...f, email: e.target.value }))} + /> + setForm((f) => ({ ...f, phoneNumber: e.target.value }))} + /> + setForm((f) => ({ ...f, dateOfBirth: e.target.value }))} + /> + + + + + + + + + setConfirmDeleteId(null)} + /> +
+ ); +} diff --git a/frontend/student-management-ui/src/pages/admin/ManageUsers.jsx b/frontend/student-management-ui/src/pages/admin/ManageUsers.jsx new file mode 100644 index 0000000..2c67ca2 --- /dev/null +++ b/frontend/student-management-ui/src/pages/admin/ManageUsers.jsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Card, + Chip, + MenuItem, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { adminApi } from '../../api/authApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; +import { ROLES } from '../../utils/constants'; +import { useAuth } from '../../context/AuthContext'; + +export default function ManageUsers() { + const { enqueueSnackbar } = useSnackbar(); + const { user: currentUser } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + + const load = async () => { + setLoading(true); + try { + setUsers(await adminApi.listUsers()); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRoleChange = async (userId, role) => { + setBusyId(userId); + try { + await adminApi.assignRole(userId, role); + enqueueSnackbar('Role updated', { variant: 'success' }); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setBusyId(null); + } + }; + + const handleToggleEnabled = async (userId, enabled) => { + setBusyId(userId); + try { + await adminApi.setEnabled(userId, enabled); + enqueueSnackbar(enabled ? 'User activated' : 'User deactivated', { variant: 'success' }); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setBusyId(null); + } + }; + + if (loading) return ; + + return ( + + + Users + + + Assign roles and activate/deactivate accounts. + + + + + + + + Name + Email + Role + Student ID + Active + + + + {users.map((u) => ( + + {u.fullName} + {u.email} + + handleRoleChange(u.id, e.target.value)} + sx={{ minWidth: 130 }} + > + {Object.values(ROLES).map((r) => ( + + {r} + + ))} + + + + {u.studentId ? : '—'} + + + handleToggleEnabled(u.id, e.target.checked)} + /> + + + ))} + {users.length === 0 && ( + + + No users found. + + + )} + +
+
+
+
+ ); +} diff --git a/frontend/student-management-ui/src/pages/student/AvailableCourses.jsx b/frontend/student-management-ui/src/pages/student/AvailableCourses.jsx new file mode 100644 index 0000000..f9f5480 --- /dev/null +++ b/frontend/student-management-ui/src/pages/student/AvailableCourses.jsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Chip, + Grid, + Stack, + Typography, + Alert, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useAuth } from '../../context/AuthContext'; +import { courseApi } from '../../api/courseApi'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; + +export default function AvailableCourses() { + const { user } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + const [courses, setCourses] = useState([]); + const [myEnrollments, setMyEnrollments] = useState([]); + const [loading, setLoading] = useState(true); + const [enrollingId, setEnrollingId] = useState(null); + + const load = async () => { + setLoading(true); + try { + const [courseData, enrollmentData] = await Promise.all([ + courseApi.list(), + user?.studentId ? enrollmentApi.getByStudent(user.studentId) : Promise.resolve([]), + ]); + setCourses(courseData.filter((c) => c.status === 'ACTIVE')); + setMyEnrollments(enrollmentData); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.studentId]); + + const handleEnroll = async (courseId) => { + setEnrollingId(courseId); + try { + await enrollmentApi.enroll(user.studentId, courseId); + enqueueSnackbar('Enrolled successfully', { variant: 'success' }); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setEnrollingId(null); + } + }; + + if (!user?.studentId) { + return ( + + Your account isn't linked to a student profile yet. Ask an + administrator to link your account before enrolling in courses. + + ); + } + + if (loading) return ; + + const enrolledCourseIds = new Set( + myEnrollments.filter((e) => e.status !== 'DROPPED' && e.status !== 'REJECTED').map((e) => e.courseId) + ); + + return ( + + + Available Courses + + + Browse active courses and enroll. + + + + {courses.map((course) => { + const alreadyEnrolled = enrolledCourseIds.has(course.id); + return ( + + + + + + + {course.credits} credits + + + + {course.title} + + {course.description && ( + + {course.description} + + )} + + {course.instructor && ( + + Instructor: {course.instructor} + + )} + {course.department && ( + + Department: {course.department} + + )} + {course.semester && ( + + Semester: {course.semester} + + )} + + + + + + + + ); + })} + {courses.length === 0 && ( + + + No active courses available right now. + + + )} + + + ); +} diff --git a/frontend/student-management-ui/src/pages/student/MyCourses.jsx b/frontend/student-management-ui/src/pages/student/MyCourses.jsx new file mode 100644 index 0000000..544ec2b --- /dev/null +++ b/frontend/student-management-ui/src/pages/student/MyCourses.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Box, + Card, + Chip, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutlineOutlined'; +import { useSnackbar } from 'notistack'; +import { useAuth } from '../../context/AuthContext'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import ConfirmDialog from '../../components/common/ConfirmDialog'; +import { extractErrorMessage } from '../../api/axiosClient'; + +const statusColor = { + PENDING: 'warning', + CONFIRMED: 'success', + REJECTED: 'error', + DROPPED: 'default', + COMPLETED: 'info', +}; + +export default function MyCourses() { + const { user } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + const [enrollments, setEnrollments] = useState([]); + const [loading, setLoading] = useState(true); + const [confirmDropId, setConfirmDropId] = useState(null); + + const load = async () => { + setLoading(true); + try { + setEnrollments(await enrollmentApi.getByStudent(user.studentId)); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (user?.studentId) load(); + else setLoading(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.studentId]); + + const handleDrop = async () => { + try { + await enrollmentApi.drop(confirmDropId); + enqueueSnackbar('Course dropped', { variant: 'success' }); + setConfirmDropId(null); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } + }; + + if (!user?.studentId) { + return ( + + Your account isn't linked to a student profile yet. Ask an + administrator to link your account. + + ); + } + + if (loading) return ; + + return ( + + + My Courses + + + Everything you're currently enrolled in, plus your enrollment history. + + + + + + + + Course + Instructor + Credits + Status + Enrolled + Actions + + + + {enrollments.map((e) => ( + + + {e.course ? `${e.course.courseCode} — ${e.course.title}` : `Course #${e.courseId}`} + + {e.course?.instructor || '—'} + {e.course?.credits ?? '—'} + + + + {e.enrolledAt ? new Date(e.enrolledAt).toLocaleDateString() : '—'} + + {(e.status === 'CONFIRMED' || e.status === 'PENDING') && ( + + setConfirmDropId(e.id)}> + + + + )} + + + ))} + {enrollments.length === 0 && ( + + + You're not enrolled in any courses yet. + + + )} + +
+
+
+ + setConfirmDropId(null)} + /> +
+ ); +} diff --git a/frontend/student-management-ui/src/pages/student/MyGrades.jsx b/frontend/student-management-ui/src/pages/student/MyGrades.jsx new file mode 100644 index 0000000..759cbd4 --- /dev/null +++ b/frontend/student-management-ui/src/pages/student/MyGrades.jsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Box, + Card, + CardContent, + Chip, + Grid, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useAuth } from '../../context/AuthContext'; +import { gradeApi } from '../../api/gradeApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; + +export default function MyGrades() { + const { user } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + const [grades, setGrades] = useState([]); + const [gpa, setGpa] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!user?.studentId) { + setLoading(false); + return; + } + (async () => { + setLoading(true); + try { + const [gradeData, gpaData] = await Promise.all([ + gradeApi.getByStudent(user.studentId), + gradeApi.getGpa(user.studentId), + ]); + setGrades(gradeData); + setGpa(gpaData); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.studentId]); + + if (!user?.studentId) { + return ( + + Your account isn't linked to a student profile yet. Ask an + administrator to link your account. + + ); + } + + if (loading) return ; + + return ( + + + My Grades + + + Your finalized grades and GPA. + + + + + + + + CGPA + + {gpa?.cgpa?.toFixed(2) ?? '—'} + + + + + + + + Total Credits + + {gpa?.totalCredits ?? 0} + + + + + + + + Graded Courses + + {grades.length} + + + + + + {gpa && Object.keys(gpa.gpaBySemester || {}).length > 0 && ( + + + + GPA by Semester + + + {Object.entries(gpa.gpaBySemester).map(([semester, semesterGpa]) => ( + + {semester} + + {semesterGpa.toFixed(2)} + + + ))} + + + + )} + + + + + + + Course + Semester + Credits + Grade + + + + {grades.map((g) => ( + + {g.courseCode} — {g.courseTitle} + {g.semester || '—'} + {g.credits} + + + + + ))} + {grades.length === 0 && ( + + + No grades recorded yet. + + + )} + +
+
+
+
+ ); +} diff --git a/frontend/student-management-ui/src/pages/student/Profile.jsx b/frontend/student-management-ui/src/pages/student/Profile.jsx new file mode 100644 index 0000000..eaa42c6 --- /dev/null +++ b/frontend/student-management-ui/src/pages/student/Profile.jsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Avatar, + Box, + Button, + Card, + CardContent, + Chip, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useAuth } from '../../context/AuthContext'; +import { studentApi } from '../../api/studentApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; + +export default function Profile() { + const { user } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [profile, setProfile] = useState(null); + const [form, setForm] = useState({ firstName: '', lastName: '', phoneNumber: '', dateOfBirth: '' }); + + const load = async () => { + setLoading(true); + try { + const data = await studentApi.getById(user.studentId); + setProfile(data); + setForm({ + firstName: data.firstName || '', + lastName: data.lastName || '', + phoneNumber: data.phoneNumber || '', + dateOfBirth: data.dateOfBirth || '', + }); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (user?.studentId) load(); + else setLoading(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.studentId]); + + const handleSave = async () => { + setSaving(true); + try { + await studentApi.update(user.studentId, form); + enqueueSnackbar('Profile updated', { variant: 'success' }); + load(); + } catch (err) { + enqueueSnackbar(extractErrorMessage(err), { variant: 'error' }); + } finally { + setSaving(false); + } + }; + + if (!user?.studentId) { + return ( + + Your account isn't linked to a student profile yet. Ask an + administrator to link your account. + + ); + } + + if (loading) return ; + + return ( + + + Profile + + + View and update your personal information. + + + + + + + {(profile?.firstName || '?').charAt(0).toUpperCase()} + + + + {profile?.firstName} {profile?.lastName} + + + + {profile?.email} + + + + + + + + + setForm((f) => ({ ...f, firstName: e.target.value }))} + /> + setForm((f) => ({ ...f, lastName: e.target.value }))} + /> + + + setForm((f) => ({ ...f, phoneNumber: e.target.value }))} + /> + setForm((f) => ({ ...f, dateOfBirth: e.target.value }))} + /> + + + + + + + + ); +} diff --git a/frontend/student-management-ui/src/pages/student/StudentDashboard.jsx b/frontend/student-management-ui/src/pages/student/StudentDashboard.jsx new file mode 100644 index 0000000..413d297 --- /dev/null +++ b/frontend/student-management-ui/src/pages/student/StudentDashboard.jsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { Box, Card, CardContent, Grid, Stack, Typography, Alert } from '@mui/material'; +import MenuBookIcon from '@mui/icons-material/MenuBookOutlined'; +import CheckCircleIcon from '@mui/icons-material/CheckCircleOutlined'; +import GradeIcon from '@mui/icons-material/GradeOutlined'; +import EmojiEventsIcon from '@mui/icons-material/EmojiEventsOutlined'; +import { useAuth } from '../../context/AuthContext'; +import { enrollmentApi } from '../../api/enrollmentApi'; +import { gradeApi } from '../../api/gradeApi'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { extractErrorMessage } from '../../api/axiosClient'; + +export default function StudentDashboard() { + const { user } = useAuth(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [enrollments, setEnrollments] = useState([]); + const [gpa, setGpa] = useState(null); + + useEffect(() => { + if (!user?.studentId) { + setLoading(false); + return; + } + let cancelled = false; + + (async () => { + setLoading(true); + try { + const [enrollmentData, gpaData] = await Promise.all([ + enrollmentApi.getByStudent(user.studentId), + gradeApi.getGpa(user.studentId), + ]); + if (!cancelled) { + setEnrollments(enrollmentData); + setGpa(gpaData); + } + } catch (err) { + if (!cancelled) setError(extractErrorMessage(err)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [user?.studentId]); + + if (!user?.studentId) { + return ( + + Your account isn't linked to a student profile yet. Ask an + administrator to link your account, then check back here. + + ); + } + + if (loading) return ; + if (error) return {error}; + + const activeCourses = enrollments.filter((e) => e.status === 'CONFIRMED').length; + const completedCourses = enrollments.filter((e) => e.status === 'COMPLETED').length; + + const cards = [ + { label: 'My Courses', value: activeCourses, icon: , color: '#3454D1' }, + { label: 'Completed Courses', value: completedCourses, icon: , color: '#0EA5A4' }, + { label: 'Total Credits', value: gpa?.totalCredits ?? 0, icon: , color: '#7C3AED' }, + { label: 'CGPA', value: gpa?.cgpa?.toFixed(2) ?? '—', icon: , color: '#F59E0B' }, + ]; + + return ( + + + Welcome back, {user.fullName?.split(' ')[0]} + + + Here's where things stand. + + + + {cards.map((c) => ( + + + + + + {c.icon} + + + {c.value} + + {c.label} + + + + + + + ))} + + + {gpa && Object.keys(gpa.gpaBySemester || {}).length > 0 && ( + + + + GPA by Semester + + + {Object.entries(gpa.gpaBySemester).map(([semester, semesterGpa]) => ( + + {semester} + + {semesterGpa.toFixed(2)} + + + ))} + + + + )} + + ); +} diff --git a/frontend/student-management-ui/src/theme/theme.js b/frontend/student-management-ui/src/theme/theme.js new file mode 100644 index 0000000..dba2c5f --- /dev/null +++ b/frontend/student-management-ui/src/theme/theme.js @@ -0,0 +1,51 @@ +import { createTheme } from '@mui/material/styles'; + +export const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#3454D1', + }, + secondary: { + main: '#7C3AED', + }, + background: { + default: '#F5F7FB', + paper: '#FFFFFF', + }, + }, + shape: { + borderRadius: 10, + }, + typography: { + fontFamily: [ + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + 'Arial', + 'sans-serif', + ].join(','), + h4: { fontWeight: 700 }, + h5: { fontWeight: 700 }, + h6: { fontWeight: 600 }, + }, + components: { + MuiButton: { + styleOverrides: { + root: { textTransform: 'none', fontWeight: 600, borderRadius: 8 }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { backgroundImage: 'none' }, + }, + }, + MuiCard: { + styleOverrides: { + root: { boxShadow: '0 1px 3px rgba(16,24,40,0.08)' }, + }, + }, + }, +}); diff --git a/frontend/student-management-ui/src/utils/constants.js b/frontend/student-management-ui/src/utils/constants.js new file mode 100644 index 0000000..6d90423 --- /dev/null +++ b/frontend/student-management-ui/src/utils/constants.js @@ -0,0 +1,25 @@ +export const ROLES = { + ADMIN: 'ADMIN', + STUDENT: 'STUDENT', +}; + +export const STORAGE_KEYS = { + ACCESS_TOKEN: 'sms_access_token', + REFRESH_TOKEN: 'sms_refresh_token', + USER: 'sms_user', +}; + +export const ENROLLMENT_STATUS = { + PENDING: 'PENDING', + CONFIRMED: 'CONFIRMED', + REJECTED: 'REJECTED', + DROPPED: 'DROPPED', + COMPLETED: 'COMPLETED', +}; + +export const COURSE_STATUS = { + ACTIVE: 'ACTIVE', + INACTIVE: 'INACTIVE', + COMPLETED: 'COMPLETED', + CANCELLED: 'CANCELLED', +}; diff --git a/frontend/student-management-ui/src/utils/tokenStorage.js b/frontend/student-management-ui/src/utils/tokenStorage.js new file mode 100644 index 0000000..af0244a --- /dev/null +++ b/frontend/student-management-ui/src/utils/tokenStorage.js @@ -0,0 +1,24 @@ +import { STORAGE_KEYS } from './constants'; + +export const tokenStorage = { + getAccessToken: () => localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN), + getRefreshToken: () => localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN), + getUser: () => { + const raw = localStorage.getItem(STORAGE_KEYS.USER); + return raw ? JSON.parse(raw) : null; + }, + setSession: ({ accessToken, refreshToken, ...user }) => { + localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken); + localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken); + localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user)); + }, + updateTokens: ({ accessToken, refreshToken }) => { + localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken); + localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken); + }, + clear: () => { + localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); + localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); + localStorage.removeItem(STORAGE_KEYS.USER); + }, +}; diff --git a/frontend/student-management-ui/vite.config.js b/frontend/student-management-ui/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/student-management-ui/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/postman/Phase3-Courses-Grades.postman_collection.json b/postman/Phase3-Courses-Grades.postman_collection.json new file mode 100644 index 0000000..be8412c --- /dev/null +++ b/postman/Phase3-Courses-Grades.postman_collection.json @@ -0,0 +1,144 @@ +{ + "info": { + "name": "Student Management System - Phase 3 (Courses & Grades)", + "description": "course-service and grade-service flows, routed through api-gateway on port 9000. Run the Phase 2 collection first to get tokens and a student profile.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "gatewayUrl", "value": "http://localhost:9000/api/v1" }, + { "key": "studentAccessToken", "value": "" }, + { "key": "adminAccessToken", "value": "" } + ], + "item": [ + { + "name": "1. Course Catalog (course-service)", + "item": [ + { + "name": "Admin Creates a Course (full fields)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/courses", + "body": { + "mode": "raw", + "raw": "{\n \"courseCode\": \"CS201\",\n \"title\": \"Data Structures\",\n \"description\": \"Core CS course\",\n \"credits\": 4,\n \"capacity\": 30,\n \"semester\": \"FALL2026\",\n \"instructor\": \"Dr. Iyer\",\n \"department\": \"Computer Science\"\n}" + } + } + }, + { + "name": "Student Browses Courses", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/courses" + } + }, + { + "name": "Admin Updates Course Status", + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/courses/1", + "body": { "mode": "raw", "raw": "{\n \"status\": \"ACTIVE\"\n}" } + } + }, + { + "name": "Student Tries to Create a Course (should 403)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/courses", + "body": { "mode": "raw", "raw": "{\n \"courseCode\": \"HACK101\",\n \"title\": \"Should Fail\",\n \"credits\": 1\n}" } + } + } + ] + }, + { + "name": "2. Enrollment (now calls course-service internally)", + "item": [ + { + "name": "Student Enrolls in the New Course", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/enrollments", + "body": { "mode": "raw", "raw": "{\n \"studentId\": 1,\n \"courseId\": 1\n}" } + } + }, + { + "name": "Student Views Own Enrollment (shows nested course from course-service)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/enrollments/student/1" + } + } + ] + }, + { + "name": "3. Grades & GPA (grade-service)", + "item": [ + { + "name": "Admin Assigns a Grade (by enrollmentId)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/grades", + "body": { "mode": "raw", "raw": "{\n \"enrollmentId\": 1,\n \"gradePoints\": 8.7\n}" } + } + }, + { + "name": "Student Views Own Grades", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/grades/student/1" + } + }, + { + "name": "Student Views Own CGPA + Semester Breakdown", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/grades/student/1/gpa" + } + }, + { + "name": "Student Tries to View Someone Else's Grades (should 403)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/grades/student/2" + } + }, + { + "name": "Admin Assigns Grade for a Non-Enrolled Course (should 404)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/grades", + "body": { "mode": "raw", "raw": "{\n \"enrollmentId\": 9999,\n \"gradePoints\": 9.0\n}" } + } + } + ] + } + ] +}