diff --git a/.env.example b/.env.example
index 282d2f1..9b707d3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,9 @@
+DATABASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
-# Optional comma-separated allowlist for future server-side registration enforcement, e.g. msit.in
-COLLEGE_EMAIL_DOMAINS=
-# Server-only. Never prefix with NEXT_PUBLIC_ and never import it into client components.
SUPABASE_SERVICE_ROLE_KEY=
+NEXTAUTH_SECRET=
+COLLEGE_EMAIL_DOMAINS=
+CLASS_PULSE_DEFAULT_PASSWORD=changeme123
+GOOGLE_SA_EMAIL=
+GOOGLE_SA_PRIVATE_KEY=
diff --git a/README.md b/README.md
index ef386f7..3a425bd 100644
--- a/README.md
+++ b/README.md
@@ -1,95 +1,116 @@
# ClassPulse — Attendance & Academic Analytics Platform
-A college-wide web app version of the "CLASSPULSE" Google Sheet: teachers log in,
-pick a class or subject, and see the same analysis (attendance trends, midsem
-performance, KPI cards, top/bottom performers, report cards) — but live,
-multi-user, and backed by a real database.
+ClassPulse is a college-wide attendance and academic analytics application for teachers. The application reads its structured academic data from one PostgreSQL database and uses Google Sheets only as the raw attendance/marks source where configured.
## Architecture
+```text
+Supabase
+├─ Auth
+│ └─ email/password credentials
+└─ PostgreSQL
+ └─ ClassPulse application tables
+ └─ accessed by the Next.js backend through Prisma
+
+Google Sheets
+└─ raw attendance / marks source for analysis syncs
```
+
+There is one application database: the PostgreSQL database belonging to the ClassPulse Supabase project. Prisma is the ORM used by the Next.js backend to access that database. Supabase Auth is the single authority for user passwords and identities.
+
+The current Pages Router still uses NextAuth as a session bridge around the Supabase credential check. NextAuth does not store passwords or application user records; Supabase Auth owns credentials, while Prisma owns the ClassPulse user/profile and academic data.
+
+## Data model
+
+```text
College
- └─ Department (e.g. ECE)
- └─ Class (e.g. B.Tech ECE, 4th Year, Sem 7)
- └─ Section (e.g. A)
- ├─ Student (enrollment no, name, email)
- └─ Subject (e.g. Data Analysis — DA 338 T)
- ├─ TeacherAssignment (which teacher owns this subject/section)
- ├─ SheetLink (the Google Sheet holding raw marks/attendance)
- └─ AnalysisSnapshot (cached computed analysis, refreshed on demand)
+ └─ Department
+ └─ Class
+ └─ Section
+ ├─ Student
+ └─ Subject
+ ├─ Assignment → User
+ ├─ SheetLink
+ └─ AnalysisSnapshot
+
+User
+ ├─ Assignment
+ ├─ ClassAccess
+ ├─ Proctored classes
+ └─ AttendanceSession
```
-**Why cache analysis instead of recomputing from Sheets on every page load?**
-Google Sheets API has per-minute quotas (default 60 read requests/min/user).
-At college scale (many teachers loading dashboards concurrently) hitting the
-Sheets API on every request will throttle fast. Pattern used here:
-- A "Sync now" button / cron job pulls the sheet, computes analysis, stores it
- in Postgres (`AnalysisSnapshot`) with a `computedAt` timestamp.
-- Dashboard pages read from the DB (fast, no quota risk) and show
- "Last synced: X min ago" with a manual refresh option.
-- This still satisfies "live data" — it's live on-demand, not a hardcoded
- snapshot baked into the app.
+A subject assignment identifies the single teacher responsible for a class/section subject. ClassAccess is separate and grants whole-class visibility. This allows multiple teachers to have access to the same class while keeping each exact class+subject assignment unique.
## Stack
-- **Frontend/Backend**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Recharts
-- **Auth**: NextAuth.js (Credentials provider — bcrypt-hashed passwords), JWT sessions
-- **Database**: PostgreSQL + Prisma ORM
-- **Google Sheets access**: `googleapis` with a **service account** (share each
- subject sheet with the service account's email, read-only)
-- **Deployment target**: Vercel (app) + Neon/Supabase/RDS (Postgres)
+- Next.js 14 / TypeScript
+- Tailwind CSS / Recharts
+- Supabase Auth
+- Supabase PostgreSQL
+- Prisma ORM
+- Google Sheets API via a service account
+
+## Authentication
-## Auth & roles
+Teacher and admin accounts exist in both systems for different purposes:
-- `ADMIN` — college/department admin: creates classes, sections, subjects, assigns teachers
-- `TEACHER` — logs in, sees only classes/subjects assigned to them
-- Passwords stored as bcrypt hashes. Sessions are JWT, httpOnly cookies.
-- (Recommended next step once this is running: switch admin-created accounts
- to invite-based signup + SSO via your college's Google Workspace, using
- NextAuth's Google provider restricted to your college domain.)
+- Supabase Auth stores the actual email/password credential.
+- Prisma `User` stores the ClassPulse application profile, role, college and authorization relationships.
+- `User.authUserId` links the Prisma user to the corresponding Supabase Auth user.
+- Password hashes are not stored in Prisma.
-## Setup
+To provision the current Prisma users in Supabase Auth, configure `SUPABASE_SERVICE_ROLE_KEY` and run:
```bash
-cp .env.example .env # fill in DATABASE_URL, NEXTAUTH_SECRET, Google service account
npm install
-npx prisma migrate dev --name init
-npx prisma db seed # optional: creates a demo admin + demo class
+npx prisma generate
+npx prisma migrate deploy
+npm run auth:sync
+```
+
+The sync utility creates missing Supabase Auth users, confirms their emails, sets the configured demo password, and links their Supabase Auth IDs back to Prisma. Set `CLASS_PULSE_DEFAULT_PASSWORD` before running it if you do not want the demo default.
+
+## Environment
+
+Copy `.env.example` to `.env.local` and configure:
+
+```text
+DATABASE_URL
+NEXT_PUBLIC_SUPABASE_URL
+NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
+SUPABASE_SERVICE_ROLE_KEY
+NEXTAUTH_SECRET
+GOOGLE_SA_EMAIL
+GOOGLE_SA_PRIVATE_KEY
+```
+
+`SUPABASE_SERVICE_ROLE_KEY` is server-only and must never be exposed to client code.
+
+## Prisma
+
+Prisma migrations are the source of truth for the ClassPulse application schema. Run migrations with:
+
+```bash
+npx prisma migrate deploy
+```
+
+For local development:
+
+```bash
npm run dev
```
-## Google Sheets service account (needed for live sync)
-
-1. In Google Cloud Console: create a project → enable "Google Sheets API".
-2. Create a Service Account → generate a JSON key.
-3. Put `client_email` and `private_key` from that JSON into `.env`
- (`GOOGLE_SA_EMAIL`, `GOOGLE_SA_PRIVATE_KEY`).
-4. For every subject sheet, share it (Viewer access) with the service
- account's email — same as sharing with a person.
-5. Store each sheet's ID (from its URL) in the `SheetLink` table when a
- teacher/admin adds a subject.
-
-## What's included in this scaffold
-
-- Prisma schema for the full data model (multi-college, multi-department ready)
-- NextAuth credentials auth with bcrypt
-- Google Sheets fetch + parser (`lib/googleSheets.ts`)
-- Analysis engine (`lib/analysis.ts`) — ports the sheet's logic: attendance
- trend classification, KPI cards, tiering, top/bottom performer lists
-- API routes for class analysis and subject analysis (read from cache, or
- trigger a live resync)
-- Login page + dashboard (choose Class Analysis vs Subject Analysis) +
- class/subject analysis pages with charts
-
-## What you still need to do to go to production
-
-1. Run `npx prisma migrate dev` against a real Postgres instance.
-2. Add your college's actual class/section/subject/teacher data (via a seed
- script or a simple admin UI you build on top of the `Admin*` API routes).
-3. Share each Google Sheet with the service account.
-4. Set a strong `NEXTAUTH_SECRET`, deploy behind HTTPS (Vercel handles this).
-5. Decide on a sync strategy: manual "Sync now" button (included), and/or a
- scheduled job (Vercel Cron / GitHub Action) hitting `/api/analysis/sync`
- every N minutes for active subjects.
-6. Add row-level access control checks (included in API routes as
- `assertTeacherOwnsSubject`) — extend for admin/department-head roles.
+Prisma Studio:
+
+```bash
+npm run prisma:studio
+```
+
+## Google Sheets
+
+The Google Sheets service account is used only when a ClassPulse `SheetLink` is configured for a section or subject. Share the relevant sheet with the service account email and store its sheet ID in PostgreSQL through Prisma-backed application functionality.
+
+## Important rule
+
+Do not add a second application database or a second password store. New teachers, classes, subjects, assignments, class access, students, attendance sessions and analysis snapshots belong in the Prisma schema/database. Authentication belongs in Supabase Auth.
diff --git a/app/admin/classes/page.tsx b/app/admin/classes/page.tsx
deleted file mode 100644
index c9f3496..0000000
--- a/app/admin/classes/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import {redirect}from'next/navigation';import{currentProfile,serverClient}from'@/lib/server';import{Shell}from'@/components/shell';import{AdminForms}from'@/components/admin-forms';export default async function AdminClasses(){const p=await currentProfile();if(!p)redirect('/login');if(p.role!=='admin')redirect('/dashboard');const db=await serverClient();const[terms,classes,subjects,teachers]=await Promise.all(['academic_terms','classes','subjects','profiles'].map(async table=>(await db.from(table).select('*')).data??[]));return Classes & assignments Create core academic records, then assign approved teachers.
}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
deleted file mode 100644
index 37ea97f..0000000
--- a/app/admin/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import { redirect } from 'next/navigation';import Link from 'next/link';import{currentProfile,serverClient}from '@/lib/server';import{Shell}from '@/components/shell';
-export default async function Admin(){const p=await currentProfile();if(!p)redirect('/login');if(p.role!=='admin'||p.approval_status!=='approved')redirect('/dashboard');const db=await serverClient();const[{count:pending},{count:classes},{count:students}]=await Promise.all([db.from('profiles').select('*',{count:'exact',head:true}).eq('approval_status','pending'),db.from('classes').select('*',{count:'exact',head:true}),db.from('students').select('*',{count:'exact',head:true})]);return Administration {[['Pending teachers',pending,'/admin/teachers'],['Classes',classes,'/admin/classes'],['Students',students,'/admin/classes']].map(([name,count,href])=>
{name}
{count??0} )}
Manage teachers Internal weightages
}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx
deleted file mode 100644
index 1997d4f..0000000
--- a/app/admin/settings/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import{redirect}from'next/navigation';import{currentProfile,serverClient}from'@/lib/server';import{Shell}from'@/components/shell';import{WeightagesForm}from'@/components/weightages-form';export default async function Settings(){const p=await currentProfile();if(!p)redirect('/login');if(p.role!=='admin')redirect('/dashboard');const db=await serverClient();const[{data:terms},{data:existing}]=await Promise.all([db.from('academic_terms').select('*'),db.from('internal_weightages').select('*')]);return Internal mark settings
}
diff --git a/app/admin/teachers/page.tsx b/app/admin/teachers/page.tsx
deleted file mode 100644
index 3cb6ef4..0000000
--- a/app/admin/teachers/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import {redirect}from 'next/navigation';import{currentProfile,serverClient}from '@/lib/server';import{Shell}from '@/components/shell';import{TeacherAdmin}from '@/components/teacher-admin';export default async function Teachers(){const p=await currentProfile();if(!p)redirect('/login');if(p.role!=='admin')redirect('/dashboard');const db=await serverClient();const{data}=await db.from('profiles').select('*').order('created_at',{ascending:false});return Teachers & approvals Approve requests before assigning class responsibilities.
}
diff --git a/app/auth/signout/route.ts b/app/auth/signout/route.ts
deleted file mode 100644
index 44cd1ca..0000000
--- a/app/auth/signout/route.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-import { createServerClient } from '@supabase/ssr'; import { NextResponse } from 'next/server'; import { cookies } from 'next/headers';
-export async function POST(request:Request){const response=NextResponse.redirect(new URL('/',request.url));const store=cookies();const db=createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,{cookies:{getAll:()=>store.getAll(),setAll:(items)=>items.forEach(({name,value,options})=>response.cookies.set(name,value,options))}});await db.auth.signOut();return response;}
diff --git a/app/classes/[id]/page.tsx b/app/classes/[id]/page.tsx
deleted file mode 100644
index 53c6a21..0000000
--- a/app/classes/[id]/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import { redirect,notFound } from 'next/navigation'; import Link from 'next/link'; import { currentProfile,serverClient } from '@/lib/server'; import { Shell } from '@/components/shell'; import { AttendanceChart } from '@/components/attendance-chart'; import { mean,median } from '@/lib/utils';
-export default async function ClassPage({params}:{params:{id:string}}){const profile=await currentProfile();if(!profile)redirect('/login');if(profile.approval_status!=='approved')redirect('/pending');const db=await serverClient();const{data:classInfo}=await db.from('classes').select('*').eq('id',params.id).single();if(!classInfo)notFound();const [{data:students},{data:uploads},{data:assessments}]=await Promise.all([db.from('students').select('id,full_name,enrollment_number').eq('class_id',params.id),db.from('attendance_uploads').select('id,month,attendance_records(percentage)').eq('class_id',params.id),db.from('assessment_records').select('marks,maximum_marks,assessment_uploads!inner(class_id,assessment_type)').eq('assessment_uploads.class_id',params.id)]);const trends=(uploads??[]).map((u:any)=>({month:new Date(u.month).toLocaleString('default',{month:'short'}),percentage:mean((u.attendance_records??[]).map((r:any)=>Number(r.percentage)))}));const percentages=(uploads??[]).flatMap((u:any)=>(u.attendance_records??[]).map((r:any)=>Number(r.percentage)));const markPercent=(assessments??[]).map((r:any)=>Number(r.marks)/Number(r.maximum_marks)*100);const brackets=[['Below 30%',percentages.filter(p=>p<30).length],['30–50%',percentages.filter(p=>p>=30&&p<50).length],['50–75%',percentages.filter(p=>p>=50&&p<75).length],['75%+',percentages.filter(p=>p>=75).length]];return {classInfo.branch} · Year {classInfo.year} · {classInfo.section} Batch {classInfo.batch}
Upload data
Students
{students?.length??0} Attendance average
{mean(percentages).toFixed(1)}% Midsem mean / median
{mean(markPercent).toFixed(1)}% / {median(markPercent).toFixed(1)}% Attendance trend {trends.length?:No attendance uploads yet.
} Attendance brackets {brackets.map(([label,count])=>
)}
Students {students?.map(s=> {s.full_name} {s.enrollment_number} )}
}
diff --git a/app/classes/[id]/students/[studentId]/page.tsx b/app/classes/[id]/students/[studentId]/page.tsx
deleted file mode 100644
index 18bf954..0000000
--- a/app/classes/[id]/students/[studentId]/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import{redirect,notFound}from'next/navigation';import{currentProfile,serverClient}from'@/lib/server';import{Shell}from'@/components/shell';import{mean}from'@/lib/utils';export default async function Student({params}:{params:{id:string;studentId:string}}){const p=await currentProfile();if(!p)redirect('/login');if(p.approval_status!=='approved')redirect('/pending');const db=await serverClient();const{data:student}=await db.from('students').select('*').eq('id',params.studentId).eq('class_id',params.id).single();if(!student)notFound();const[{data:attendance},{data:records}]=await Promise.all([db.from('attendance_records').select('percentage,attendance_uploads!inner(month,class_id)').eq('student_id',student.id).eq('attendance_uploads.class_id',params.id),db.from('assessment_records').select('marks,maximum_marks,component_name,assessment_uploads!inner(class_id)').eq('student_id',student.id).eq('assessment_uploads.class_id',params.id)]);const attendanceAvg=mean((attendance??[]).map((r:any)=>Number(r.percentage)));const academic=mean((records??[]).map((r:any)=>Number(r.marks)/Number(r.maximum_marks)*100));const risk=attendanceAvg<50||academic<40?'High':attendanceAvg<75||academic<60?'Medium':'Low';return {student.full_name} {student.enrollment_number}
Attendance average
{attendanceAvg.toFixed(1)}% Academic average
{academic.toFixed(1)}% Assessment components {records?.map((r:any,i)=>
{r.component_name} {r.marks}/{r.maximum_marks}
)}
}
diff --git a/app/classes/[id]/upload/page.tsx b/app/classes/[id]/upload/page.tsx
deleted file mode 100644
index 9d41052..0000000
--- a/app/classes/[id]/upload/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import { redirect,notFound } from 'next/navigation'; import { currentProfile,serverClient } from '@/lib/server'; import { Shell } from '@/components/shell'; import { UploadWorkflow } from '@/components/upload-workflow';
-export default async function Upload({params}:{params:{id:string}}){const profile=await currentProfile();if(!profile)redirect('/login');if(profile.approval_status!=='approved')redirect('/pending');const db=await serverClient();const{data:classInfo}=await db.from('classes').select('id').eq('id',params.id).single();if(!classInfo)notFound();const{data}=await db.from('class_subjects').select('subjects(id,code,name)').eq('class_id',params.id);const subjects=(data??[]).map((x:any)=>x.subjects).filter(Boolean);return Upload class data Use the supplied template. Values are validated before structured records are saved.
{subjects.length?
:No subjects have been configured for this class.
} }
diff --git a/app/classes/new/page.tsx b/app/classes/new/page.tsx
deleted file mode 100644
index 61e5d6f..0000000
--- a/app/classes/new/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import { redirect } from 'next/navigation'; import { currentProfile } from '@/lib/server'; import { Shell } from '@/components/shell'; import { QuickClassForm } from '@/components/quick-class-form';
-export default async function NewClass(){const profile=await currentProfile();if(!profile)redirect('/login');if(profile.approval_status!=='approved')redirect('/pending');return }
diff --git a/app/classes/page.tsx b/app/classes/page.tsx
deleted file mode 100644
index 8e52625..0000000
--- a/app/classes/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import { redirect } from 'next/navigation'; import Link from 'next/link'; import { currentProfile,serverClient } from '@/lib/server'; import { Shell } from '@/components/shell'; import { DeleteClassButton } from '@/components/delete-class-button';
-export default async function Classes(){const profile=await currentProfile();if(!profile)redirect('/login');if(profile.approval_status!=='approved')redirect('/pending');const db=await serverClient();if(profile.role==='admin'){const{data}=await db.from('classes').select('id,branch,batch,year,section').order('branch');return All classes {data?.map((item:any)=>
{item.branch} · Year {item.year} Batch {item.batch}, section {item.section}
)}
}const{data}=await db.from('teacher_assignments').select('id, assignment_role, subjects(code,name), classes(id,branch,batch,year,section)').eq('teacher_id',profile.id);return Assigned classes {data?.map((row:any)=>
{row.classes.branch} · {row.classes.year} {row.assignment_role.replace('_',' ')}
Batch {row.classes.batch}, section {row.classes.section}
{row.subjects&&
{row.subjects.code} — {row.subjects.name}
})}
}
diff --git a/app/globals.css b/app/globals.css
deleted file mode 100644
index 12cbbb5..0000000
--- a/app/globals.css
+++ /dev/null
@@ -1,38 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer base {
- * { @apply border-slate-200; }
- body { color:#172033; background:linear-gradient(135deg,#fbfaf7 0%,#f4f1ea 46%,#edf2f7 100%); }
- input,select { @apply w-full rounded-xl border bg-white px-3 py-2 text-sm; border-color:#dfe3ea; box-shadow:0 1px 2px rgba(15,23,42,.025); }
- input:focus,select:focus { outline:none; border-color:#4f46e5; box-shadow:0 0 0 3px rgba(79,70,229,.09); }
-}
-
-@layer components {
- .card { @apply rounded-2xl border bg-white p-5; border-color:rgba(203,213,225,.75); box-shadow:0 12px 32px rgba(15,23,42,.045); }
- .btn { @apply inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium text-white disabled:opacity-50; background:linear-gradient(135deg,#251b62,#4737a6); box-shadow:0 8px 18px rgba(58,45,135,.14); }
- .btn-secondary { @apply inline-flex items-center justify-center rounded-xl border bg-white px-4 py-2 text-sm font-medium; border-color:#dfe3ea; }
-}
-
-.analysis-layout{display:grid;grid-template-columns:195px minmax(0,1fr);min-height:100vh}.analysis-sidebar{position:sticky;top:0;height:100vh;padding:22px 12px;border-right:1px solid rgba(203,213,225,.78);background:rgba(255,255,255,.58);backdrop-filter:blur(18px);display:flex;flex-direction:column}.analysis-brand{display:flex;align-items:center;gap:10px;padding:0 10px 24px;font-weight:700;font-size:16px}.analysis-brand__mark{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;color:#fff;background:linear-gradient(135deg,#251b62,#5544ba);box-shadow:0 8px 20px rgba(67,52,157,.22)}.analysis-side-nav{display:grid;gap:6px}.analysis-side-nav a{display:flex;align-items:center;gap:11px;padding:11px 12px;border-radius:10px;color:#667085;font-size:13px;font-weight:500;text-decoration:none;transition:.18s}.analysis-side-nav a:hover{background:rgba(79,70,229,.06);color:#2d246f}.analysis-side-nav a.is-active{color:#312783;background:linear-gradient(90deg,rgba(79,70,229,.12),rgba(79,70,229,.04));font-weight:650}.analysis-side-footer{margin-top:auto;border-top:1px solid rgba(203,213,225,.72);padding:16px 10px 0;color:#7b8798;font-size:11px}
-
-.analysis-page{min-width:0;padding:18px 28px 28px}.analysis-topbar{display:flex;align-items:center;justify-content:space-between;gap:18px;padding-bottom:16px}.analysis-title-row{display:flex;align-items:center;gap:10px}.analysis-title-row h1{font-size:24px;line-height:1.1;letter-spacing:-.035em;font-weight:750}.analysis-sync{font-size:11px;color:#7b8798;display:flex;align-items:center;gap:6px}.analysis-top-actions{display:flex;gap:10px}.analysis-primary{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:0;border-radius:9px;padding:10px 16px;background:linear-gradient(135deg,#241d58,#45369d);color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 18px rgba(49,39,120,.14)}.analysis-primary:disabled{opacity:.55}.analysis-raw-button{display:inline-flex;align-items:center;gap:.55rem;border:1px solid #dfe5ec;border-radius:.8rem;padding:.55rem .9rem;background:rgba(255,255,255,.8);color:#344054;font-size:.8rem;font-weight:600;box-shadow:0 4px 14px rgba(15,23,42,.04);transition:.15s ease}.analysis-raw-button:hover{background:#fff;transform:translateY(-1px);text-decoration:none}
-/* Attendance currently renders Raw Data in the top action slot; visually place that same link in the shared sidebar. */
-.analysis-top-actions>.analysis-raw-button{position:fixed;left:24px;top:174px;width:147px;min-height:42px;padding:11px 12px;border:0;border-radius:10px;background:transparent;box-shadow:none;color:#667085;font-size:13px;font-weight:500;z-index:20}.analysis-top-actions>.analysis-raw-button:hover{transform:none;background:rgba(79,70,229,.06);color:#2d246f}.analysis-top-actions>.analysis-raw-button span:first-child:before{content:"↓";display:inline-grid;place-items:center;width:18px;margin-right:9px;color:#667085;font-size:16px}.analysis-top-actions>.analysis-raw-button span:last-child{display:none}
-
-.subject-analysis-nav{margin-bottom:16px}.subject-analysis-nav__rail{display:flex;gap:4px;padding:5px 10px;border:1px solid rgba(203,213,225,.78);border-radius:14px;background:rgba(255,255,255,.5);box-shadow:0 8px 22px rgba(15,23,42,.035);overflow-x:auto}.subject-analysis-nav__item{display:flex;align-items:center;gap:9px;padding:10px 18px;border-radius:9px;color:#667085;font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent}.subject-analysis-nav__item:hover{background:#fff;color:#2d246f}.subject-analysis-nav__item.is-active{color:#3d2ea0;background:rgba(79,70,229,.08);border-bottom-color:#5141bd}
-
-.analysis-view-switch{display:flex;gap:8px;margin:12px 0 18px}.analysis-view-switch button{border:1px solid #dfe5ec;background:rgba(255,255,255,.68);color:#667085;border-radius:9px;padding:9px 16px;font-size:13px;font-weight:600}.analysis-view-switch button.is-active{background:linear-gradient(135deg,#251b62,#4637a3);color:#fff;border-color:transparent;box-shadow:0 8px 18px rgba(49,39,120,.13)}.analysis-hero{display:grid;grid-template-columns:minmax(0,1fr) repeat(3,220px);gap:16px;align-items:end;margin-bottom:18px}.analysis-hero-copy h2{font-size:22px;font-weight:700;letter-spacing:-.03em}.analysis-hero-copy p{margin-top:6px;font-size:13px;color:#667085}
-
-.analysis-metric{min-height:108px;padding:16px;border:1px solid rgba(203,213,225,.72);border-radius:16px;background:linear-gradient(145deg,rgba(255,255,255,.92),rgba(246,248,251,.78));box-shadow:0 10px 28px rgba(15,23,42,.045);display:flex;align-items:flex-start;gap:12px;overflow:hidden}.analysis-metric-icon{display:grid;place-items:center;flex:0 0 38px;width:38px;height:38px;border-radius:50%;background:rgba(79,70,229,.08);color:#4f46e5}.analysis-metric-content{min-width:0;display:flex;flex-direction:column;gap:4px}.analysis-metric-label{display:block;color:#475467;font-size:12px;line-height:1.35;font-weight:500;white-space:normal;overflow-wrap:anywhere}.analysis-metric-value-row{display:flex;align-items:baseline;gap:8px;min-width:0}.analysis-metric-value-row strong{font-size:24px;line-height:1.1;letter-spacing:-.04em;color:#172033;white-space:nowrap}.analysis-metric-value-row small{font-size:11px;white-space:nowrap}.analysis-metric-detail{display:block;color:#667085;font-size:11px;line-height:1.35;white-space:normal;overflow-wrap:anywhere}.analysis-metric .change-up{color:#15803d!important;font-weight:700}.analysis-metric .change-down{color:#dc2626!important;font-weight:700}
-
-.analysis-panel{border:1px solid rgba(203,213,225,.72);border-radius:16px;background:linear-gradient(145deg,rgba(255,255,255,.9),rgba(248,249,251,.74));box-shadow:0 12px 34px rgba(15,23,42,.045)}.analysis-settings{padding:20px;margin-bottom:18px}.analysis-settings h3{font-size:14px;font-weight:700}.analysis-settings-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;align-items:end;margin-top:14px}.analysis-settings label{display:block;margin-bottom:7px;font-size:11px;font-weight:600;color:#667085}.analysis-settings input,.analysis-settings select{height:40px;background:rgba(255,255,255,.76)}.analysis-note{display:flex;align-items:center;gap:8px;margin-top:12px;padding:10px 12px;border:1px solid rgba(147,197,253,.28);border-radius:9px;background:rgba(239,246,255,.52);color:#52637a;font-size:11px}
-.analysis-content-grid{display:grid;grid-template-columns:minmax(520px,1.08fr) minmax(400px,.92fr);gap:16px}.analysis-table-panel{padding:18px}.analysis-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.analysis-panel-head h3{font-size:14px;font-weight:700}.analysis-count{padding:4px 8px;border-radius:999px;background:#f1f3f7;color:#667085;font-size:10px}.analysis-table-wrap{max-height:500px;overflow:auto;scrollbar-gutter:stable}.analysis-table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}.analysis-table th{padding:10px 8px;text-align:left;color:#7b8798;font-size:10px;font-weight:700;border-bottom:1px solid #e9edf2}.analysis-table td{padding:10px 8px;border-bottom:1px solid #edf0f4;color:#566174}.analysis-table tr:hover td{background:rgba(79,70,229,.025)}.student-cell{display:flex;align-items:center;gap:9px;color:#172033!important;font-weight:500}.student-avatar{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#edf1ff;color:#4f46a8;font-size:9px;font-weight:700}.change-up{color:#15803d!important;font-weight:700}.change-down{color:#dc2626!important;font-weight:700}.trend-badge{display:inline-flex;padding:5px 8px;border-radius:999px;font-size:10px}.trend-up{background:#ecfdf3;color:#15803d}.trend-down{background:#fef2f2;color:#dc2626}.trend-stable{background:#f2f4f7;color:#667085}.analysis-right-stack{display:grid;gap:16px}.analysis-chart-panel{padding:18px}.analysis-chart-panel p{margin-top:4px;color:#98a2b3;font-size:10px}.analysis-chart{height:220px;margin-top:10px}.analysis-chart .recharts-cartesian-grid line{stroke:#e3e7ed}.analysis-chart .recharts-text{fill:#667085}.analysis-insight{display:flex;align-items:center;gap:14px;padding:16px 18px;margin-top:16px}.analysis-insight-copy h4{font-size:13px;font-weight:700}.analysis-insight-copy p{margin-top:4px;font-size:11px;color:#667085}.analysis-insight-actions{display:flex;gap:10px;margin-left:auto}.analysis-secondary{display:inline-flex;align-items:center;gap:8px;border:1px solid #dfe5ec;background:rgba(255,255,255,.8);color:#344054;border-radius:9px;padding:9px 14px;font-size:12px;font-weight:600}
-
-/* Academic dashboard */
-.academic-content-grid{align-items:start}.academic-table-wrap{height:620px;max-height:620px}.academic-stats-panel{padding:16px;display:grid;grid-template-columns:1fr 1.4fr;gap:14px}.academic-highest{padding:12px 14px;border:1px solid #edf0f4;border-radius:12px;background:rgba(255,255,255,.58);display:flex;flex-direction:column;gap:5px}.academic-highest span,.academic-tier-card span{font-size:11px;color:#667085}.academic-highest strong{font-size:26px;line-height:1;color:#172033}.academic-highest p{margin:0;color:#475467;font-size:12px;line-height:1.35}.academic-tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.academic-tier-card{min-height:70px;padding:10px 12px;border:1px solid;border-radius:12px;background:rgba(255,255,255,.52);display:flex;flex-direction:column;justify-content:space-between}.academic-tier-card strong{font-size:21px;line-height:1}.academic-rank-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.academic-rank-panel{padding:16px;min-width:0}.academic-rank-panel h3{font-size:14px;font-weight:700;margin-bottom:10px}.academic-rank-row{display:grid;grid-template-columns:24px minmax(0,1fr) 32px;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #edf0f4}.academic-rank-row:last-child{border-bottom:0}.academic-rank-row>span{font-size:11px;color:#98a2b3}.academic-rank-row p{margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#475467}.academic-rank-row strong{justify-self:end;font-size:13px;color:#172033}.academic-rank-row strong.is-negative{color:#dc2626}.academic-table-panel .analysis-table td:first-child{min-width:210px}.academic-table-panel .analysis-table th:not(:first-child),.academic-table-panel .analysis-table td:not(:first-child){text-align:center}.academic-table-panel .student-cell{white-space:nowrap}.academic-content-grid .analysis-chart{height:190px}
-
-.recharts-tooltip-wrapper .recharts-default-tooltip{border-radius:12px!important;border:1px solid #e2e8f0!important;box-shadow:0 12px 28px rgba(15,23,42,.1)!important;background:rgba(255,255,255,.97)!important}.analysis-stat-card,.analysis-ranked-list,.analysis-grade-badge{border-color:#e2e8f0}
-@media(max-width:1100px){.analysis-hero{grid-template-columns:1fr 1fr}.analysis-hero-copy{grid-column:1/-1}.analysis-content-grid{grid-template-columns:1fr}.analysis-settings-grid{grid-template-columns:1fr 1fr}.analysis-sidebar{position:fixed;transform:translateX(-100%)}.analysis-layout{grid-template-columns:1fr}.analysis-page{padding:18px}.academic-table-wrap{height:500px;max-height:500px}.analysis-top-actions>.analysis-raw-button{position:static;width:auto;min-height:0;border:1px solid #dfe5ec;background:rgba(255,255,255,.8);box-shadow:0 4px 14px rgba(15,23,42,.04)}.analysis-top-actions>.analysis-raw-button span:first-child:before{display:none}}
-@media(max-width:700px){.analysis-topbar{align-items:flex-start;flex-direction:column}.analysis-title-row h1{font-size:21px}.analysis-top-actions{width:100%}.analysis-settings-grid{grid-template-columns:1fr}.analysis-hero{grid-template-columns:1fr}.analysis-content-grid{grid-template-columns:1fr}.analysis-page{padding:14px}.analysis-insight{align-items:flex-start;flex-direction:column}.analysis-insight-actions{margin-left:0;flex-wrap:wrap}.subject-analysis-nav__item{padding:9px 12px}.analysis-chart{height:200px}.academic-stats-panel,.academic-rank-grid{grid-template-columns:1fr}.academic-tier-grid{grid-template-columns:1fr 1fr}.academic-table-wrap{height:440px;max-height:440px}}
diff --git a/app/layout.tsx b/app/layout.tsx
deleted file mode 100644
index cce6d0a..0000000
--- a/app/layout.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import './globals.css'; import type { Metadata } from 'next';
-export const metadata: Metadata = { title: 'ClassPulse', description: 'Secure academic insights for college teachers' };
-export default function Layout({ children }: { children: React.ReactNode }) { return
{children}; }
diff --git a/app/pending/page.tsx b/app/pending/page.tsx
deleted file mode 100644
index 9838f29..0000000
--- a/app/pending/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import Link from 'next/link'; export default function Pending(){return Approval pending Your registration is recorded. You will be able to access ClassPulse after an administrator approves and assigns your account.
Back to sign in }
diff --git a/app/register/page.tsx b/app/register/page.tsx
deleted file mode 100644
index fbf80c0..0000000
--- a/app/register/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-import { AuthForm } from '@/components/auth-form'; export default function Register(){return Request teacher access An administrator will review your college account.
}
diff --git a/components/AnalysisNav.tsx b/components/AnalysisNav.tsx
index e345a09..e373f89 100644
--- a/components/AnalysisNav.tsx
+++ b/components/AnalysisNav.tsx
@@ -1,51 +1,57 @@
import Link from "next/link";
import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
+import { BarChart3, FileText, GraduationCap, LayoutDashboard } from "lucide-react";
-type Props = {
- sectionId: string;
-};
+type Props = { sectionId: string };
+
+const items = [
+ { label: "Attendance", icon: BarChart3, path: "attendance" },
+ { label: "Academic", icon: GraduationCap, path: "academic" },
+ { label: "Overall", icon: LayoutDashboard, path: "overall" },
+ { label: "Student Report", icon: FileText, path: "students" },
+];
export default function AnalysisNav({ sectionId }: Props) {
const router = useRouter();
+ const pathname = router.pathname || "";
+ const asPath = (router.asPath || "").split("?")[0];
+ const [className, setClassName] = useState("");
- const items = [
- {
- label: "Attendance",
- href: `/section-analysis/${sectionId}/attendance`,
- },
- {
- label: "Academic",
- href: `/section-analysis/${sectionId}/academic`,
- },
- {
- label: "Overall",
- href: `/section-analysis/${sectionId}/overall`,
- },
- {
- label: "Student Report",
- href: `/section-analysis/${sectionId}/students`,
- },
- ];
+ useEffect(() => {
+ let cancelled = false;
+ fetch(`/api/analysis/section-info/${sectionId}`)
+ .then((response) => response.ok ? response.json() : null)
+ .then((json) => { if (!cancelled) setClassName(json?.className || ""); })
+ .catch(() => { if (!cancelled) setClassName(""); });
+ return () => { cancelled = true; };
+ }, [sectionId]);
return (
-
- {items.map((item) => {
- const active = router.asPath === item.href;
+ <>
+ {className && (
+
+ )}
+
+
+ {items.map(({ label, icon: Icon, path }) => {
+ const href = `/section-analysis/${sectionId}/${path}`;
+ const active =
+ pathname.endsWith(`/${path}`) ||
+ asPath.endsWith(`/${path}`) ||
+ (path === "overall" && pathname.includes("class-analysis-overall-heading-fixed"));
- return (
-
- {item.label}
-
- );
- })}
-
+ return (
+
+
+ {label}
+
+ );
+ })}
+
+
+ >
);
-}
\ No newline at end of file
+}
diff --git a/components/AnalysisWidgets.tsx b/components/AnalysisWidgets.tsx
index f343979..c62ae87 100644
--- a/components/AnalysisWidgets.tsx
+++ b/components/AnalysisWidgets.tsx
@@ -44,7 +44,16 @@ export function RawDataButton({ sheetId }: { sheetId: string | null }) {
return;
}
- if (!window.location.pathname.startsWith("/subject-analysis/")) return;
+ // Class Analysis uses its own sidebar markup, so attach Raw Data to the
+ // same navigation by locating its Class Analysis link.
+ const classAnalysisLink = document.querySelector('nav a[href="/class-analysis"]');
+ const classAnalysisNav = classAnalysisLink?.closest("nav") as HTMLElement | null;
+ if (classAnalysisNav) {
+ setSideNav(classAnalysisNav);
+ return;
+ }
+
+ if (!window.location.pathname.startsWith("/subject-analysis/") && !window.location.pathname.startsWith("/section-analysis/")) return;
const root = document.querySelector("#\\_\\_next > div.min-h-screen.max-w-\\[1900px\\]")
|| document.querySelector("div.min-h-screen.max-w-\\[1900px\\]")
@@ -78,7 +87,7 @@ export function RawDataButton({ sheetId }: { sheetId: string | null }) {
Dashboard
- Class Analysis
+ Class Analysis
Subject Analysis
{rawLink}
diff --git a/components/SubjectAnalysisNav.tsx b/components/SubjectAnalysisNav.tsx
index ebeda7b..abc212d 100644
--- a/components/SubjectAnalysisNav.tsx
+++ b/components/SubjectAnalysisNav.tsx
@@ -1,9 +1,12 @@
import Link from "next/link";
import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
import { BarChart3, FileText, GraduationCap, LayoutDashboard } from "lucide-react";
type Props = { subjectId: string };
+type SubjectInfo = { className: string; subjectName: string; subjectCode: string };
+
const items = [
{ label: "Attendance", icon: BarChart3, path: "attendance" },
{ label: "Academic", icon: GraduationCap, path: "academic" },
@@ -13,21 +16,46 @@ const items = [
export default function SubjectAnalysisNav({ subjectId }: Props) {
const router = useRouter();
+ const pathname = router.pathname || "";
+ const asPath = (router.asPath || "").split("?")[0];
+ const [info, setInfo] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch(`/api/analysis/subject-info/${subjectId}`)
+ .then((response) => response.ok ? response.json() : null)
+ .then((json) => { if (!cancelled) setInfo(json); })
+ .catch(() => { if (!cancelled) setInfo(null); });
+ return () => { cancelled = true; };
+ }, [subjectId]);
return (
-
-
- {items.map(({ label, icon: Icon, path }) => {
- const href = `/subject-analysis/${subjectId}/${path}`;
- const active = router.pathname.endsWith(`/${path}`);
- return (
-
-
- {label}
-
- );
- })}
-
-
+ <>
+ {info && (
+
+
{info.className}
+
{info.subjectName} ({info.subjectCode})
+
+ )}
+
+
+ {items.map(({ label, icon: Icon, path }) => {
+ const href = `/subject-analysis/${subjectId}/${path}`;
+ const active =
+ pathname.endsWith(`/${path}`) ||
+ asPath === href ||
+ (path === "attendance" && pathname.includes("subject-analysis-attendance-trend-fixed")) ||
+ (path === "academic" && pathname.includes("combined-analysis-fixed"));
+
+ return (
+
+
+ {label}
+
+ );
+ })}
+
+
+ >
);
}
diff --git a/components/admin-forms.tsx b/components/admin-forms.tsx
deleted file mode 100644
index 8e09517..0000000
--- a/components/admin-forms.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-'use client';
-import { FormEvent, useState } from 'react';
-import { createClient } from '@/lib/supabase';
-import { StudentRosterUpload } from './student-roster-upload';
-
-type Props = { terms:any[]; classes:any[]; subjects:any[]; teachers:any[] };
-export function AdminForms({terms,classes,subjects,teachers}:Props) {
- const [message,setMessage]=useState(''); const [step,setStep]=useState(1);
- async function save(table:string, form:HTMLFormElement, next:number) {
- const values=Object.fromEntries(new FormData(form));
- const payload=table==='teacher_assignments'?{...values,subject_id:values.subject_id||null}:values;
- const {error}=await createClient().from(table).insert(payload);
- if(error) { setMessage(error.message); return; }
- form.reset(); setMessage('Saved. The page will refresh now.'); setStep(next); window.setTimeout(()=>location.reload(),700);
- }
- const submit=(table:string,next:number)=>(event:FormEvent)=>{event.preventDefault(); void save(table,event.currentTarget,next)};
- return
-
Simple setup order: 1. Current semester → 2. Subject → 3. Student group → 4. Students. You can skip teacher assignment for now because you are already the administrator.
- {message&&
{message}
}
-
1. Add the current semester This is the teaching period, not the four-year batch. Example: academic year 2026-2027 , semester 7 .
- {(terms.length>0||step>=2)&&
2. Add a subject Example: code ECE 318T , name Artificial Intelligence .
}
- {(subjects.length>0||step>=3)&&
3. Add a student group (class) Example: ECE students who joined in 2023, are now in year 4, section A.
}
- {(classes.length>0||step>=4)&&
4. Link subject and upload students
}
-
-}
diff --git a/components/attendance-chart.tsx b/components/attendance-chart.tsx
deleted file mode 100644
index a215770..0000000
--- a/components/attendance-chart.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-'use client'; import { ResponsiveContainer, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts';
-export function AttendanceChart({data}:{data:{month:string;percentage:number}[]}) { return
; }
diff --git a/components/auth-form.tsx b/components/auth-form.tsx
deleted file mode 100644
index e894f93..0000000
--- a/components/auth-form.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-'use client';
-import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/lib/supabase';
-export function AuthForm({ register = false }: { register?: boolean }) { const [message,setMessage]=useState(''); const [busy,setBusy]=useState(false); const router=useRouter(); async function submit(form: FormData) { setBusy(true); setMessage(''); const email=String(form.get('email')).trim().toLowerCase(), password=String(form.get('password')), fullName=String(form.get('fullName')||'').trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setMessage('Enter a valid college email address.'); setBusy(false); return; } const supabase=createClient(); const result=register ? await supabase.auth.signUp({email,password,options:{data:{full_name:fullName},emailRedirectTo:`${location.origin}/login`}}) : await supabase.auth.signInWithPassword({email,password}); setBusy(false); if(result.error) return setMessage(result.error.message); router.push(register ? '/pending' : '/dashboard'); router.refresh(); }
-return ; }
diff --git a/components/quick-class-form.tsx b/components/quick-class-form.tsx
deleted file mode 100644
index 0928bdc..0000000
--- a/components/quick-class-form.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-'use client';
-import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase';
-const sessions=['2023-2027','2024-2028','2025-2029','2026-2030']; const branches=['CSE','IT','ECE','EEE'];
-const theory=['PME','UHV','OPSC','AI','ML','DA','SSMDA','NSS']; const labs=['OPSC Lab','AI Lab','ML Lab','DA Lab','SSMDA Lab'];
-export function QuickClassForm(){const[batch,setBatch]=useState('2023-2027'),[branch,setBranch]=useState('ECE'),[section,setSection]=useState('1'),[subject,setSubject]=useState(''),[role,setRole]=useState<'proctor'|'subject_teacher'>('subject_teacher'),[message,setMessage]=useState(''),[saving,setSaving]=useState(false);const router=useRouter();const sections={CSE:['1','2','3','Evening'],IT:['1','2','Evening'],ECE:['1','2','Evening'],EEE:[]}[branch];const enabled=batch==='2023-2027'&&branch==='ECE';const currentYear={'2023-2027':4,'2024-2028':3,'2025-2029':2,'2026-2030':1}[batch];async function submit(){if(!enabled||!subject)return;setSaving(true);const{error}=await createClient().rpc('create_my_class',{p_batch:batch,p_branch:branch,p_section:branch==='EEE'?'General':section,p_subject_code:subject,p_assignment_role:role});setSaving(false);if(error){setMessage(error.message);return}router.push('/classes');router.refresh()}return Add my class Choose your student group and teaching responsibility. Class year is calculated automatically for 2026.
BTech session{setBatch(e.target.value);setSubject('')}}>{sessions.map(x=>{x} )} Branch{setBranch(e.target.value);setSection(({CSE:'1',IT:'1',ECE:'1',EEE:'General'} as Record)[e.target.value]);setSubject('')}}>{branches.map(x=>{x} )} {branch!=='EEE'&&Section / shiftsetSection(e.target.value)}>{sections.map(x=>{x} )} }
For session {batch} , the current BTech year in 2026 is Year {currentYear} .{branch==='EEE'?' EEE has one general group.':''}
{enabled?<>SubjectsetSubject(e.target.value)}>Choose subject {theory.map(x=>{x} )} {labs.map(x=>{x} )} My rolesetRole(e.target.value as 'proctor'|'subject_teacher')}>Subject teacher Proctor >:The subject list is currently configured only for ECE 2023-2027, as requested. Choose that combination to continue.
}{message&&{message}
}void submit()} className="btn mt-5">{saving?'Creating class…':'Create my class'} }
diff --git a/components/shell.tsx b/components/shell.tsx
deleted file mode 100644
index de11b55..0000000
--- a/components/shell.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-import Link from 'next/link'; import { BarChart3, BookOpen, Plus, Settings, ShieldCheck } from 'lucide-react'; import type { Profile } from '@/lib/types';
-export function Shell({profile,children}:{profile:Profile;children:React.ReactNode}) { const links=[['/dashboard','Dashboard',BarChart3],['/classes','Classes',BookOpen],['/classes/new','Add class',Plus],...(profile.role==='admin'?[['/admin','Admin',ShieldCheck] as const]:[])]; return ClassPulse{links.map(([href,label,Icon])=>{label})}Account }
diff --git a/components/student-roster-upload.tsx b/components/student-roster-upload.tsx
deleted file mode 100644
index 9549a6c..0000000
--- a/components/student-roster-upload.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-'use client';
-import { useState } from 'react'; import * as XLSX from 'xlsx'; import { createClient } from '@/lib/supabase';
-type ClassItem={id:string;branch:string;batch:string;section:string}; type Row={ 'Enrollment Number':string; 'Student Name':string };
-const columns=['Enrollment Number','Student Name'];
-export function StudentRosterUpload({classes}:{classes:ClassItem[]}) { const [classId,setClassId]=useState(''); const [rows,setRows]=useState([]); const [errors,setErrors]=useState([]); const [message,setMessage]=useState(''); const [saving,setSaving]=useState(false);
- function download(){const sheet=XLSX.utils.json_to_sheet([{'Enrollment Number':'BT2023ECE001','Student Name':'Student name'}]);const book=XLSX.utils.book_new();XLSX.utils.book_append_sheet(book,sheet,'Students');XLSX.writeFile(book,'classpulse-student-roster-template.xlsx')}
- async function choose(file?:File){if(!file)return;setMessage('');const data=await file.arrayBuffer();const book=XLSX.read(data,{type:'array'});const sheet=book.Sheets[book.SheetNames[0]];const parsed=XLSX.utils.sheet_to_json>(sheet,{defval:''});const found=Object.keys(parsed[0]??{});const issues=columns.filter(c=>!found.includes(c)).map(c=>`Missing required column: ${c}`);const seen=new Set();const valid:Row[]=[];parsed.forEach((item,index)=>{const enrollment=String(item['Enrollment Number']??'').trim(),name=String(item['Student Name']??'').trim();if(!enrollment||!name)issues.push(`Row ${index+2}: enrollment number and student name are required.`);else if(seen.has(enrollment))issues.push(`Row ${index+2}: duplicate enrollment number ${enrollment}.`);else{seen.add(enrollment);valid.push({'Enrollment Number':enrollment,'Student Name':name})}});if(classId&&valid.length){const{data}=await createClient().from('students').select('enrollment_number').eq('class_id',classId);const existing=new Set((data??[]).map(x=>x.enrollment_number));valid.forEach((r,index)=>{if(existing.has(r['Enrollment Number']))issues.push(`Row ${index+2}: ${r['Enrollment Number']} is already in this class.`)})}setRows(valid);setErrors(issues)}
- async function save(){if(!classId||!rows.length||errors.length)return;setSaving(true);const {error}=await createClient().from('students').insert(rows.map(r=>({class_id:classId,enrollment_number:r['Enrollment Number'],full_name:r['Student Name']})));setSaving(false);setMessage(error?.message??`${rows.length} students saved. Refresh the page to see them.`);if(!error){setRows([])}}
- return Upload student roster from Excel Use the supplied two-column template. The Excel file stays in your browser; only checked student records are saved.
Student group{setClassId(e.target.value);setRows([]);setErrors([])}} required>Choose student group {classes.map(c=>{c.branch} · {c.batch} · Section {c.section} )} Download Excel template
Completed roster file void choose(e.target.files?.[0])}/> {rows.length>0&&
Ready to import: {rows.length} students.
}{errors.length>0&&
{errors.map((error,index)=>{error} )} }{message&&
{message}
}
void save()} className="btn mt-3">{saving?'Saving…':'Save roster'} }
diff --git a/components/teacher-admin.tsx b/components/teacher-admin.tsx
deleted file mode 100644
index ad8290d..0000000
--- a/components/teacher-admin.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-'use client'; import { useState } from 'react'; import { createClient } from '@/lib/supabase';
-export function TeacherAdmin({teachers}:{teachers:any[]}){const[items,setItems]=useState(teachers);const[message,setMessage]=useState('');async function update(id:string,approval_status:string,role:string){if(!confirm(`Save ${approval_status} status and ${role} role?`))return;const {error}=await createClient().from('profiles').update({approval_status,role}).eq('id',id);setMessage(error?.message??'Teacher updated.');if(!error)setItems(x=>x.map(t=>t.id===id?{...t,approval_status,role}:t));}return <>{message}
Teacher Status Role Action {items.map(t=>{t.full_name} {t.email}pending approved rejected subject_teacher proctor admin update(t.id,(document.getElementById(`status-${t.id}`) as HTMLSelectElement).value,(document.getElementById(`role-${t.id}`) as HTMLSelectElement).value)}>Save )}
>}
diff --git a/components/upload-workflow.tsx b/components/upload-workflow.tsx
deleted file mode 100644
index 888b49e..0000000
--- a/components/upload-workflow.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-'use client';
-import { useState } from 'react'; import * as XLSX from 'xlsx'; import { parseWorkbook,template,validateRows } from '@/lib/excel'; import type { ParsedRow,UploadKind } from '@/lib/types'; import { createClient } from '@/lib/supabase';
-export function UploadWorkflow({classId,subjects,userId}:{classId:string;subjects:{id:string;code:string;name:string}[];userId:string}){const[kind,setKind]=useState('attendance'),[subject,setSubject]=useState(subjects[0]?.id??''),[rows,setRows]=useState([]),[errors,setErrors]=useState([]),[month,setMonth]=useState(''),[saving,setSaving]=useState(false),[status,setStatus]=useState('');function download(){const sheet=XLSX.utils.json_to_sheet(template(kind));const book=XLSX.utils.book_new();XLSX.utils.book_append_sheet(book,sheet,kind);XLSX.writeFile(book,`classpulse-${kind}-template.xlsx`)}async function selectFile(file?:File){if(!file)return;const parsed=await parseWorkbook(file);const basic=validateRows(kind,parsed);const{data:students}=await createClient().from('students').select('enrollment_number').eq('class_id',classId);const enrolled=new Set((students??[]).map(s=>s.enrollment_number));const unknown=parsed.flatMap((r,i)=>enrolled.has(String(r['Enrollment Number']))?[]:[{row:i+2,field:'Enrollment Number',message:'Student is not enrolled in this class.'}]);setRows(parsed);setErrors([...basic,...unknown]);setStatus('')}async function save(){if(errors.length||!rows.length||!subject||(kind==='attendance'&&!month))return;setSaving(true);setStatus('');const db=createClient();if(kind==='attendance'){const{data:upload,error}=await db.from('attendance_uploads').insert({class_id:classId,subject_id:subject,month,uploaded_by:userId}).select('id').single();if(error){setStatus(error.message);setSaving(false);return}const{data:students}=await db.from('students').select('id,enrollment_number').eq('class_id',classId);const ids=new Map((students??[]).map(s=>[s.enrollment_number,s.id]));const records=rows.map(r=>({attendance_upload_id:upload.id,student_id:ids.get(String(r['Enrollment Number']))!,lectures_held:Number(r.LH),lectures_attended:Number(r.LA)}));const result=await db.from('attendance_records').insert(records);if(result.error)setStatus(result.error.message);else setStatus(`Processed ${records.length} attendance records.`)}else{const assessment_type=kind==='midsem'?'midsem':'internal';const{data:upload,error}=await db.from('assessment_uploads').insert({class_id:classId,subject_id:subject,assessment_type,uploaded_by:userId}).select('id').single();if(error){setStatus(error.message);setSaving(false);return}const{data:students}=await db.from('students').select('id,enrollment_number').eq('class_id',classId);const ids=new Map((students??[]).map(s=>[s.enrollment_number,s.id]));const records=rows.flatMap(r=>kind==='midsem'?[{assessment_upload_id:upload.id,student_id:ids.get(String(r['Enrollment Number']))!,component_name:'Midsem',marks:Number(r.Marks),maximum_marks:Number(r['Maximum Marks'])}]:[['Assignment','assignment'],['Presentation','presentation'],['Attendance','attendance'],['Midsem 1','midsem_1'],['Midsem 2','midsem_2']].map(([column,name])=>({assessment_upload_id:upload.id,student_id:ids.get(String(r['Enrollment Number']))!,component_name:name,marks:Number(r[column]),maximum_marks:100})));const result=await db.from('assessment_records').insert(records);if(result.error)setStatus(result.error.message);else setStatus(`Processed ${rows.length} student rows.`)}setSaving(false)}return {(['attendance','midsem','internal'] as UploadKind[]).map(x=>{setKind(x);setRows([]);setErrors([])}} className={kind===x?'btn':'btn-secondary'}>{x==='internal'?'Internal marks':x} )}
SubjectsetSubject(e.target.value)}>{subjects.map(s=>{s.code} — {s.name} )} {kind==='attendance'&&Month setMonth(e.target.value)}/> }Download template
Completed template selectFile(e.target.files?.[0])}/> {rows.length>0&&
Preview: {rows.length} rows parsed. {errors.length} validation issue(s).
}{errors.length>0&&
{errors.map((e,i)=>Row {e.row}, {e.field}: {e.message} )} }{status&&
{status}
}
{saving?'Saving processed records…':'Save validated records'} The workbook is parsed in your browser and is never uploaded or stored.
}
diff --git a/components/weightages-form.tsx b/components/weightages-form.tsx
deleted file mode 100644
index 980f9ce..0000000
--- a/components/weightages-form.tsx
+++ /dev/null
@@ -1 +0,0 @@
-'use client'; import {useState}from'react';import{createClient}from'@/lib/supabase';import{validateWeights}from'@/lib/internal-marks';export function WeightagesForm({terms,existing}:{terms:any[];existing:any[]}){const[msg,setMsg]=useState('');async function save(form:HTMLFormElement){const data=Object.fromEntries(new FormData(form));const weights=Object.fromEntries(Object.entries(data).filter(([k])=>k.endsWith('_weight')).map(([k,v])=>[k,Number(v)])) as any;if(!validateWeights(weights)){setMsg('Weights must total exactly 100.');return}const{error}=await createClient().from('internal_weightages').upsert({...data,...weights},{onConflict:'academic_term_id'});setMsg(error?.message??'Weightages saved.')}return Raw internal mark is the weighted component total. Moderation is a transparent capped multiplier applied in reporting.
}
diff --git a/lib/access.ts b/lib/access.ts
index 12eedaa..b5cfe13 100644
--- a/lib/access.ts
+++ b/lib/access.ts
@@ -12,44 +12,34 @@ export async function requireSession(req: NextApiRequest, res: NextApiResponse)
return session;
}
-// Admins can access anything in their college. Teachers only subjects
-// they're assigned to (via Assignment) or classes they proctor.
+// Admins can access anything in their college. Teachers can view a subject
+// only when they are the single teacher assigned to that subject.
export async function assertTeacherCanViewSubject(userId: string, role: string, subjectId: string) {
if (role === "ADMIN") return true;
- const assignment = await prisma.assignment.findFirst({
- where: { teacherId: userId, subjectId },
+ const assignment = await prisma.assignment.findUnique({
+ where: { subjectId },
});
- return !!assignment;
+ return assignment?.teacherId === userId;
}
+// Class-level access is separate from subject assignment. A teacher must be
+// explicitly granted ClassAccess for the whole class. Teaching one subject
+// no longer implicitly grants access to the complete class.
export async function assertTeacherCanViewClass(userId: string, role: string, classId: string) {
if (role === "ADMIN") return true;
- const cls = await prisma.class.findFirst({ where: { id: classId, proctorId: userId } });
- if (cls) return true;
- // or teaches at least one subject within this class's sections
- const teaches = await prisma.assignment.findFirst({
- where: {
- teacherId: userId,
- subject: { section: { classId } },
- },
+ const access = await prisma.classAccess.findUnique({
+ where: { teacherId_classId: { teacherId: userId, classId } },
});
- return !!teaches;
+ return !!access;
}
-// Section access: the class proctor, or anyone teaching a subject within
-// this specific section.
export async function assertTeacherCanViewSection(userId: string, role: string, sectionId: string) {
if (role === "ADMIN") return true;
- const section = await prisma.section.findUnique({ where: { id: sectionId } });
+ const section = await prisma.section.findUnique({ where: { id: sectionId }, select: { classId: true } });
if (!section) return false;
+ return assertTeacherCanViewClass(userId, role, section.classId);
+}
- const proctorMatch = await prisma.class.findFirst({
- where: { id: section.classId, proctorId: userId },
- });
- if (proctorMatch) return true;
-
- const teaches = await prisma.assignment.findFirst({
- where: { teacherId: userId, subject: { sectionId } },
- });
- return !!teaches;
+export async function assertTeacherCanViewOverall(userId: string, role: string, sectionId: string) {
+ return assertTeacherCanViewSection(userId, role, sectionId);
}
diff --git a/lib/authOptions.ts b/lib/authOptions.ts
index 4dffb17..af602c7 100644
--- a/lib/authOptions.ts
+++ b/lib/authOptions.ts
@@ -1,8 +1,57 @@
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
-import bcrypt from "bcryptjs";
+import { createClient } from "@supabase/supabase-js";
import { prisma } from "./prisma";
+function getSupabaseAuthClient() {
+ const rawUrl =
+ process.env.SUPABASE_URL?.trim() || process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
+ const publishableKey =
+ process.env.SUPABASE_PUBLISHABLE_KEY?.trim() ||
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY?.trim() ||
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
+
+ if (!rawUrl) {
+ throw new Error("Missing Supabase URL. Set SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL.");
+ }
+
+ if (!publishableKey) {
+ throw new Error(
+ "Missing Supabase publishable key. Set SUPABASE_PUBLISHABLE_KEY or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.",
+ );
+ }
+
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ throw new Error(`Invalid Supabase URL: ${rawUrl}`);
+ }
+
+ // Supabase clients expect the project root URL. Accept common API URLs
+ // copied from the dashboard and normalize them back to the project root.
+ const allowedApiPaths = ["/rest/v1", "/auth/v1", "/storage/v1", "/functions/v1"];
+ if (url.pathname !== "/" && url.pathname !== "") {
+ if (allowedApiPaths.some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`))) {
+ url.pathname = "/";
+ url.search = "";
+ url.hash = "";
+ } else {
+ throw new Error(
+ `Invalid Supabase URL path: ${url.pathname}. Use the project root URL, for example https://ayktccawcpxmhpauwqie.supabase.co`,
+ );
+ }
+ }
+
+ return createClient(url.toString().replace(/\/$/, ""), publishableKey, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ detectSessionInUrl: false,
+ },
+ });
+}
+
export const authOptions: NextAuthOptions = {
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
@@ -11,7 +60,7 @@ export const authOptions: NextAuthOptions = {
},
providers: [
CredentialsProvider({
- name: "Credentials",
+ name: "Supabase Auth",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
@@ -19,13 +68,34 @@ export const authOptions: NextAuthOptions = {
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
- const user = await prisma.user.findUnique({
- where: { email: credentials.email.toLowerCase() },
+ const email = credentials.email.trim().toLowerCase();
+ const supabaseAuth = getSupabaseAuthClient();
+ const { data, error } = await supabaseAuth.auth.signInWithPassword({
+ email,
+ password: credentials.password,
});
- if (!user) return null;
- const valid = await bcrypt.compare(credentials.password, user.passwordHash);
- if (!valid) return null;
+ if (error || !data.user?.id) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error("Supabase login failed:", error?.message ?? "No user returned");
+ }
+ return null;
+ }
+
+ const user = await prisma.user.findUnique({ where: { email } });
+ if (!user) {
+ if (process.env.NODE_ENV !== "production") {
+ console.error(`Supabase login succeeded but no ClassPulse user exists for ${email}`);
+ }
+ return null;
+ }
+
+ if (user.authUserId !== data.user.id) {
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { authUserId: data.user.id },
+ });
+ }
return {
id: user.id,
diff --git a/lib/googleSheetsAttendance.ts b/lib/googleSheetsAttendance.ts
new file mode 100644
index 0000000..797b0fb
--- /dev/null
+++ b/lib/googleSheetsAttendance.ts
@@ -0,0 +1,396 @@
+import { getSheetsClient } from "./googleSheetsClient";
+
+export interface AttendanceSheetStudent {
+ enrollmentNo: string;
+ present: boolean;
+}
+
+const TIME_SLOT_ORDER = [
+ "8 to 9",
+ "9 to 10",
+ "10 to 11",
+ "11 to 12",
+ "12.30 to 1.30",
+ "1.30 to 2.30",
+ "2.30 to 3.30",
+ "3.30 to 4.30",
+] as const;
+
+function cleanEnrollment(value: unknown): string {
+ let raw = String(value ?? "").trim().replace(/^'+/, "").replace(/\s+/g, "");
+ if (!raw) return "";
+ const scientific = raw.match(/^(\d+(?:\.\d+)?)e\+?(\d+)$/i);
+ if (scientific) {
+ const number = Number(raw);
+ if (Number.isFinite(number)) return Math.round(number).toString();
+ }
+ return raw.replace(/\.0$/, "");
+}
+
+function normalize(value: unknown): string {
+ return String(value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
+}
+
+function normalizeSubjectCode(value: unknown): string {
+ return String(value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
+}
+
+function subjectCodesMatch(a: unknown, b: unknown): boolean {
+ const left = normalizeSubjectCode(a);
+ const right = normalizeSubjectCode(b);
+ if (!left || !right) return false;
+ if (left === right) return true;
+ const token = (value: string) => value.trim().toUpperCase().split(/[\s(\[]+/)[0].replace(/[^A-Z0-9]/g, "");
+ return token(String(a ?? "")) === token(String(b ?? ""));
+}
+
+function columnName(index: number): string {
+ let n = index + 1;
+ let result = "";
+ while (n > 0) {
+ const remainder = (n - 1) % 26;
+ result = String.fromCharCode(65 + remainder) + result;
+ n = Math.floor((n - 1) / 26);
+ }
+ return result;
+}
+
+function findStudentHeader(rows: string[][]): number {
+ return rows.findIndex((row) => {
+ const a = normalize(row?.[0]);
+ const b = normalize(row?.[1]);
+ const c = normalize(row?.[2]);
+ return a === "s.no" && b.includes("enrollment") && c.includes("student");
+ });
+}
+
+function sessionHeaderMatches(value: unknown, date: string, slot: string): boolean {
+ const text = normalize(value);
+ const target = normalize(`${date} | ${slot}`);
+ return text === target || text === normalize(`${date} ${slot}`);
+}
+
+function sessionSortKey(value: unknown): string | null {
+ const text = String(value ?? "").trim();
+ const match = text.match(/^(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)$/);
+ if (!match) return null;
+ const slot = normalize(match[2]);
+ const slotIndex = TIME_SLOT_ORDER.findIndex((item) => normalize(item) === slot);
+ return `${match[1]}|${String(slotIndex === -1 ? 999 : slotIndex).padStart(3, "0")}|${slot}`;
+}
+
+function monthTabMatches(title: string, date: string): boolean {
+ const parsed = new Date(`${date}T00:00:00.000Z`);
+ if (Number.isNaN(parsed.getTime())) return false;
+ const month = parsed.toLocaleString("en-US", { month: "long", timeZone: "UTC" }).toLowerCase();
+ const shortMonth = month.slice(0, 3);
+ const year = parsed.getUTCFullYear().toString();
+ const lower = title.trim().toLowerCase();
+ return lower.includes(year) && (lower.includes(month) || lower.includes(shortMonth));
+}
+
+async function refreshLatestSessionMetadata(params: {
+ sheets: ReturnType;
+ spreadsheetId: string;
+ title: string;
+}): Promise {
+ const result = await params.sheets.spreadsheets.values.get({
+ spreadsheetId: params.spreadsheetId,
+ range: `'${params.title}'!A1:AZ500`,
+ valueRenderOption: "FORMATTED_VALUE",
+ });
+ const rows = result.data.values || [];
+ const headerRow = findStudentHeader(rows);
+ if (headerRow === -1) throw new Error(`TD-${params.title.replace(/^TD-/i, "")} does not have the expected student header`);
+ const subHeaderRow = headerRow + 1;
+ const maxColumns = Math.max(...rows.map((row) => row.length), 4);
+ let latestColumn = -1;
+ let latestSortKey: string | null = null;
+ for (let col = 3; col < maxColumns - 1; col++) {
+ if (normalize(rows[subHeaderRow]?.[col]) !== "lh" || normalize(rows[subHeaderRow]?.[col + 1]) !== "la") continue;
+ const key = sessionSortKey(rows[headerRow]?.[col]);
+ if (key && (latestSortKey === null || key > latestSortKey)) {
+ latestSortKey = key;
+ latestColumn = col;
+ }
+ }
+
+ if (latestColumn === -1) {
+ await params.sheets.spreadsheets.values.update({
+ spreadsheetId: params.spreadsheetId,
+ range: `'${params.title}'!B4:E4`,
+ valueInputOption: "RAW",
+ requestBody: { values: [["", "", "", ""]] },
+ });
+ return;
+ }
+
+ const latestHeader = String(rows[headerRow]?.[latestColumn] || "");
+ const latestKey = String(rows[headerRow - 1]?.[latestColumn] || "");
+ const separator = latestHeader.indexOf("|");
+ const latestDate = separator === -1 ? latestHeader : latestHeader.slice(0, separator).trim();
+ const latestSlot = separator === -1 ? "" : latestHeader.slice(separator + 1).trim();
+ await params.sheets.spreadsheets.values.update({
+ spreadsheetId: params.spreadsheetId,
+ range: `'${params.title}'!B4:E4`,
+ valueInputOption: "RAW",
+ requestBody: { values: [[latestDate, latestSlot, "Session ID", latestKey]] },
+ });
+}
+
+/**
+ * Rebuilds one subject's monthly LH/LA totals from every matching Teacher Diary
+ * session in that month. This makes the monthly sheet an aggregate of TD data:
+ * LH = number of sessions held; LA = number of sessions attended.
+ */
+export async function syncMonthlyAttendanceFromTeacherDiary(params: {
+ spreadsheetId: string;
+ subjectCode: string;
+ date: string;
+}): Promise<{ monthTitle: string; presentColumns: number; studentCount: number }> {
+ const sheets = getSheetsClient();
+ const metadata = await sheets.spreadsheets.get({
+ spreadsheetId: params.spreadsheetId,
+ fields: "sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))",
+ });
+ const sheetList = metadata.data.sheets || [];
+
+ const tdTarget = sheetList.find((sheet) => {
+ const title = sheet.properties?.title || "";
+ return title.toLowerCase().replace(/\s+/g, "") === `td-${params.subjectCode}`.toLowerCase().replace(/\s+/g, "");
+ });
+ if (!tdTarget?.properties?.title) throw new Error(`Teacher Diary sheet TD-${params.subjectCode} was not found`);
+
+ const monthTarget = sheetList.find((sheet) => monthTabMatches(sheet.properties?.title || "", params.date));
+ if (!monthTarget?.properties?.title) throw new Error(`Monthly attendance sheet for ${params.date.slice(0, 7)} was not found`);
+
+ const tdTitle = tdTarget.properties.title;
+ const monthTitle = monthTarget.properties.title;
+ const tdResult = await sheets.spreadsheets.values.get({
+ spreadsheetId: params.spreadsheetId,
+ range: `'${tdTitle}'!A1:AZ500`,
+ valueRenderOption: "FORMATTED_VALUE",
+ });
+ const monthResult = await sheets.spreadsheets.values.get({
+ spreadsheetId: params.spreadsheetId,
+ range: `'${monthTitle}'!A1:AZ500`,
+ valueRenderOption: "FORMATTED_VALUE",
+ });
+
+ const tdRows = tdResult.data.values || [];
+ const monthRows = monthResult.data.values || [];
+ const tdHeaderRow = findStudentHeader(tdRows);
+ if (tdHeaderRow === -1) throw new Error(`TD-${params.subjectCode} does not have the expected student header`);
+
+ const tdSubHeaderRow = tdHeaderRow + 1;
+ const tdStudentStart = tdHeaderRow + 2;
+ const monthHeaderRow = monthRows.findIndex((row) => {
+ for (let col = 0; col + 1 < row.length; col++) {
+ if (normalize(row[col]) === "lh" && normalize(row[col + 1]) === "la") return true;
+ }
+ return false;
+ });
+ if (monthHeaderRow === -1) throw new Error(`Monthly sheet ${monthTitle} does not have LH/LA attendance columns`);
+
+ const monthSubjectRow = monthHeaderRow - 1;
+ const monthParentSubjectRow = monthHeaderRow - 2;
+ let monthLhCol = -1;
+ for (let col = 0; col + 1 < (monthRows[monthHeaderRow]?.length || 0); col++) {
+ if (normalize(monthRows[monthHeaderRow]?.[col]) !== "lh" || normalize(monthRows[monthHeaderRow]?.[col + 1]) !== "la") continue;
+ const directCandidate = monthRows[monthSubjectRow]?.[col];
+ const parentCandidate = monthRows[monthParentSubjectRow]?.[col];
+ if (subjectCodesMatch(directCandidate, params.subjectCode) || subjectCodesMatch(parentCandidate, params.subjectCode)) {
+ monthLhCol = col;
+ break;
+ }
+ }
+ if (monthLhCol === -1) throw new Error(`Subject ${params.subjectCode} was not found in monthly sheet ${monthTitle}`);
+
+ const monthEnrollmentRows = new Map();
+ for (let rowIndex = monthHeaderRow + 1; rowIndex < monthRows.length; rowIndex++) {
+ const enrollmentNo = cleanEnrollment(monthRows[rowIndex]?.[1]);
+ if (/^\d+$/.test(enrollmentNo)) monthEnrollmentRows.set(enrollmentNo, rowIndex);
+ }
+
+ const [targetYear, targetMonth] = params.date.slice(0, 7).split("-").map(Number);
+ const totals = new Map();
+ const tdMaxColumns = Math.max(...tdRows.map((row) => row.length), 4);
+
+ for (let col = 3; col < tdMaxColumns - 1; col++) {
+ if (normalize(tdRows[tdSubHeaderRow]?.[col]) !== "lh" || normalize(tdRows[tdSubHeaderRow]?.[col + 1]) !== "la") continue;
+ const header = String(tdRows[tdHeaderRow]?.[col] || "").trim();
+ const match = header.match(/^(\d{4})-(\d{2})-(\d{2})\s*\|/);
+ if (!match) continue;
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ if (year !== targetYear || month !== targetMonth) continue;
+
+ for (let rowIndex = tdStudentStart; rowIndex < tdRows.length; rowIndex++) {
+ const enrollmentNo = cleanEnrollment(tdRows[rowIndex]?.[1]);
+ if (!/^\d+$/.test(enrollmentNo)) continue;
+ const current = totals.get(enrollmentNo) || { lh: 0, la: 0 };
+ current.lh += Number(tdRows[rowIndex]?.[col] || 0) || 0;
+ current.la += Number(tdRows[rowIndex]?.[col + 1] || 0) || 0;
+ totals.set(enrollmentNo, current);
+ }
+ }
+
+ const writeRanges: Array<{ range: string; values: number[][] }> = [];
+ for (const [enrollmentNo, rowIndex] of monthEnrollmentRows.entries()) {
+ const total = totals.get(enrollmentNo) || { lh: 0, la: 0 };
+ const rowNumber = rowIndex + 1;
+ writeRanges.push({ range: `'${monthTitle}'!${columnName(monthLhCol)}${rowNumber}`, values: [[total.lh]] });
+ writeRanges.push({ range: `'${monthTitle}'!${columnName(monthLhCol + 1)}${rowNumber}`, values: [[total.la]] });
+ }
+
+ if (writeRanges.length > 0) {
+ await sheets.spreadsheets.values.batchUpdate({
+ spreadsheetId: params.spreadsheetId,
+ requestBody: { valueInputOption: "RAW", data: writeRanges },
+ });
+ }
+
+ return { monthTitle, presentColumns: writeRanges.length, studentCount: monthEnrollmentRows.size };
+}
+
+export async function writeTeacherDiaryAttendance(params: {
+ spreadsheetId: string;
+ subjectCode: string;
+ subjectName: string;
+ classLabel: string;
+ teacherName: string;
+ date: string;
+ slot: string;
+ sessionKey: string;
+ students: AttendanceSheetStudent[];
+}): Promise<{ sheetTitle: string; startColumn: number; present: number; total: number }> {
+ const sheets = getSheetsClient();
+ const metadata = await sheets.spreadsheets.get({
+ spreadsheetId: params.spreadsheetId,
+ fields: "sheets(properties(sheetId,title,gridProperties(columnCount,rowCount)))",
+ });
+ const sheetsList = metadata.data.sheets || [];
+ const normalizedTarget = `td-${params.subjectCode}`.toLowerCase().replace(/\s+/g, "");
+ const target = sheetsList.find((sheet) => {
+ const title = sheet.properties?.title || "";
+ return title.toLowerCase().replace(/\s+/g, "") === normalizedTarget;
+ });
+ const sheetId = target?.properties?.sheetId;
+ if (!target?.properties?.title || typeof sheetId !== "number") throw new Error(`Teacher Diary sheet TD-${params.subjectCode} was not found in the linked Google Sheet`);
+ const title = target.properties.title;
+ const result = await sheets.spreadsheets.values.get({ spreadsheetId: params.spreadsheetId, range: `'${title}'!A1:AZ500`, valueRenderOption: "FORMATTED_VALUE" });
+ const rows = result.data.values || [];
+ const headerRow = findStudentHeader(rows);
+ if (headerRow === -1) throw new Error(`TD-${params.subjectCode} does not have the expected S.No / Enrollment No. / Student Name header`);
+ const attendanceSubHeaderRow = headerRow + 1;
+ const studentStartRow = headerRow + 2;
+ const enrollmentRows = new Map();
+ for (let rowIndex = studentStartRow; rowIndex < rows.length; rowIndex++) {
+ const enrollmentNo = cleanEnrollment(rows[rowIndex]?.[1]);
+ if (enrollmentNo) enrollmentRows.set(enrollmentNo, rowIndex);
+ }
+ const incoming = new Map();
+ for (const student of params.students) {
+ const enrollmentNo = cleanEnrollment(student.enrollmentNo);
+ if (enrollmentNo) incoming.set(enrollmentNo, student.present);
+ }
+ if (incoming.size !== params.students.length) throw new Error(`Attendance contains a student with an invalid enrollment number`);
+ for (const enrollmentNo of incoming.keys()) if (!enrollmentRows.has(enrollmentNo)) throw new Error(`Student ${enrollmentNo} is missing from TD-${params.subjectCode}; attendance was not written`);
+
+ let startColumn = -1;
+ const maxColumns = Math.max(...rows.map((row) => row.length), 4);
+ let lastSessionEnd = 2;
+ let insertionColumn = -1;
+ const slotIndex = TIME_SLOT_ORDER.findIndex((item) => normalize(item) === normalize(params.slot));
+ const newSortKey = `${params.date}|${String(slotIndex === -1 ? 999 : slotIndex).padStart(3, "0")}|${normalize(params.slot)}`;
+
+ for (let col = 3; col < maxColumns - 1; col++) {
+ if (sessionHeaderMatches(rows[headerRow]?.[col], params.date, params.slot) && normalize(rows[attendanceSubHeaderRow]?.[col]) === "lh" && normalize(rows[attendanceSubHeaderRow]?.[col + 1]) === "la") {
+ startColumn = col;
+ break;
+ }
+ if (normalize(rows[attendanceSubHeaderRow]?.[col]) === "lh" && normalize(rows[attendanceSubHeaderRow]?.[col + 1]) === "la") {
+ lastSessionEnd = col + 1;
+ const existingSortKey = sessionSortKey(rows[headerRow]?.[col]);
+ if (insertionColumn === -1 && existingSortKey && newSortKey < existingSortKey) insertionColumn = col;
+ }
+ }
+
+ if (startColumn === -1) {
+ if (insertionColumn !== -1) {
+ startColumn = insertionColumn;
+ await sheets.spreadsheets.batchUpdate({
+ spreadsheetId: params.spreadsheetId,
+ requestBody: {
+ requests: [{
+ insertDimension: {
+ range: { sheetId, dimension: "COLUMNS", startIndex: startColumn, endIndex: startColumn + 3 },
+ inheritFromBefore: startColumn > 3,
+ },
+ }],
+ },
+ });
+ } else {
+ startColumn = lastSessionEnd === 2 ? 3 : lastSessionEnd + 2;
+ }
+ }
+
+ const requiredColumnCount = startColumn + 2;
+ const currentColumnCount = target.properties.gridProperties?.columnCount || 26;
+ if (requiredColumnCount > currentColumnCount) {
+ await sheets.spreadsheets.batchUpdate({ spreadsheetId: params.spreadsheetId, requestBody: { requests: [{ appendDimension: { sheetId, dimension: "COLUMNS", length: requiredColumnCount - currentColumnCount } }] } });
+ }
+
+ const startCol = columnName(startColumn);
+ const endCol = columnName(startColumn + 1);
+ await sheets.spreadsheets.values.update({ spreadsheetId: params.spreadsheetId, range: `'${title}'!${startCol}6:${endCol}8`, valueInputOption: "RAW", requestBody: { values: [[params.sessionKey, ""], [`${params.date} | ${params.slot}`, ""], ["LH", "LA"]] } });
+
+ const writeRanges: Array<{ range: string; values: number[][] }> = [];
+ for (const [enrollmentNo, rowIndex] of enrollmentRows.entries()) {
+ if (!incoming.has(enrollmentNo)) continue;
+ const rowNumber = rowIndex + 1;
+ writeRanges.push({ range: `'${title}'!${startCol}${rowNumber}`, values: [[1]] });
+ writeRanges.push({ range: `'${title}'!${endCol}${rowNumber}`, values: [[incoming.get(enrollmentNo) ? 1 : 0]] });
+ }
+ await sheets.spreadsheets.values.batchUpdate({ spreadsheetId: params.spreadsheetId, requestBody: { valueInputOption: "RAW", data: writeRanges } });
+ await sheets.spreadsheets.values.batchUpdate({ spreadsheetId: params.spreadsheetId, requestBody: { valueInputOption: "RAW", data: [{ range: `'${title}'!A1:H5`, values: [[`TEACHER DIARY — ${params.subjectName}`, "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", ""], ["Class", params.classLabel, "", "Subject", params.subjectName, "", "Teacher", params.teacherName], ["Latest Session", "", "", "Session ID", "", "", "", ""], ["Attendance is recorded below by date and time slot.", "", "", "", "", "", "", ""]] }] } });
+ await refreshLatestSessionMetadata({ sheets, spreadsheetId: params.spreadsheetId, title });
+
+ await syncMonthlyAttendanceFromTeacherDiary({ spreadsheetId: params.spreadsheetId, subjectCode: params.subjectCode, date: params.date });
+
+ return { sheetTitle: title, startColumn, present: params.students.filter((student) => student.present).length, total: params.students.length };
+}
+
+export async function deleteTeacherDiaryAttendance(params: {
+ spreadsheetId: string;
+ subjectCode: string;
+ date: string;
+ slot: string;
+}): Promise<{ sheetTitle: string; startColumn: number }> {
+ const sheets = getSheetsClient();
+ const metadata = await sheets.spreadsheets.get({ spreadsheetId: params.spreadsheetId, fields: "sheets(properties(sheetId,title,gridProperties(columnCount,rowCount)))" });
+ const normalizedTarget = `td-${params.subjectCode}`.toLowerCase().replace(/\s+/g, "");
+ const target = (metadata.data.sheets || []).find((sheet) => (sheet.properties?.title || "").toLowerCase().replace(/\s+/g, "") === normalizedTarget);
+ const sheetId = target?.properties?.sheetId;
+ const title = target?.properties?.title;
+ if (!title || typeof sheetId !== "number") throw new Error(`Teacher Diary sheet TD-${params.subjectCode} was not found in the linked Google Sheet`);
+ const result = await sheets.spreadsheets.values.get({ spreadsheetId: params.spreadsheetId, range: `'${title}'!A1:AZ500`, valueRenderOption: "FORMATTED_VALUE" });
+ const rows = result.data.values || [];
+ const headerRow = findStudentHeader(rows);
+ if (headerRow === -1) throw new Error(`TD-${params.subjectCode} does not have the expected S.No / Enrollment No. / Student Name header`);
+ const attendanceSubHeaderRow = headerRow + 1;
+ const maxColumns = Math.max(...rows.map((row) => row.length), 4);
+ let startColumn = -1;
+ for (let col = 3; col < maxColumns - 1; col++) {
+ if (sessionHeaderMatches(rows[headerRow]?.[col], params.date, params.slot) && normalize(rows[attendanceSubHeaderRow]?.[col]) === "lh" && normalize(rows[attendanceSubHeaderRow]?.[col + 1]) === "la") { startColumn = col; break; }
+ }
+
+ if (startColumn !== -1) {
+ await sheets.spreadsheets.batchUpdate({ spreadsheetId: params.spreadsheetId, requestBody: { requests: [{ deleteDimension: { range: { sheetId, dimension: "COLUMNS", startIndex: startColumn, endIndex: startColumn + 2 } } }] } });
+ }
+
+ await refreshLatestSessionMetadata({ sheets, spreadsheetId: params.spreadsheetId, title });
+
+ await syncMonthlyAttendanceFromTeacherDiary({ spreadsheetId: params.spreadsheetId, subjectCode: params.subjectCode, date: params.date });
+ return { sheetTitle: title, startColumn };
+}
diff --git a/lib/googleSheetsAttendanceAgent.ts b/lib/googleSheetsAttendanceAgent.ts
new file mode 100644
index 0000000..3ce7f88
--- /dev/null
+++ b/lib/googleSheetsAttendanceAgent.ts
@@ -0,0 +1,120 @@
+import { getSheetsClient } from "./googleSheetsClient";
+
+const TIME_SLOT_ORDER = [
+ "8 to 9",
+ "9 to 10",
+ "10 to 11",
+ "11 to 12",
+ "12.30 to 1.30",
+ "1.30 to 2.30",
+ "2.30 to 3.30",
+ "3.30 to 4.30",
+] as const;
+
+export interface TeacherDiarySession {
+ id: string;
+ subjectCode: string;
+ slot: string;
+ date: string;
+ teacherName: string;
+ presentEnrollmentNos: string[];
+}
+
+function normalize(value: unknown): string {
+ return String(value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
+}
+
+function cleanEnrollment(value: unknown): string {
+ let raw = String(value ?? "").trim().replace(/^'+/, "").replace(/\s+/g, "");
+ if (!raw) return "";
+ const scientific = raw.match(/^(\d+(?:\.\d+)?)e\+?(\d+)$/i);
+ if (scientific) {
+ const number = Number(raw);
+ if (Number.isFinite(number)) return Math.round(number).toString();
+ }
+ return raw.replace(/\.0$/, "");
+}
+
+function findStudentHeader(rows: string[][]): number {
+ return rows.findIndex((row) => normalize(row?.[0]) === "s.no" && normalize(row?.[1]).includes("enrollment") && normalize(row?.[2]).includes("student"));
+}
+
+function sessionSortKey(session: TeacherDiarySession): string {
+ const index = TIME_SLOT_ORDER.findIndex((slot) => normalize(slot) === normalize(session.slot));
+ return `${session.date}|${String(index === -1 ? 999 : index).padStart(3, "0")}|${normalize(session.slot)}`;
+}
+
+function readTeacherName(rows: string[][]): string {
+ const row = rows[2] || [];
+ for (let i = 0; i < row.length - 1; i++) {
+ if (normalize(row[i]) === "teacher") return String(row[i + 1] || "Teacher").trim() || "Teacher";
+ }
+ return "Teacher";
+}
+
+async function readSubjectSessions(spreadsheetId: string, subjectCode: string, date?: string): Promise {
+ const sheets = getSheetsClient();
+ const metadata = await sheets.spreadsheets.get({
+ spreadsheetId,
+ fields: "sheets(properties(title))",
+ });
+ const target = (metadata.data.sheets || []).find((sheet) => {
+ const title = sheet.properties?.title || "";
+ return title.toLowerCase().replace(/\s+/g, "") === `td-${subjectCode}`.toLowerCase().replace(/\s+/g, "");
+ });
+ if (!target?.properties?.title) return [];
+
+ const title = target.properties.title;
+ const result = await sheets.spreadsheets.values.get({
+ spreadsheetId,
+ range: `'${title}'!A1:AZ500`,
+ valueRenderOption: "FORMATTED_VALUE",
+ });
+ const rows = result.data.values || [];
+ const headerRow = findStudentHeader(rows);
+ if (headerRow === -1) return [];
+
+ const subHeaderRow = headerRow + 1;
+ const studentStartRow = headerRow + 2;
+ const maxColumns = Math.max(...rows.map((row) => row.length), 4);
+ const teacherName = readTeacherName(rows);
+ const sessions: TeacherDiarySession[] = [];
+
+ for (let col = 3; col < maxColumns - 1; col++) {
+ if (normalize(rows[subHeaderRow]?.[col]) !== "lh" || normalize(rows[subHeaderRow]?.[col + 1]) !== "la") continue;
+ const header = String(rows[headerRow]?.[col] || "").trim();
+ const match = header.match(/^(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)$/);
+ if (!match) continue;
+ if (date && match[1] !== date) continue;
+
+ const presentEnrollmentNos: string[] = [];
+ for (let rowIndex = studentStartRow; rowIndex < rows.length; rowIndex++) {
+ const enrollmentNo = cleanEnrollment(rows[rowIndex]?.[1]);
+ if (!/^\d+$/.test(enrollmentNo)) continue;
+ const la = Number(rows[rowIndex]?.[col + 1] || 0) || 0;
+ if (la > 0) presentEnrollmentNos.push(enrollmentNo);
+ }
+
+ const sessionKey = String(rows[headerRow - 1]?.[col] || "").trim();
+ const fallbackId = `ATT-${match[1].replace(/-/g, "")}-${subjectCode.replace(/[^a-z0-9]/gi, "").toUpperCase()}-${match[2].replace(/[^a-z0-9]+/gi, "-").toUpperCase()}`;
+ sessions.push({
+ id: sessionKey || fallbackId,
+ subjectCode,
+ date: match[1],
+ slot: match[2].trim(),
+ teacherName,
+ presentEnrollmentNos,
+ });
+ }
+
+ return sessions.sort((a, b) => sessionSortKey(a).localeCompare(sessionSortKey(b)));
+}
+
+export async function readTeacherDiarySessions(params: {
+ spreadsheetId: string;
+ subjectCodes: string[];
+ date?: string;
+}): Promise {
+ const results = await Promise.all(params.subjectCodes.map((code) => readSubjectSessions(params.spreadsheetId, code, params.date)));
+ return results.flat().sort((a, b) => sessionSortKey(a).localeCompare(sessionSortKey(b)));
+}
diff --git a/lib/googleSheetsClient.ts b/lib/googleSheetsClient.ts
index dfd6dc4..a87a1a4 100644
--- a/lib/googleSheetsClient.ts
+++ b/lib/googleSheetsClient.ts
@@ -1,12 +1,12 @@
import { google } from "googleapis";
-// Auth via service account. Share each sheet (Viewer) with GOOGLE_SA_EMAIL
-// for this to work — either a subject's sheet or a section's combined sheet.
+// Auth via service account. The linked spreadsheets must grant this service
+// account Editor access for the Attendance Agent to write Teacher Diary data.
export function getSheetsClient() {
const auth = new google.auth.JWT({
email: process.env.GOOGLE_SA_EMAIL,
key: (process.env.GOOGLE_SA_PRIVATE_KEY || "").replace(/\\n/g, "\n"),
- scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"],
+ scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
return google.sheets({ version: "v4", auth });
}
diff --git a/lib/internal-marks.ts b/lib/internal-marks.ts
deleted file mode 100644
index 02fda5c..0000000
--- a/lib/internal-marks.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { Weightages } from './types';
-export type InternalComponents = { assignment: number; presentation: number; attendance: number; midsem1: number; midsem2: number };
-export function validateWeights(weights: Weightages) { const total = Object.values(weights).reduce((sum, value) => sum + value, 0); return Math.abs(total - 100) < 0.001; }
-export function weightedInternal(components: InternalComponents, weights: Weightages) {
- const raw = components.assignment * weights.assignment_weight / 100 + components.presentation * weights.presentation_weight / 100 + components.attendance * weights.attendance_weight / 100 + components.midsem1 * weights.midsem_1_weight / 100 + components.midsem2 * weights.midsem_2_weight / 100;
- return Math.round(raw * 100) / 100;
-}
-export function moderatedInternal(raw: number, factor = 1, cap = 40) { return Math.min(cap, Math.round(raw * factor * 100) / 100); }
-export function riskLevel(attendancePercent: number, internalMark: number) { if (attendancePercent < 50 || internalMark < 10) return 'high'; if (attendancePercent < 75 || internalMark < 20) return 'medium'; return 'low'; }
diff --git a/lib/server.ts b/lib/server.ts
deleted file mode 100644
index c0134d0..0000000
--- a/lib/server.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers';
-const publicKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-export async function serverClient() { const store=cookies(); return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,publicKey,{cookies:{getAll:()=>store.getAll(),setAll:()=>{}}}); }
-export async function currentProfile() { const client=await serverClient(); const {data:{user}}=await client.auth.getUser(); if(!user) return null; const {data}=await client.from('profiles').select('*').eq('id',user.id).single(); return data; }
diff --git a/lib/supabase.ts b/lib/supabase.ts
index 9db4030..5bcab1d 100644
--- a/lib/supabase.ts
+++ b/lib/supabase.ts
@@ -1,3 +1,7 @@
-import { createBrowserClient } from '@supabase/ssr';
-const publicKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-export const createClient = () => createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, publicKey);
+import { createBrowserClient } from "@supabase/ssr";
+
+export const createClient = () =>
+ createBrowserClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
+ );
diff --git a/lib/types.ts b/lib/types.ts
deleted file mode 100644
index 1c10cef..0000000
--- a/lib/types.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export type Role = 'admin' | 'proctor' | 'subject_teacher';
-export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
-export type Profile = { id: string; full_name: string; email: string; role: Role; approval_status: ApprovalStatus };
-export type UploadKind = 'attendance' | 'midsem' | 'internal';
-export type RowError = { row: number; field: string; message: string };
-export type ParsedRow = Record;
-export type Weightages = { assignment_weight: number; presentation_weight: number; attendance_weight: number; midsem_1_weight: number; midsem_2_weight: number };
diff --git a/middleware.ts b/middleware.ts
index 19fcaf6..161ead0 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -4,26 +4,69 @@ import { NextResponse, type NextRequest } from 'next/server';
const protectedPaths = ['/dashboard', '/classes', '/admin'];
export async function middleware(request: NextRequest) {
- const isProtected = protectedPaths.some((path) =>
- request.nextUrl.pathname.startsWith(path)
- );
+ const pathname = request.nextUrl.pathname;
+ const isProtected = protectedPaths.some((path) => pathname.startsWith(path));
- if (!isProtected) return NextResponse.next();
+ if (isProtected) {
+ const token = await getToken({
+ req: request,
+ secret: process.env.NEXTAUTH_SECRET,
+ });
- const token = await getToken({
- req: request,
- secret: process.env.NEXTAUTH_SECRET,
- });
+ if (!token) {
+ const loginUrl = new URL('/login', request.url);
+ loginUrl.searchParams.set('callbackUrl', pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+ }
+
+ const subjectAcademicMatch = pathname.match(/^\/subject-analysis\/([^/]+)\/academic$/);
+ if (subjectAcademicMatch) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/combined-analysis-fixed/${subjectAcademicMatch[1]}`;
+ return NextResponse.rewrite(url);
+ }
+
+ const subjectAttendanceTrendMatch = pathname.match(/^\/subject-analysis\/([^/]+)\/attendance$/);
+ if (subjectAttendanceTrendMatch) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/subject-analysis-attendance-trend-fixed/${subjectAttendanceTrendMatch[1]}`;
+ return NextResponse.rewrite(url);
+ }
+
+ const studentReportMatch = pathname.match(/^\/section-analysis\/([^/]+)\/students$/);
+ if (studentReportMatch) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/class-analysis-student-shell-fixed/${studentReportMatch[1]}`;
+ return NextResponse.rewrite(url);
+ }
+
+ const overallMatch = pathname.match(/^\/section-analysis\/([^/]+)\/overall$/);
+ if (overallMatch) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/class-analysis-overall-heading-fixed/${overallMatch[1]}`;
+ return NextResponse.rewrite(url);
+ }
- if (!token) {
- const loginUrl = new URL('/login', request.url);
- loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);
- return NextResponse.redirect(loginUrl);
+ const attendanceTrendMatch = pathname.match(/^\/section-analysis\/([^/]+)\/attendance$/);
+ if (attendanceTrendMatch) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/class-analysis-attendance-trend-fixed/${attendanceTrendMatch[1]}`;
+ return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = {
- matcher: ['/dashboard/:path*', '/classes/:path*', '/admin/:path*'],
+ matcher: [
+ '/dashboard/:path*',
+ '/classes/:path*',
+ '/admin/:path*',
+ '/subject-analysis/:subjectId/academic',
+ '/subject-analysis/:subjectId/attendance',
+ '/section-analysis/:sectionId/students',
+ '/section-analysis/:sectionId/overall',
+ '/section-analysis/:sectionId/attendance',
+ ],
};
diff --git a/package.json b/package.json
index eab309f..00c61d8 100644
--- a/package.json
+++ b/package.json
@@ -8,11 +8,13 @@
"start": "next start",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
- "seed": "ts-node prisma/seed.ts"
+ "seed:ece2-students": "ts-node prisma/seed-ece2-sem7-students.ts",
+ "auth:sync": "ts-node scripts/sync-supabase-auth.ts"
},
"dependencies": {
- "@prisma/client": "^5.18.0",
- "bcryptjs": "^2.4.3",
+ "@prisma/client": "^5.22.0",
+ "@supabase/ssr": "^0.12.5",
+ "@supabase/supabase-js": "^2.57.0",
"googleapis": "^140.0.1",
"lucide-react": "^1.34.0",
"next": "^14.2.5",
@@ -22,19 +24,14 @@
"recharts": "^2.12.7"
},
"devDependencies": {
- "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"autoprefixer": "^10.4.19",
- "concurrently": "^10.0.5",
"postcss": "^8.4.39",
- "prisma": "^5.18.0",
+ "prisma": "^5.22.0",
"tailwindcss": "^3.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.5.3",
- "wait-on": "^9.1.0"
- },
- "prisma": {
- "seed": "ts-node prisma/seed.ts"
+ "wait-on": "^9.0.0"
}
}
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 5e55630..1ac4e59 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -1,15 +1,42 @@
+import { useEffect } from "react";
import type { AppProps } from "next/app";
-import { SessionProvider } from "next-auth/react";
import GlobalButtonLoading from "../components/GlobalButtonLoading";
import "../styles/globals.css";
-import "../app/globals.css";
+import "../styles/analysis.css";
import "../styles/overall-analysis-fixes.css";
+import "../styles/academic-table-fix.css";
+import "../styles/academic-summary-fix.css";
-export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
+function AnalysisSidebarRouting() {
+ useEffect(() => {
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ const link = target?.closest(".analysis-side-nav a") as HTMLAnchorElement | null;
+ if (!link) return;
+
+ const label = link.textContent?.trim();
+ if (label === "Class Analysis") {
+ event.preventDefault();
+ window.location.href = "/class-analysis";
+ } else if (label === "Subject Analysis") {
+ event.preventDefault();
+ window.location.href = "/subject-analysis";
+ }
+ };
+
+ document.addEventListener("click", handleClick);
+ return () => document.removeEventListener("click", handleClick);
+ }, []);
+
+ return null;
+}
+
+export default function App({ Component, pageProps }: AppProps) {
return (
-
+ <>
+
-
+ >
);
}
diff --git a/pages/api/analysis/section-info/[sectionId].ts b/pages/api/analysis/section-info/[sectionId].ts
new file mode 100644
index 0000000..75b48a1
--- /dev/null
+++ b/pages/api/analysis/section-info/[sectionId].ts
@@ -0,0 +1,26 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import { prisma } from "../../../../lib/prisma";
+import { requireSession, assertTeacherCanViewSection } from "../../../../lib/access";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await requireSession(req, res);
+ if (!session) return;
+
+ const sectionId = req.query.sectionId as string;
+ const userId = (session.user as any).id;
+ const role = (session.user as any).role;
+
+ const allowed = await assertTeacherCanViewSection(userId, role, sectionId);
+ if (!allowed) return res.status(403).json({ error: "Not authorized for this class" });
+
+ const section = await prisma.section.findUnique({
+ where: { id: sectionId },
+ include: { class: { include: { department: true } } },
+ });
+
+ if (!section) return res.status(404).json({ error: "Class not found" });
+
+ return res.status(200).json({
+ className: `${section.class.department.name}-${section.name} Sem ${section.class.semester}`,
+ });
+}
diff --git a/pages/api/analysis/section/[sectionId].ts b/pages/api/analysis/section/[sectionId].ts
index 57f680f..c8c9191 100644
--- a/pages/api/analysis/section/[sectionId].ts
+++ b/pages/api/analysis/section/[sectionId].ts
@@ -1,119 +1,87 @@
-import {
- NextApiRequest,
- NextApiResponse,
-} from "next";
-
+import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../../lib/prisma";
+import { requireSession, assertTeacherCanViewSection } from "../../../../lib/access";
+import { fetchClassRawData } from "../../../../lib/googleSheetsClass";
+import { computeSectionAnalysis } from "../../../../lib/analysisClass";
-import {
- requireSession,
- assertTeacherCanViewSection,
-} from "../../../../lib/access";
-
-import {
- fetchClassRawData,
-} from "../../../../lib/googleSheetsClass";
-
-import {
- computeSectionAnalysis,
-} from "../../../../lib/analysisClass";
-
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse
-) {
- const session = await requireSession(
- req,
- res
- );
-
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await requireSession(req, res);
if (!session) return;
- const sectionId =
- req.query.sectionId as string;
-
- const userId =
- (session.user as any).id;
+ const requestedId = req.query.sectionId as string;
+ const userId = (session.user as any).id;
+ const role = (session.user as any).role;
+
+ // The analysis UI is section-based, but older dashboard/class-analysis links
+ // may still provide a class id. Resolve a direct section id first. If the id
+ // is a class id, resolve to a section the current teacher is actually allowed
+ // to view instead of blindly selecting the first section in that class.
+ let resolvedSection = await prisma.section.findUnique({
+ where: { id: requestedId },
+ });
+
+ if (!resolvedSection) {
+ if (role === "ADMIN") {
+ resolvedSection = await prisma.section.findFirst({
+ where: { classId: requestedId },
+ });
+ } else {
+ resolvedSection = await prisma.section.findFirst({
+ where: {
+ classId: requestedId,
+ OR: [
+ { class: { proctorId: userId } },
+ {
+ subjects: {
+ some: {
+ assignments: {
+ some: { teacherId: userId },
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+ }
+ }
- const role =
- (session.user as any).role;
+ if (!resolvedSection) {
+ return res.status(404).json({ error: "Section not found or not assigned to you" });
+ }
- const allowed =
- await assertTeacherCanViewSection(
- userId,
- role,
- sectionId
- );
+ const sectionId = resolvedSection.id;
+ // Keep the existing authorization check for direct section URLs as the final
+ // guard. This does not weaken access; it only fixes legacy class-id routing.
+ const allowed = await assertTeacherCanViewSection(userId, role, sectionId);
if (!allowed) {
- return res.status(403).json({
- error:
- "Not authorized for this section",
- });
+ return res.status(403).json({ error: "Not authorized for this section" });
}
- const forceSync =
- req.query.sync === "1";
+ const forceSync = req.query.sync === "1";
+ const previousMonth = typeof req.query.previousMonth === "string" ? req.query.previousMonth : undefined;
+ const currentMonth = typeof req.query.currentMonth === "string" ? req.query.currentMonth : undefined;
- const previousMonth =
- typeof req.query.previousMonth ===
- "string"
- ? req.query.previousMonth
- : undefined;
-
- const currentMonth =
- typeof req.query.currentMonth ===
- "string"
- ? req.query.currentMonth
- : undefined;
-
- // Accept both names so the frontend and API
- // cannot get out of sync again.
const criteriaValue =
typeof req.query.criteria === "string"
? req.query.criteria
- : typeof req.query.trendCriteria ===
- "string"
- ? req.query.trendCriteria
- : undefined;
-
- const criteria =
- criteriaValue !== undefined
- ? Number(criteriaValue)
- : undefined;
-
- const hasCustomTrendSettings =
- previousMonth !== undefined ||
- currentMonth !== undefined ||
- criteria !== undefined;
-
- const link =
- await prisma.sheetLink.findUnique({
- where: { sectionId },
- });
+ : typeof req.query.trendCriteria === "string"
+ ? req.query.trendCriteria
+ : undefined;
+ const criteria = criteriaValue !== undefined ? Number(criteriaValue) : undefined;
+ const hasCustomTrendSettings = previousMonth !== undefined || currentMonth !== undefined || criteria !== undefined;
+ const link = await prisma.sheetLink.findUnique({ where: { sectionId } });
if (!link) {
- return res.status(404).json({
- error:
- "No combined Google Sheet linked to this section yet",
- });
+ return res.status(404).json({ error: "No combined Google Sheet linked to this section yet" });
}
- // Use cache only for the ordinary overview request.
- // Custom month comparisons always calculate from
- // the latest Google Sheet data.
- if (
- !forceSync &&
- !hasCustomTrendSettings
- ) {
- const latest =
- await prisma.analysisSnapshot.findFirst({
- where: { sectionId },
- orderBy: {
- computedAt: "desc",
- },
- });
-
+ if (!forceSync && !hasCustomTrendSettings) {
+ const latest = await prisma.analysisSnapshot.findFirst({
+ where: { sectionId },
+ orderBy: { computedAt: "desc" },
+ });
if (latest) {
return res.status(200).json({
cached: true,
@@ -125,52 +93,24 @@ export default async function handler(
}
try {
- const raw =
- await fetchClassRawData(
- link.sheetId
- );
-
- const analysis =
- computeSectionAnalysis(raw, {
- previousMonth,
- currentMonth,
- criteria,
- });
-
- const snapshot =
- await prisma.analysisSnapshot.create({
- data: {
- sectionId,
- data: analysis as any,
- },
- });
-
- await prisma.sheetLink.update({
- where: { sectionId },
- data: {
- lastSyncAt: new Date(),
- },
+ const raw = await fetchClassRawData(link.sheetId);
+ const analysis = computeSectionAnalysis(raw, { previousMonth, currentMonth, criteria });
+ const snapshot = await prisma.analysisSnapshot.create({
+ data: { sectionId, data: analysis as any },
});
+ await prisma.sheetLink.update({ where: { sectionId }, data: { lastSyncAt: new Date() } });
return res.status(200).json({
cached: false,
- computedAt:
- snapshot.computedAt,
+ computedAt: snapshot.computedAt,
sheetId: link.sheetId,
data: analysis,
});
} catch (err: any) {
- console.error(
- "Section analysis sync failed:",
- err
- );
-
+ console.error("Section analysis sync failed:", err);
return res.status(502).json({
- error:
- "Failed to sync from Google Sheets.",
- detail:
- err?.message ||
- "Unknown Google Sheets error.",
+ error: "Failed to sync from Google Sheets.",
+ detail: err?.message || "Unknown Google Sheets error.",
});
}
-}
\ No newline at end of file
+}
diff --git a/pages/api/analysis/section/[sectionId]/overall.ts b/pages/api/analysis/section/[sectionId]/overall.ts
index 60a363a..abc18a6 100644
--- a/pages/api/analysis/section/[sectionId]/overall.ts
+++ b/pages/api/analysis/section/[sectionId]/overall.ts
@@ -8,64 +8,93 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const session = await requireSession(req, res);
if (!session) return;
- const sectionId = req.query.sectionId as string;
+ const requestedId = req.query.sectionId as string;
const userId = (session.user as any).id;
const role = (session.user as any).role;
+ let resolvedSection = await prisma.section.findUnique({ where: { id: requestedId } });
+ if (!resolvedSection) {
+ if (role === "ADMIN") {
+ resolvedSection = await prisma.section.findFirst({ where: { classId: requestedId }, orderBy: { name: "asc" } });
+ } else {
+ resolvedSection = await prisma.section.findFirst({
+ where: {
+ classId: requestedId,
+ OR: [
+ { class: { proctorId: userId } },
+ { subjects: { some: { assignments: { some: { teacherId: userId } } } } },
+ ],
+ },
+ orderBy: { name: "asc" },
+ });
+ }
+ }
+
+ if (!resolvedSection) return res.status(404).json({ error: "Section not found or not assigned to you" });
+
+ const sectionId = resolvedSection.id;
const allowed = await assertTeacherCanViewSection(userId, role, sectionId);
if (!allowed) return res.status(403).json({ error: "Not authorized for this section" });
- const forceSync = req.query.sync === "1";
-
- const subjects = await prisma.subject.findMany({ where: { sectionId } });
- if (subjects.length === 0) {
- return res.status(404).json({ error: "No subjects exist for this section yet" });
- }
+ const subjects = await prisma.subject.findMany({ where: { sectionId, type: "THEORY" }, orderBy: { code: "asc" } });
+ if (subjects.length === 0) return res.status(404).json({ error: "No theory subjects exist for this section yet" });
const link = await prisma.sheetLink.findUnique({ where: { sectionId } });
if (!link) return res.status(404).json({ error: "No combined Google Sheet linked to this section yet" });
- if (!forceSync) {
+ const theoryIds = new Set(subjects.map((s) => s.id));
+ const theoryCodes = new Set(subjects.map((s) => s.code));
+
+ if (req.query.sync !== "1") {
const latest = await prisma.analysisSnapshot.findFirst({
where: { sectionId, data: { path: ["kind"], equals: "overall" } as any },
orderBy: { computedAt: "desc" },
});
if (latest) {
- return res
- .status(200)
- .json({ cached: true, computedAt: latest.computedAt, sheetId: link.sheetId, data: latest.data });
+ const cached = latest.data as any;
+ const cachedSubjects = Array.isArray(cached.subjects)
+ ? cached.subjects.filter((s: any) => theoryIds.has(s.id) || theoryCodes.has(s.code))
+ : subjects.map((s) => ({ id: s.id, name: s.name, code: s.code }));
+ const data = {
+ ...cached,
+ subjects: cachedSubjects,
+ students: Array.isArray(cached.students)
+ ? cached.students.map((student: any) => ({
+ ...student,
+ subjects: Array.isArray(student.subjects)
+ ? student.subjects.filter((s: any) => theoryIds.has(s.subjectId) || theoryCodes.has(s.code))
+ : [],
+ }))
+ : [],
+ };
+ return res.status(200).json({ cached: true, computedAt: latest.computedAt, sheetId: link.sheetId, data });
}
}
try {
const raw = await fetchClassRawData(link.sheetId);
- const subjectAnalyses = computeAllSubjectAnalyses(
- raw,
- subjects.map((s) => s.code)
- );
-
- // Combine per student across every subject.
- const studentMap = new Map<
- string,
- {
- enrollmentNo: string;
+ const subjectAnalyses = computeAllSubjectAnalyses(raw, subjects.map((s) => s.code));
+
+ const studentMap = new Map();
+ attendance: number;
+ midsem1: number;
+ midsem2: number;
+ combined: number;
+ assignment: { submitted: number; total: number; mark: number };
+ presentation: { raw: number; mark: number };
+ basicInternal: number;
+ moderatedInternal: number;
+ basicMax: number;
+ grade: string;
+ }[];
+ }>();
subjects.forEach((subject) => {
const analysis = subjectAnalyses[subject.code];
@@ -73,19 +102,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
analysis.students.forEach((s) => {
if (!studentMap.has(s.enrollmentNo)) {
- studentMap.set(s.enrollmentNo, {
- enrollmentNo: s.enrollmentNo,
- name: s.name,
- email: s.email,
- subjects: [],
- });
+ studentMap.set(s.enrollmentNo, { enrollmentNo: s.enrollmentNo, name: s.name, email: s.email, subjects: [] });
}
- // Subject's own "full marks" for its internal score: assignment
- // total + presentation (out of 10) + midsem (out of 30) — used to
- // turn each subject's basic marks into a percentage so subjects
- // with different assignment totals can be averaged fairly.
- const basicMax = (s.assignment.total || 0) + 10 + 30;
+ const assignmentMark = s.assignment.total > 0 ? round1((s.assignment.submitted / s.assignment.total) * 5) : 0;
+ const presentationMark = round1((s.presentation / 10) * 5);
+ const basicMax = 40;
studentMap.get(s.enrollmentNo)!.subjects.push({
subjectId: subject.id,
@@ -95,22 +117,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
midsem1: s.midsem.first,
midsem2: s.midsem.second,
combined: s.midsem.combined,
+ assignment: { submitted: s.assignment.submitted, total: s.assignment.total, mark: assignmentMark },
+ presentation: { raw: s.presentation, mark: presentationMark },
basicInternal: s.internalMarks.basic,
moderatedInternal: s.internalMarks.moderated,
basicMax,
- grade: s.midsem.grade,
+ grade: gradeFor(s.internalMarks.basic, basicMax),
});
});
});
const students = Array.from(studentMap.values()).map((student) => {
- const percentages = student.subjects.map((sub) =>
- sub.basicMax > 0 ? (sub.basicInternal / sub.basicMax) * 100 : 0
- );
- const overallPct = percentages.length
- ? round1(percentages.reduce((a, b) => a + b, 0) / percentages.length)
- : 0;
- const attendancePercentages = student.subjects.map((sub) => sub.attendance);
+ const sixSubjects = student.subjects.slice(0, 6);
+ const total = sixSubjects.reduce((sum, sub) => sum + sub.basicInternal, 0);
+ const average = sixSubjects.length ? total / sixSubjects.length : 0;
+ const overallPct = round1((average / 40) * 100);
+ const attendancePercentages = sixSubjects.map((sub) => sub.attendance);
const overallAttendance = attendancePercentages.length
? round1(attendancePercentages.reduce((a, b) => a + b, 0) / attendancePercentages.length)
: 0;
@@ -119,7 +141,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
...student,
overallPct,
overallAttendance,
- overallGrade: gradeFor(overallPct, 100),
+ overallGrade: gradeFor(average, 40),
};
});
@@ -127,18 +149,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
kind: "overall" as const,
subjects: subjects.map((s) => ({ id: s.id, name: s.name, code: s.code })),
students,
- classAverageOverallPct: students.length
- ? round1(students.reduce((a, s) => a + s.overallPct, 0) / students.length)
- : 0,
+ classAverageOverallPct: students.length ? round1(students.reduce((a, s) => a + s.overallPct, 0) / students.length) : 0,
};
- const snapshot = await prisma.analysisSnapshot.create({
- data: { sectionId, data: data as any },
- });
-
- return res
- .status(200)
- .json({ cached: false, computedAt: snapshot.computedAt, sheetId: link.sheetId, data });
+ const snapshot = await prisma.analysisSnapshot.create({ data: { sectionId, data: data as any } });
+ return res.status(200).json({ cached: false, computedAt: snapshot.computedAt, sheetId: link.sheetId, data });
} catch (err: any) {
console.error(err);
return res.status(502).json({
diff --git a/pages/api/analysis/subject-info/[subjectId].ts b/pages/api/analysis/subject-info/[subjectId].ts
new file mode 100644
index 0000000..aa07b7c
--- /dev/null
+++ b/pages/api/analysis/subject-info/[subjectId].ts
@@ -0,0 +1,28 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import { prisma } from "../../../../lib/prisma";
+import { requireSession, assertTeacherCanViewSubject } from "../../../../lib/access";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await requireSession(req, res);
+ if (!session) return;
+
+ const subjectId = req.query.subjectId as string;
+ const userId = (session.user as any).id;
+ const role = (session.user as any).role;
+
+ const allowed = await assertTeacherCanViewSubject(userId, role, subjectId);
+ if (!allowed) return res.status(403).json({ error: "Not authorized for this subject" });
+
+ const subject = await prisma.subject.findUnique({
+ where: { id: subjectId },
+ include: { section: { include: { class: { include: { department: true } } } } },
+ });
+
+ if (!subject) return res.status(404).json({ error: "Subject not found" });
+
+ return res.status(200).json({
+ className: `${subject.section.class.department.name}-${subject.section.name} Sem ${subject.section.class.semester}`,
+ subjectName: subject.name,
+ subjectCode: subject.code,
+ });
+}
diff --git a/pages/api/attendance-agent.ts b/pages/api/attendance-agent.ts
new file mode 100644
index 0000000..918c7ab
--- /dev/null
+++ b/pages/api/attendance-agent.ts
@@ -0,0 +1,261 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../../lib/authOptions";
+import { prisma } from "../../lib/prisma";
+import { fetchClassRawData } from "../../lib/googleSheetsClass";
+import { deleteTeacherDiaryAttendance, writeTeacherDiaryAttendance } from "../../lib/googleSheetsAttendance";
+import { readTeacherDiarySessions } from "../../lib/googleSheetsAttendanceAgent";
+
+const TIME_SLOTS = [
+ "8 to 9",
+ "9 to 10",
+ "10 to 11",
+ "11 to 12",
+ "12.30 to 1.30",
+ "1.30 to 2.30",
+ "2.30 to 3.30",
+ "3.30 to 4.30",
+] as const;
+
+function validDate(value: unknown): value is string {
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
+}
+
+async function canManageSubject(userId: string, role: string, subjectId: string) {
+ if (role === "ADMIN") return true;
+ return !!(await prisma.assignment.findFirst({ where: { teacherId: userId, subjectId } }));
+}
+
+async function orderStudentsBySheet(sectionId: string, students: Array<{ id: string; enrollmentNo: string; name: string }>) {
+ try {
+ const link = await prisma.sheetLink.findUnique({ where: { sectionId }, select: { sheetId: true } });
+ if (!link?.sheetId) return students;
+
+ const raw = await fetchClassRawData(link.sheetId);
+ const sheetRank = new Map();
+ let nextRank = 1;
+
+ for (const month of raw.months) {
+ for (const row of month.rows) {
+ if (!sheetRank.has(row.enrollmentNo)) sheetRank.set(row.enrollmentNo, nextRank++);
+ }
+ }
+
+ if (sheetRank.size === 0) return students;
+
+ return [...students].sort((a, b) => {
+ const aRank = sheetRank.get(a.enrollmentNo);
+ const bRank = sheetRank.get(b.enrollmentNo);
+ if (aRank !== undefined && bRank !== undefined) return aRank - bRank;
+ if (aRank !== undefined) return -1;
+ if (bRank !== undefined) return 1;
+ return a.enrollmentNo.localeCompare(b.enrollmentNo, undefined, { numeric: true });
+ });
+ } catch (error) {
+ console.error("Could not read Google Sheet student order:", error);
+ return students;
+ }
+}
+
+function sessionSortKey(date: string, slot: string): string {
+ const slotIndex = TIME_SLOTS.findIndex((item) => item === slot);
+ return `${date}|${String(slotIndex === -1 ? 999 : slotIndex).padStart(3, "0")}|${slot}`;
+}
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getServerSession(req, res, authOptions);
+ if (!session?.user) return res.status(401).json({ error: "Not authenticated" });
+
+ const userId = (session.user as any).id as string;
+ const role = (session.user as any).role as string;
+ const teacherName = ((session.user as any).name as string | undefined) || "Teacher";
+
+ if (req.method === "GET") {
+ const sectionId = typeof req.query.sectionId === "string" ? req.query.sectionId : "";
+ const date = validDate(req.query.date) ? req.query.date : new Date().toISOString().slice(0, 10);
+ const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : "";
+
+ if (!sectionId) return res.status(400).json({ error: "sectionId is required" });
+
+ const section = await prisma.section.findUnique({
+ where: { id: sectionId },
+ include: {
+ class: { include: { department: true } },
+ students: { orderBy: [{ enrollmentNo: "asc" }] },
+ subjects: { include: { assignments: true }, orderBy: { name: "asc" } },
+ sheetLink: true,
+ },
+ });
+
+ if (!section) return res.status(404).json({ error: "Class not found" });
+ if (!section.sheetLink?.sheetId) return res.status(400).json({ error: "No Google Sheet is linked to this class" });
+
+ const visibleSubjects = role === "ADMIN"
+ ? section.subjects
+ : section.subjects.filter((subject) => subject.assignments.some((a) => a.teacherId === userId));
+
+ if (visibleSubjects.length === 0) return res.status(403).json({ error: "No assigned subjects for this class" });
+
+ const orderedStudents = await orderStudentsBySheet(sectionId, section.students);
+ const sheetSessions = await readTeacherDiarySessions({
+ spreadsheetId: section.sheetLink.sheetId,
+ subjectCodes: visibleSubjects.map((subject) => subject.code),
+ date,
+ });
+
+ const subjectByCode = new Map(visibleSubjects.map((subject) => [subject.code.toLowerCase(), subject]));
+ const sessions = sheetSessions
+ .map((item) => {
+ const subject = subjectByCode.get(item.subjectCode.toLowerCase());
+ if (!subject) return null;
+ return {
+ id: item.id,
+ subjectId: subject.id,
+ subjectName: subject.name,
+ subjectCode: subject.code,
+ slot: item.slot,
+ teacherId: "sheet",
+ teacherName: item.teacherName,
+ present: item.presentEnrollmentNos.length,
+ total: orderedStudents.length,
+ canEdit: true,
+ };
+ })
+ .filter((item): item is NonNullable => item !== null)
+ .sort((a, b) => sessionSortKey(date, a.slot).localeCompare(sessionSortKey(date, b.slot)));
+
+ if (sessionId) {
+ const found = sheetSessions.find((item) => item.id === sessionId);
+ if (!found) return res.status(404).json({ error: "Attendance session not found" });
+ const presentEnrollmentNos = new Set(found.presentEnrollmentNos);
+ return res.status(200).json({
+ presentStudentIds: orderedStudents.filter((student) => presentEnrollmentNos.has(student.enrollmentNo)).map((student) => student.id),
+ });
+ }
+
+ return res.status(200).json({
+ section: { id: section.id, label: `${section.class.department.name}-${section.name} Sem ${section.class.semester}`, strength: section.strength },
+ subjects: visibleSubjects.map((subject) => ({ id: subject.id, name: subject.name, code: subject.code, type: subject.type })),
+ students: orderedStudents.map((student, index) => ({ id: student.id, enrollmentNo: student.enrollmentNo, name: student.name, serialNo: index + 1 })),
+ sessions,
+ });
+ }
+
+ if (req.method === "DELETE") {
+ const { sectionId, sessionId } = req.body ?? {};
+ if (typeof sectionId !== "string" || typeof sessionId !== "string" || !sessionId) {
+ return res.status(400).json({ error: "sectionId and sessionId are required" });
+ }
+
+ const section = await prisma.section.findUnique({
+ where: { id: sectionId },
+ include: {
+ sheetLink: true,
+ subjects: { include: { assignments: true } },
+ },
+ });
+ if (!section) return res.status(404).json({ error: "Class not found" });
+ if (!section.sheetLink?.sheetId) return res.status(400).json({ error: "No Google Sheet is linked to this class" });
+
+ const visibleSubjects = role === "ADMIN"
+ ? section.subjects
+ : section.subjects.filter((subject) => subject.assignments.some((a) => a.teacherId === userId));
+ const sheetSessions = await readTeacherDiarySessions({
+ spreadsheetId: section.sheetLink.sheetId,
+ subjectCodes: visibleSubjects.map((subject) => subject.code),
+ });
+ const target = sheetSessions.find((item) => item.id === sessionId);
+ if (!target) return res.status(404).json({ error: "Attendance session not found" });
+
+ try {
+ await deleteTeacherDiaryAttendance({
+ spreadsheetId: section.sheetLink.sheetId,
+ subjectCode: target.subjectCode,
+ date: target.date,
+ slot: target.slot,
+ });
+ } catch (error) {
+ console.error("Teacher Diary deletion failed:", error);
+ const message = error instanceof Error ? error.message : "Unknown Google Sheets error";
+ return res.status(502).json({ error: `Attendance was not deleted because Teacher Diary could not be updated. ${message}` });
+ }
+
+ return res.status(200).json({ ok: true, deletedSessionId: sessionId });
+ }
+
+ if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" });
+
+ const { sectionId, subjectId, date, slot, presentStudentIds } = req.body ?? {};
+ if (typeof sectionId !== "string" || typeof subjectId !== "string" || !validDate(date) || typeof slot !== "string" || !slot.trim()) {
+ return res.status(400).json({ error: "sectionId, subjectId, date and slot are required" });
+ }
+
+ const normalizedSlot = slot.trim();
+ if (!TIME_SLOTS.includes(normalizedSlot as (typeof TIME_SLOTS)[number])) {
+ return res.status(400).json({ error: "Choose one of the available class time slots" });
+ }
+
+ if (!Array.isArray(presentStudentIds) || presentStudentIds.some((id) => typeof id !== "string")) {
+ return res.status(400).json({ error: "presentStudentIds must be an array" });
+ }
+
+ if (!(await canManageSubject(userId, role, subjectId))) {
+ return res.status(403).json({ error: "You are not assigned to this subject" });
+ }
+
+ const subject = await prisma.subject.findUnique({
+ where: { id: subjectId },
+ select: { sectionId: true, name: true, code: true },
+ });
+ if (!subject || subject.sectionId !== sectionId) return res.status(400).json({ error: "Subject does not belong to this class" });
+
+ const section = await prisma.section.findUnique({
+ where: { id: sectionId },
+ select: {
+ name: true,
+ sheetLink: { select: { sheetId: true } },
+ class: { select: { semester: true, department: { select: { name: true } } } },
+ },
+ });
+ if (!section) return res.status(404).json({ error: "Class not found" });
+ if (!section.sheetLink?.sheetId) return res.status(400).json({ error: "No Google Sheet is linked to this class" });
+
+ const students = await prisma.student.findMany({
+ where: { sectionId },
+ select: { id: true, enrollmentNo: true },
+ });
+ const studentIds = new Set(students.map((student) => student.id));
+ const presentIds = [...new Set(presentStudentIds as string[])];
+ if (presentIds.some((id) => !studentIds.has(id))) return res.status(400).json({ error: "Attendance contains a student outside this class" });
+
+ const sessionKey = `ATT-${date.replace(/-/g, "")}-${subject.code.replace(/[^a-z0-9]/gi, "").toUpperCase()}-${normalizedSlot.replace(/[^a-z0-9]+/gi, "-").toUpperCase()}`;
+
+ try {
+ await writeTeacherDiaryAttendance({
+ spreadsheetId: section.sheetLink.sheetId,
+ subjectCode: subject.code,
+ subjectName: subject.name,
+ classLabel: `${section.class.department.name}-${section.name} Sem ${section.class.semester}`,
+ teacherName,
+ date,
+ slot: normalizedSlot,
+ sessionKey,
+ students: students.map((student) => ({
+ enrollmentNo: student.enrollmentNo,
+ present: presentIds.includes(student.id),
+ })),
+ });
+ } catch (error) {
+ console.error("Teacher Diary update failed:", error);
+ const message = error instanceof Error ? error.message : "Unknown Google Sheets error";
+ return res.status(502).json({ error: `Attendance was not saved because Teacher Diary could not be updated. ${message}` });
+ }
+
+ return res.status(200).json({
+ ok: true,
+ sessionId: sessionKey,
+ present: presentIds.length,
+ total: students.length,
+ sheetUpdated: true,
+ });
+}
diff --git a/pages/attendance-agent.tsx b/pages/attendance-agent.tsx
new file mode 100644
index 0000000..8747c4f
--- /dev/null
+++ b/pages/attendance-agent.tsx
@@ -0,0 +1,189 @@
+import type { GetServerSideProps } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../lib/authOptions";
+import { prisma } from "../lib/prisma";
+import Link from "next/link";
+import { useEffect, useRef, useState } from "react";
+import { signOut } from "next-auth/react";
+import { BarChart3, BookOpen, CalendarDays, Check, CheckCircle2, ChevronDown, Clock3, LayoutDashboard, LogOut, Trash2, UserRoundPlus } from "lucide-react";
+
+type Student = { id: string; enrollmentNo: string; name: string; serialNo: number };
+type Subject = { id: string; name: string; code: string; type: string };
+type Section = { id: string; label: string; strength: number };
+type Session = { id: string; subjectId: string; subjectName: string; subjectCode: string; slot: string; teacherId: string; teacherName: string; present: number; total: number; canEdit: boolean };
+
+type Props = { teacherName: string; sections: Section[]; initialSectionId: string; initialStudents: Student[]; initialSubjects: Subject[]; };
+type ApiData = { section: Section; subjects: Subject[]; students: Student[]; sessions: Session[] };
+
+const TIME_SLOTS = [
+ "8 to 9",
+ "9 to 10",
+ "10 to 11",
+ "11 to 12",
+ "12.30 to 1.30",
+ "1.30 to 2.30",
+ "2.30 to 3.30",
+ "3.30 to 4.30",
+];
+
+function today() { return new Date().toLocaleDateString("en-CA"); }
+
+function Sidebar({ teacherName }: { teacherName: string }) {
+ const initials = teacherName.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join("") || "T";
+ return
+ ClassPulse
+
+ Dashboard Attendance Agent Timetable Class Analysis Subject Analysis
+ signOut({ callbackUrl: "/login" })} className="flex w-full items-center gap-3 border-t border-[#eeeeeb] px-3 pt-5 text-sm font-medium text-[#626b80] hover:text-[#17223b]"> Sign out
+ ;
+}
+
+export default function AttendanceAgent({ teacherName, sections, initialSectionId, initialStudents, initialSubjects }: Props) {
+ const [sectionId, setSectionId] = useState(initialSectionId);
+ const [students, setStudents] = useState(initialStudents);
+ const [subjects, setSubjects] = useState(initialSubjects);
+ const [sessions, setSessions] = useState([]);
+ const [subjectId, setSubjectId] = useState(initialSubjects[0]?.id || "");
+ const [date, setDate] = useState(today());
+ const [slot, setSlot] = useState("");
+ const [present, setPresent] = useState>(new Set(initialStudents.map((student) => student.id)));
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [deletingId, setDeletingId] = useState("");
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ const [editingId, setEditingId] = useState("");
+ const loadRequestRef = useRef(0);
+ const viewRef = useRef({ sectionId: initialSectionId, date: today() });
+
+ const selectedSubject = subjects.find((subject) => subject.id === subjectId);
+ const selectedSession = sessions.find((session) => session.subjectId === subjectId && session.slot === slot);
+ const showStudentList = !selectedSession || editingId === selectedSession.id;
+ const allPresent = students.length > 0 && present.size === students.length;
+ const presentCount = students.filter((student) => present.has(student.id)).length;
+
+ async function loadSection(nextSectionId: string, nextDate = date) {
+ const requestId = ++loadRequestRef.current;
+ setLoading(true); setError("");
+ try {
+ const response = await fetch(`/api/attendance-agent?sectionId=${encodeURIComponent(nextSectionId)}&date=${encodeURIComponent(nextDate)}`, { cache: "no-store" });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Could not load class");
+ if (requestId !== loadRequestRef.current) return;
+ const result = data as ApiData;
+ setStudents(result.students); setSubjects(result.subjects); setSessions(result.sessions);
+ setSubjectId((current) => result.subjects.some((subject) => subject.id === current) ? current : result.subjects[0]?.id || "");
+ setPresent(new Set(result.students.map((student) => student.id))); setEditingId("");
+ } catch (e) {
+ if (requestId !== loadRequestRef.current) return;
+ setError(e instanceof Error ? e.message : "Could not load class");
+ } finally {
+ if (requestId === loadRequestRef.current) setLoading(false);
+ }
+ }
+
+ async function loadDate(nextDate: string) {
+ viewRef.current.date = nextDate;
+ setDate(nextDate); setSessions([]); setEditingId(""); setSlot(""); setMessage(""); setError("");
+ await loadSection(viewRef.current.sectionId, nextDate);
+ }
+
+ useEffect(() => { loadSection(initialSectionId, date); // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ function toggle(studentId: string) {
+ setPresent((current) => { const next = new Set(current); if (next.has(studentId)) next.delete(studentId); else next.add(studentId); return next; });
+ }
+
+ function toggleAll() {
+ setPresent(allPresent ? new Set() : new Set(students.map((student) => student.id)));
+ }
+
+ async function editSession(session: Session) {
+ if (!session.canEdit) return;
+ setLoading(true); setError("");
+ try {
+ const response = await fetch(`/api/attendance-agent?sectionId=${encodeURIComponent(sectionId)}&date=${encodeURIComponent(date)}&sessionId=${encodeURIComponent(session.id)}`, { cache: "no-store" });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Could not load attendance");
+ if (viewRef.current.sectionId !== sectionId || viewRef.current.date !== date) return;
+ setSubjectId(session.subjectId); setSlot(session.slot); setPresent(new Set(data.presentStudentIds)); setEditingId(session.id); setMessage("");
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ } catch (e) { setError(e instanceof Error ? e.message : "Could not load attendance"); }
+ finally { setLoading(false); }
+ }
+
+ async function deleteSession(session: Session) {
+ if (!session.canEdit || deletingId) return;
+ const confirmed = window.confirm(`Delete attendance for ${session.subjectName} (${session.subjectCode}) at ${session.slot}?\n\nThis will permanently remove this attendance session from ClassPulse and the Teacher Diary. This action cannot be undone.`);
+ if (!confirmed) return;
+
+ const deletedSectionId = sectionId;
+ const deletedDate = date;
+ setDeletingId(session.id); setError(""); setMessage("");
+ try {
+ const response = await fetch("/api/attendance-agent", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sectionId: deletedSectionId, sessionId: session.id }) });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Could not delete attendance");
+ setSessions((current) => current.filter((item) => item.id !== session.id));
+ if (editingId === session.id) setEditingId("");
+ setMessage(`Attendance deleted — ${session.subjectName} · ${session.slot}.`);
+ if (viewRef.current.sectionId === deletedSectionId && viewRef.current.date === deletedDate) {
+ await loadSection(deletedSectionId, deletedDate);
+ }
+ } catch (e) { setError(e instanceof Error ? e.message : "Could not delete attendance"); }
+ finally { setDeletingId(""); }
+ }
+
+ async function submit() {
+ if (!subjectId || !slot || students.length === 0) { setError("Choose a subject and time slot before saving."); return; }
+ const savedSectionId = sectionId;
+ const savedDate = date;
+ setSaving(true); setError(""); setMessage("");
+ try {
+ const response = await fetch("/api/attendance-agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sectionId: savedSectionId, subjectId, date: savedDate, slot, presentStudentIds: [...present] }) });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Could not save attendance");
+ setMessage(`Attendance saved — ${data.present} present, ${data.total - data.present} absent.`); setEditingId("");
+ if (viewRef.current.sectionId === savedSectionId && viewRef.current.date === savedDate) await loadSection(savedSectionId, savedDate);
+ } catch (e) { setError(e instanceof Error ? e.message : "Could not save attendance"); }
+ finally { setSaving(false); }
+ }
+
+ return
+
← Back to Dashboard
+
Attendance Agent
Take attendance Record attendance for a class session. Present students are marked LA = 1 and every student in the session receives LH = 1.
+
+
+ { const nextSectionId = e.target.value; viewRef.current.sectionId = nextSectionId; setSectionId(nextSectionId); setSessions([]); setEditingId(""); setSlot(""); setMessage(""); setError(""); loadSection(nextSectionId, date); }} className="input">Select class {sections.map((section) => {section.label} )}
loadDate(e.target.value)} className="input"/> { setSubjectId(e.target.value); setEditingId(""); }} className="input">Select subject {subjects.map((subject) => {subject.name} ({subject.code}) )}
{ setSlot(e.target.value); setEditingId(""); }} className="input">Select time slot {TIME_SLOTS.map((timeSlot) => {timeSlot} )}
+ {selectedSubject && {selectedSubject.name}
{selectedSubject.code} · {selectedSubject.type === "LAB" ? "Lab" : "Theory"}
}
+
+
+
+ {showStudentList ? <>
+ Students {presentCount} of {students.length} marked present
{allPresent ? "Mark all absent" : "Mark all present"}
+ {loading ?
Loading class…
: students.map((student) => { const isPresent = present.has(student.id); return
toggle(student.id)} className="flex w-full items-center gap-4 px-6 py-3.5 text-left transition hover:bg-[#faf9ff] sm:px-8">{student.serialNo} {isPresent ? : null} {student.name} {student.enrollmentNo} {isPresent ? "Present" : "Absent"} ; })}
+ {error &&
{error}
}{message &&
{message}
}{editingId && !error &&
Editing an existing attendance session.
}
{saving ? "Saving…" : editingId ? "Update attendance" : "Save attendance"}
+ > : Attendance recorded
{selectedSession?.present}/{selectedSession?.total} present · {selectedSession?.slot}
{selectedSession?.canEdit &&
editSession(selectedSession)} className="rounded-lg border border-[#d9d5ef] px-4 py-2 text-xs font-semibold text-[#4b36a7] hover:bg-[#faf9ff] disabled:cursor-not-allowed disabled:opacity-50">Edit attendance deleteSession(selectedSession)} className="inline-flex items-center gap-1.5 rounded-lg border border-[#f0caca] px-4 py-2 text-xs font-semibold text-[#c94747] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:opacity-50">{deletingId === selectedSession.id ? "Deleting…" : <> Delete>}
}
}
+
+
+
Attendance recorded for this date Each class period is stored as a separate attendance session.
{sessions.length === 0 ? No attendance has been recorded for this class on this date.
: {sessions.map((session) =>
{session.subjectName} ({session.subjectCode})
{session.slot} · {session.teacherName}
{session.present}/{session.total} present {session.canEdit &&
editSession(session)} className="rounded-lg border border-[#d9d5ef] px-3 py-2 text-xs font-semibold text-[#4b36a7] hover:bg-[#faf9ff] disabled:cursor-not-allowed disabled:opacity-50">Edit deleteSession(session)} className="inline-flex items-center gap-1.5 rounded-lg border border-[#f0caca] px-3 py-2 text-xs font-semibold text-[#c94747] hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:opacity-50">{deletingId === session.id ? "Deleting…" : <> Delete>}
}
)}
}
+
Signed in as {teacherName}. Only your assigned classes and subjects are available.
+
;
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) { return {label} {children} ; }
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const session = await getServerSession(ctx.req, ctx.res, authOptions);
+ if (!session?.user) return { redirect: { destination: "/login", permanent: false } };
+ const userId = (session.user as any).id as string;
+ const role = (session.user as any).role as string;
+ const sections = await prisma.section.findMany({ where: role === "ADMIN" ? undefined : { OR: [{ class: { proctorId: userId } }, { subjects: { some: { assignments: { some: { teacherId: userId } } } } }] }, include: { class: { include: { department: true } }, subjects: { include: { assignments: true }, orderBy: { name: "asc" } } }, orderBy: { name: "asc" } });
+ const visibleSections = sections.map((section) => ({ id: section.id, label: `${section.class.department.name}-${section.name} Sem ${section.class.semester}`, strength: section.strength }));
+ const initialSectionId = visibleSections[0]?.id || "";
+ const initial = sections.find((section) => section.id === initialSectionId);
+ const visibleSubjects = initial ? (role === "ADMIN" ? initial.subjects : initial.subjects.filter((subject) => subject.assignments.some((assignment) => assignment.teacherId === userId))) : [];
+ const students = initial ? await prisma.student.findMany({ where: { sectionId: initial.id }, orderBy: { enrollmentNo: "asc" } }) : [];
+ return { props: { teacherName: session.user.name || session.user.email || "Faculty", sections: visibleSections, initialSectionId, initialStudents: students.map((student, index) => ({ id: student.id, enrollmentNo: student.enrollmentNo, name: student.name, serialNo: index + 1 })), initialSubjects: visibleSubjects.map((subject) => ({ id: subject.id, name: subject.name, code: subject.code, type: subject.type })) } };
+};
diff --git a/pages/class-analysis-attendance-trend-fixed/[sectionId].tsx b/pages/class-analysis-attendance-trend-fixed/[sectionId].tsx
new file mode 100644
index 0000000..5db90b1
--- /dev/null
+++ b/pages/class-analysis-attendance-trend-fixed/[sectionId].tsx
@@ -0,0 +1,143 @@
+import { useEffect } from "react";
+import SectionAttendancePage from "../section-analysis/[sectionId]/attendance";
+
+type Student = {
+ enrollmentNo: string;
+ name: string;
+ email?: string;
+ attendancePct: { prevMonth: number; currMonth: number; trend: string };
+};
+type AttendancePayload = {
+ students?: Student[];
+ monthsUsed?: { previous?: string; current?: string };
+};
+
+const fmt = (n: number) => `${Math.round(n * 10) / 10}%`;
+const esc = (v: unknown) => String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
+const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((x) => x[0]).join("").toUpperCase();
+
+function getSectionId() {
+ return window.location.pathname.match(/\/section-analysis\/([^/]+)/)?.[1] || "";
+}
+
+function addStyles() {
+ if (document.getElementById("classpulse-attendance-trend-fixes")) return;
+ const style = document.createElement("style");
+ style.id = "classpulse-attendance-trend-fixes";
+ style.textContent = `
+ .classpulse-trend-hero{display:grid!important;grid-template-columns:300px minmax(480px,620px)!important;justify-content:space-between!important;gap:24px!important;align-items:center!important}
+ .classpulse-trend-hero-copy h2{margin:0;font-size:21px;line-height:1.2;font-weight:700;color:#17223b}
+ .classpulse-trend-hero-copy p{margin:8px 0 0;max-width:300px;font-size:12px;line-height:1.55;color:#667085}
+ .classpulse-trend-metrics{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:10px;width:100%;max-width:620px}
+ .classpulse-trend-metric{min-width:0;height:76px;padding:11px 14px;border:1px solid #e7ebf1;border-top:3px solid;border-radius:13px;background:#fff;box-shadow:0 2px 7px rgba(16,24,40,.04);box-sizing:border-box}
+ .classpulse-trend-metric-label{display:block;font-size:10px;line-height:1.25;color:#64748b;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-metric-value{margin-top:5px;font-size:19px;line-height:1.15;color:#17223b;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-metric-detail{margin-top:3px;font-size:9px;line-height:1.25;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-table-wrap{overflow-x:hidden!important;overflow-y:auto!important;height:500px!important;max-height:500px!important;scrollbar-gutter:stable}
+ .classpulse-trend-table{width:100%!important;max-width:100%!important;min-width:0!important;table-layout:fixed!important;border-collapse:collapse!important}
+ .classpulse-trend-table th,.classpulse-trend-table td{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:10px 6px!important}
+ .classpulse-trend-table th:nth-child(1),.classpulse-trend-table td:nth-child(1){width:5%!important;text-align:center}
+ .classpulse-trend-table th:nth-child(2),.classpulse-trend-table td:nth-child(2){width:28%!important}
+ .classpulse-trend-table th:nth-child(3),.classpulse-trend-table td:nth-child(3){width:18%!important}
+ .classpulse-trend-table th:nth-child(4),.classpulse-trend-table td:nth-child(4){width:13%!important}
+ .classpulse-trend-table th:nth-child(5),.classpulse-trend-table td:nth-child(5){width:13%!important}
+ .classpulse-trend-table th:nth-child(6),.classpulse-trend-table td:nth-child(6){width:10%!important}
+ .classpulse-trend-table th:nth-child(7),.classpulse-trend-table td:nth-child(7){width:13%!important}
+ .classpulse-trend-chart-stack{min-width:0}
+ .classpulse-trend-chart-stack .analysis-chart-panel{min-width:0}
+ @media(min-width:1000px){.analysis-content-grid{grid-template-columns:minmax(0,1.35fr) minmax(300px,.85fr)!important;gap:14px!important}}
+ @media(max-width:1050px){.classpulse-trend-hero{grid-template-columns:260px minmax(440px,1fr)!important}.classpulse-trend-metrics{max-width:560px}}
+ @media(max-width:900px){.classpulse-trend-hero{grid-template-columns:1fr!important}.classpulse-trend-metrics{grid-template-columns:repeat(2,minmax(0,1fr));max-width:none}.analysis-content-grid{grid-template-columns:1fr!important}}
+ @media(max-width:560px){.classpulse-trend-metrics{grid-template-columns:1fr}.classpulse-trend-table th,.classpulse-trend-table td{padding-left:4px!important;padding-right:4px!important}}
+ `;
+ document.head.appendChild(style);
+}
+
+function makeMetric(label: string, value: string, detail: string, color: string) {
+ const card = document.createElement("div");
+ card.className = "classpulse-trend-metric";
+ card.style.borderTopColor = color;
+ card.innerHTML = `${esc(label)} ${esc(value)}
${esc(detail)}
`;
+ return card;
+}
+
+function installHero(payload: AttendancePayload) {
+ const hero = document.querySelector(".analysis-hero") as HTMLElement | null;
+ if (!hero) return;
+ hero.classList.add("classpulse-trend-hero");
+ Array.from(hero.querySelectorAll(":scope > .analysis-metric")).forEach((x) => x.remove());
+ const copy = hero.querySelector(":scope > .analysis-hero-copy") as HTMLElement | null;
+ if (copy) copy.classList.add("classpulse-trend-hero-copy");
+ let metrics = hero.querySelector(":scope > .classpulse-trend-metrics") as HTMLElement | null;
+ if (!metrics) { metrics = document.createElement("div"); metrics.className = "classpulse-trend-metrics"; hero.appendChild(metrics); }
+ metrics.innerHTML = "";
+ const students = payload.students || [];
+ const prev = students.length ? students.reduce((s, x) => s + Number(x.attendancePct.prevMonth || 0), 0) / students.length : 0;
+ const curr = students.length ? students.reduce((s, x) => s + Number(x.attendancePct.currMonth || 0), 0) / students.length : 0;
+ const improving = students.filter((x) => x.attendancePct.trend === "Increasing").length;
+ const highest = [...students].sort((a, b) => Number(b.attendancePct.currMonth || 0) - Number(a.attendancePct.currMonth || 0))[0];
+ metrics.appendChild(makeMetric(`Class Average (${payload.monthsUsed?.previous || "Previous"})`, fmt(prev), "Previous month attendance", "#2563eb"));
+ metrics.appendChild(makeMetric(`Class Average (${payload.monthsUsed?.current || "Current"})`, fmt(curr), `${fmt(curr - prev)} change from previous month`, "#16a34a"));
+ metrics.appendChild(makeMetric("Students Improving", String(improving), `${students.length ? fmt((improving / students.length) * 100) : "0%"} of total students`, "#f59e0b"));
+ metrics.appendChild(makeMetric("Highest Attendance Student", highest ? fmt(Number(highest.attendancePct.currMonth || 0)) : "—", highest?.name || "Highest current-month attendance", "#7c3aed"));
+}
+
+function installTable(payload: AttendancePayload) {
+ const panel = document.querySelector(".analysis-table-panel") as HTMLElement | null;
+ const oldTable = panel?.querySelector("table.analysis-table") as HTMLTableElement | null;
+ if (!panel || !oldTable) return;
+ const students = payload.students || [];
+ const oldSearch = panel.querySelector(".analysis-panel-head input") as HTMLInputElement | null;
+ const search = oldSearch?.value || "";
+ const filtered = students.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.enrollmentNo.toLowerCase().includes(search.toLowerCase()));
+ oldTable.className = "analysis-table classpulse-trend-table";
+ oldTable.innerHTML = `S.No. Student Name Enrollment No. ${esc(payload.monthsUsed?.previous || "Month 1")} ${esc(payload.monthsUsed?.current || "Month 2")} Change Trend ${filtered.map((s, i) => { const prev = Number(s.attendancePct.prevMonth || 0); const curr = Number(s.attendancePct.currMonth || 0); const change = Math.round((curr - prev) * 10) / 10; const trend = s.attendancePct.trend; const badge = trend === "Increasing" ? "trend-up" : trend === "Decreasing" ? "trend-down" : "trend-stable"; return `${i + 1} ${esc(initials(s.name))} ${esc(s.name)}${esc(s.enrollmentNo)} ${fmt(prev)} ${fmt(curr)} ${change > 0 ? "+" : ""}${fmt(change)} ${trend === "Increasing" ? "↑ " : trend === "Decreasing" ? "↓ " : "− "}${esc(trend)} `; }).join("")} `;
+ const wrap = panel.querySelector(".analysis-table-wrap") as HTMLElement | null;
+ if (wrap) wrap.classList.add("classpulse-trend-table-wrap");
+ const pagination = panel.querySelector(".classpulse-trend-pagination");
+ pagination?.remove();
+ const count = panel.querySelector(".analysis-count") as HTMLElement | null;
+ if (count) count.textContent = `${filtered.length} Students`;
+}
+
+function installCharts() {
+ const stack = document.querySelector(".analysis-right-stack") as HTMLElement | null;
+ if (stack) stack.classList.add("classpulse-trend-chart-stack");
+}
+
+export default function ClassAnalysisAttendanceTrendFixedPage() {
+ useEffect(() => {
+ let dead = false;
+ let timer: number | undefined;
+ let running = false;
+ const run = async () => {
+ if (running || dead) return;
+ running = true;
+ const observer = (window as any).__classpulseAttendanceObserver as MutationObserver | undefined;
+ observer?.disconnect();
+ addStyles();
+ const id = getSectionId();
+ if (!id) { running = false; return; }
+ try {
+ const response = await fetch(`/api/analysis/section/${id}`);
+ if (!response.ok) return;
+ const json = await response.json();
+ if (dead) return;
+ const payload: AttendancePayload = json.data || {};
+ installHero(payload);
+ installTable(payload);
+ installCharts();
+ } catch {} finally {
+ running = false;
+ if (!dead) observer?.observe(document.body, { childList: true, subtree: true });
+ }
+ };
+ const schedule = () => { window.clearTimeout(timer); timer = window.setTimeout(run, 150); };
+ const observer = new MutationObserver(schedule);
+ (window as any).__classpulseAttendanceObserver = observer;
+ observer.observe(document.body, { childList: true, subtree: true });
+ schedule();
+ return () => { dead = true; window.clearTimeout(timer); observer.disconnect(); delete (window as any).__classpulseAttendanceObserver; };
+ }, []);
+ return ;
+}
diff --git a/pages/class-analysis-overall-heading-fixed/[sectionId].tsx b/pages/class-analysis-overall-heading-fixed/[sectionId].tsx
new file mode 100644
index 0000000..4e01523
--- /dev/null
+++ b/pages/class-analysis-overall-heading-fixed/[sectionId].tsx
@@ -0,0 +1,186 @@
+import { useEffect } from "react";
+import SectionOverallPage from "../section-analysis/[sectionId]/overall";
+
+type MarkMode = "basic" | "moderated";
+type Subject = { id: string; name: string; code: string };
+type Score = { subjectId: string; code: string; name: string; basicInternal?: number; moderatedInternal?: number; basicMax?: number };
+type Student = { enrollmentNo: string; name: string; subjects?: Score[] };
+type Payload = { subjects?: Subject[]; students?: Student[] };
+type Row = Student & { average: number; pct: number; tier: string; marks: Array<{ subject: Subject; mark: number; max: number; tier: string }> };
+
+const TIERS = ["Excellent", "Good", "Needs Attention", "Critical Risk"];
+const RANGES: Record = { Excellent: [32, 40], Good: [24, 31.99], "Needs Attention": [16, 23.99], "Critical Risk": [0, 15.99] };
+const COLORS: Record = { Excellent: "#2563eb", Good: "#16a34a", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
+const CARD_COLORS = ["#2563eb", "#16a34a", "#f59e0b", "#7c3aed", "#06b6d4"];
+
+const fmt = (n: number) => Number.isInteger(n) ? String(n) : n.toFixed(1);
+const tierFor = (pct: number) => pct >= 80 ? "Excellent" : pct >= 60 ? "Good" : pct >= 40 ? "Needs Attention" : "Critical Risk";
+const esc = (v: unknown) => String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
+
+function sectionId() {
+ return window.location.pathname.match(/\/section-analysis\/([^/]+)/)?.[1] || "";
+}
+
+function rowsFor(payload: Payload, mode: MarkMode): Row[] {
+ const subjects = (payload.subjects || []).slice(0, 6);
+ return (payload.students || []).map((student) => {
+ const marks = subjects.map((subject) => {
+ const score = student.subjects?.find((s) => s.subjectId === subject.id || s.code === subject.code);
+ const mark = Number(mode === "moderated" ? score?.moderatedInternal ?? score?.basicInternal ?? 0 : score?.basicInternal ?? 0);
+ const max = Number(score?.basicMax || 40);
+ return { subject, mark, max, tier: tierFor(max ? (mark / max) * 100 : 0) };
+ });
+ const average = marks.length ? marks.reduce((s, x) => s + x.mark, 0) / marks.length : 0;
+ const max = marks.length ? marks.reduce((s, x) => s + x.max, 0) / marks.length : 40;
+ return { ...student, marks, average, pct: max ? (average / max) * 100 : 0, tier: tierFor(max ? (average / max) * 100 : 0) };
+ });
+}
+
+function setInput(input: HTMLInputElement, value: number) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ setter?.call(input, String(value));
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+}
+
+function panel(text: string) {
+ const el = Array.from(document.querySelectorAll("h2,h3,h4,p")).find((x) => x.textContent?.trim() === text) as HTMLElement | undefined;
+ return el?.closest("section") as HTMLElement | null;
+}
+
+function metric(label: string, value: string, detail: string, color: string) {
+ const el = document.createElement("section");
+ el.className = "rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm classpulse-overall-metric";
+ el.style.borderTop = `3px solid ${color}`;
+ el.innerHTML = `${esc(label)}
${esc(value)}
${esc(detail)}
`;
+ return el;
+}
+
+function installMetrics(rows: Row[], subjects: Subject[]) {
+ const filter = document.querySelector(".at-risk-filter") as HTMLElement | null;
+ const grid = filter?.previousElementSibling as HTMLElement | null;
+ if (!grid || !grid.classList.contains("grid")) return;
+ let heading = document.querySelector(".classpulse-overall-heading") as HTMLElement | null;
+ if (!heading) {
+ heading = document.createElement("div");
+ heading.className = "classpulse-overall-heading";
+ heading.innerHTML = `Overall Analysis Overall performance overview across the six theory subjects.
`;
+ grid.parentElement?.insertBefore(heading, grid);
+ }
+ const avgs = subjects.map((subject) => {
+ const values = rows.map((r) => r.marks.find((m) => m.subject.id === subject.id)?.mark || 0);
+ return { subject, average: values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0 };
+ });
+ const bestSubject = [...avgs].sort((a, b) => b.average - a.average || a.subject.name.localeCompare(b.subject.name))[0];
+ const worstSubject = [...avgs].sort((a, b) => a.average - b.average || a.subject.name.localeCompare(b.subject.name))[0];
+ const bestStudent = [...rows].sort((a, b) => b.average - a.average || a.name.localeCompare(b.name))[0];
+ const classAverage = rows.length ? rows.reduce((s, r) => s + r.average, 0) / rows.length : 0;
+ const above80 = rows.filter((r) => r.pct > 80).length;
+
+ grid.innerHTML = "";
+ grid.className = "grid classpulse-overall-metrics";
+ grid.style.gridTemplateColumns = "repeat(5, minmax(0, 1fr))";
+ grid.style.gap = "12px";
+ grid.appendChild(metric("Class Average", `${fmt(classAverage)} / 40`, "average marks across all 6 theory subjects", CARD_COLORS[0]));
+ grid.appendChild(metric("Best Performing Subject", bestSubject ? `${fmt(bestSubject.average)} / 40` : "—", bestSubject?.subject.name || "highest subject average", CARD_COLORS[1]));
+ grid.appendChild(metric("Worst Performing Subject", worstSubject ? `${fmt(worstSubject.average)} / 40` : "—", worstSubject?.subject.name || "lowest subject average", CARD_COLORS[2]));
+ grid.appendChild(metric("Best Performing Student", bestStudent ? `${fmt(bestStudent.average)} / 40` : "—", bestStudent?.name || "highest student average", CARD_COLORS[3]));
+ grid.appendChild(metric("Students Above 80%", String(above80), "students scoring above 80% overall", CARD_COLORS[4]));
+}
+
+function installMode(mode: MarkMode, change: (m: MarkMode) => void) {
+ const controls = document.querySelector(".at-risk-filter-controls") as HTMLElement | null;
+ if (!controls) return;
+ let select = document.querySelector("#classpulse-mark-mode") as HTMLSelectElement | null;
+ if (!select) {
+ const label = document.createElement("label");
+ label.className = "classpulse-mark-mode-control";
+ label.innerHTML = `Marks Basic Marks Moderated Marks `;
+ controls.insertBefore(label, controls.firstElementChild);
+ select = label.querySelector("select");
+ select?.addEventListener("change", () => change(select?.value === "moderated" ? "moderated" : "basic"));
+ }
+ if (select.value !== mode) select.value = mode;
+}
+
+function filterState() {
+ const inputs = Array.from(document.querySelectorAll(".at-risk-filter input[type='number']")) as HTMLInputElement[];
+ const selects = Array.from(document.querySelectorAll(".at-risk-filter select")) as HTMLSelectElement[];
+ const lo = Number(inputs[0]?.value ?? 0);
+ const hi = Number(inputs[1]?.value ?? 40);
+ return { lo: Number.isFinite(lo) ? Math.max(0, Math.min(40, lo)) : 0, hi: Number.isFinite(hi) ? Math.max(0, Math.min(40, hi)) : 40, sort: selects.find((s) => s.id !== "classpulse-mark-mode")?.value || "none" };
+}
+
+function renderTable(rows: Row[]) {
+ const table = document.querySelector(".at-risk-table table") as HTMLTableElement | null;
+ if (!table) return;
+ const head = table.querySelector("thead tr");
+ const body = table.querySelector("tbody");
+ if (!head || !body) return;
+ table.style.width = "100%";
+ table.style.tableLayout = "fixed";
+ table.style.minWidth = "0";
+ const state = filterState();
+ const lo = Math.min(state.lo, state.hi), hi = Math.max(state.lo, state.hi);
+ const filtered = rows.filter((r) => r.average >= lo && r.average <= hi);
+ const ordered = [...filtered].sort((a, b) => state.sort === "asc" ? a.average - b.average || a.name.localeCompare(b.name) : state.sort === "desc" ? b.average - a.average || a.name.localeCompare(b.name) : rows.indexOf(a) - rows.indexOf(b));
+ head.innerHTML = `${state.sort === "none" ? "S.No." : "Rank"} Student Enrollment No. ${rows[0]?.marks.map((m) => `${esc(m.subject.code || m.subject.name)} `).join("") || ""}Total Average Grade `;
+ body.innerHTML = ordered.map((r, i) => {
+ const total = r.marks.reduce((s, m) => s + m.mark, 0);
+ return `${i + 1} ${esc(r.name)} ${esc(r.enrollmentNo)} ${r.marks.map((m) => `${fmt(m.mark)} `).join("")}${fmt(total)} ${fmt(r.average)} ${r.tier} `;
+ }).join("");
+ const p = Array.from(document.querySelectorAll(".at-risk-table p")).find((x) => x.textContent?.includes("Showing")) as HTMLElement | undefined;
+ if (p) p.textContent = `Showing ${ordered.length} of ${rows.length} students.`;
+}
+
+function renderDistribution(rows: Row[]) {
+ const p = panel("Distribution");
+ if (!p) return;
+ p.innerHTML = `Distribution Overall performance across the six theory subjects.
`;
+ const content = p.querySelector(".classpulse-distribution-content") as HTMLElement;
+ const counts = rows.reduce((a, r) => { a[r.tier] = (a[r.tier] || 0) + 1; return a; }, {} as Record);
+ content.innerHTML = TIERS.map((tier) => { const count = counts[tier] || 0; const width = rows.length ? Math.max(2, count / rows.length * 100) : 0; return `${tier} ${count}
`; }).join("");
+ content.querySelectorAll(".classpulse-distribution-row").forEach((row) => row.addEventListener("click", () => {
+ const range = RANGES[row.dataset.tier || ""];
+ const inputs = Array.from(document.querySelectorAll(".at-risk-filter input[type='number']")) as HTMLInputElement[];
+ if (range && inputs.length >= 2) { setInput(inputs[0], range[0]); setInput(inputs[1], range[1]); (document.querySelector(".at-risk-apply") as HTMLButtonElement | null)?.click(); }
+ }));
+}
+
+function renderTopFive(rows: Row[]) {
+ const p = panel("Top Students");
+ if (!p) return;
+ const top = [...rows].sort((a, b) => b.average - a.average || a.name.localeCompare(b.name)).slice(0, 5);
+ p.innerHTML = `Top 5 Students Highest average internal marks.
${top.map((r, i) => `${i + 1}. ${esc(r.name)}
${fmt(r.average)} / 40 `).join("")}`;
+}
+
+function styles() {
+ if (document.getElementById("classpulse-overall-fixes")) return;
+ const s = document.createElement("style"); s.id = "classpulse-overall-fixes"; s.textContent = `
+ .classpulse-overall-heading{margin:18px 0 14px}.classpulse-overall-heading h2{margin:0;color:#0f172a;font-size:20px;line-height:1.25;font-weight:600}.classpulse-overall-heading p{margin:5px 0 0;color:#64748b;font-size:12px;line-height:1.5}
+ .classpulse-overall-metrics{margin-bottom:16px}.classpulse-overall-metrics>.classpulse-overall-metric{min-width:0;min-height:88px;height:88px;overflow:hidden}
+ .classpulse-metric-label{margin:0;color:#475569;font-size:11px;line-height:1.3;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.classpulse-metric-value{margin:7px 0 0;color:#0f172a;font-size:22px;line-height:1.1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.classpulse-metric-detail{margin:5px 0 0;color:#94a3b8;font-size:9px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-mark-mode-control{min-width:0!important}.classpulse-mark-mode-control select{width:100%}.at-risk-filter-controls{display:grid!important;grid-template-columns:repeat(4,minmax(0,1fr)) minmax(120px,1fr)!important;gap:12px!important;align-items:end}
+ .at-risk-table{overflow:hidden!important}.at-risk-table table{width:100%!important;min-width:0!important;table-layout:fixed!important}.at-risk-table th,.at-risk-table td{overflow:hidden}.at-risk-table th{white-space:nowrap}.at-risk-table .classpulse-sno{width:4%!important}.at-risk-table th:nth-child(2),.at-risk-table td:nth-child(2){width:14%!important}.at-risk-table th:nth-child(3),.at-risk-table td:nth-child(3){width:12%!important}.at-risk-table th:nth-child(n+4):nth-child(-n+9),.at-risk-table td:nth-child(n+4):nth-child(-n+9){width:7%!important}.at-risk-table th:nth-child(10),.at-risk-table td:nth-child(10){width:7%!important}.at-risk-table th:nth-child(11),.at-risk-table td:nth-child(11){width:7%!important}.at-risk-table th:nth-child(12),.at-risk-table td:nth-child(12){width:14%!important}
+ .classpulse-distribution-content{display:grid;gap:4px}.classpulse-distribution-row{display:block;width:100%;text-align:left;border:0;background:transparent;padding:11px 4px;margin:0;cursor:pointer;border-radius:8px}.classpulse-distribution-row:hover{background:#f8fafc}.classpulse-distribution-row:focus-visible{outline:2px solid #4a35b3;outline-offset:2px}
+ @media(max-width:1100px){.classpulse-overall-metrics{grid-template-columns:repeat(3,minmax(0,1fr))!important}.at-risk-filter-controls{grid-template-columns:repeat(2,minmax(0,1fr))!important}}@media(max-width:700px){.classpulse-overall-metrics{grid-template-columns:repeat(2,minmax(0,1fr))!important}}@media(max-width:480px){.classpulse-overall-metrics{grid-template-columns:1fr!important}.at-risk-filter-controls{grid-template-columns:1fr!important}}
+ `; document.head.appendChild(s);
+}
+
+export default function ClassAnalysisOverallHeadingFixedPage() {
+ useEffect(() => {
+ let dead = false, observer: MutationObserver | null = null, scheduled = false, mode: MarkMode = "basic", payload: Payload = {};
+ const apply = () => {
+ scheduled = false; if (dead) return; observer?.disconnect(); styles();
+ const subjects = (payload.subjects || []).slice(0, 6); const rows = rowsFor(payload, mode); if (!rows.length) { observer?.observe(document.body,{childList:true,subtree:true}); return; }
+ installMetrics(rows, subjects); installMode(mode, (next) => { mode = next; schedule(); }); renderTable(rows); renderDistribution(rows); renderTopFive(rows);
+ observer?.observe(document.body,{childList:true,subtree:true});
+ };
+ const schedule = () => { if (scheduled || dead) return; scheduled = true; requestAnimationFrame(apply); };
+ observer = new MutationObserver(schedule); observer.observe(document.body,{childList:true,subtree:true});
+ const load = async () => { const id = sectionId(); if (!id) return; try { const r = await fetch(`/api/analysis/section/${id}/overall`); if (r.ok) { payload = (await r.json()).data || {}; schedule(); } } catch {} };
+ load(); schedule();
+ return () => { dead = true; observer?.disconnect(); };
+ }, []);
+ return ;
+}
diff --git a/pages/class-analysis-student-shell-fixed/[sectionId].tsx b/pages/class-analysis-student-shell-fixed/[sectionId].tsx
new file mode 100644
index 0000000..72e81cb
--- /dev/null
+++ b/pages/class-analysis-student-shell-fixed/[sectionId].tsx
@@ -0,0 +1,102 @@
+import { useEffect } from "react";
+import SubjectStudentReportPage from "../section-analysis/[sectionId]/students";
+
+const SHELL_CSS = `
+ .class-analysis-student-shell-fix .student-report-shell-heading { margin-top: 20px !important; margin-bottom: 20px !important; }
+ .class-analysis-student-shell-fix .student-report-shell-heading p { display: none !important; }
+ .class-analysis-student-shell-fix .student-report-profile-meta { color: #94a3b8 !important; }
+`;
+
+function syncStudentMeta(page: HTMLElement) {
+ const profile = Array.from(page.querySelectorAll("section")).find((section) => {
+ const h3 = section.querySelector("h3");
+ return h3 && section.textContent?.includes("Rank by average");
+ });
+ if (!profile) return;
+
+ const meta = Array.from(profile.querySelectorAll("p")).find((p) => p.textContent?.includes("Student report"));
+ if (!meta) return;
+
+ const name = profile.querySelector("h3")?.textContent?.trim() || "";
+ const students = (window as any).__classPulseStudentData || [];
+ const student = students.find((item: any) => item.name === name);
+ if (student) meta.textContent = `${student.enrollmentNo} · ${student.email || ""}`;
+ meta.className = "text-xs mt-1 truncate student-report-profile-meta";
+}
+
+function installFix() {
+ const page = document.querySelector("div.min-h-screen.max-w-\\[1900px\\]") as HTMLElement | null;
+ if (!page || page.dataset.classAnalysisStudentShellFix === "1") return;
+ page.dataset.classAnalysisStudentShellFix = "1";
+ page.classList.add("class-analysis-student-shell-fix");
+
+ const style = document.createElement("style");
+ style.id = "class-analysis-student-shell-fix-style";
+ style.textContent = SHELL_CSS;
+ document.head.appendChild(style);
+
+ const header = page.firstElementChild as HTMLElement | null;
+ if (header) {
+ header.className = "analysis-topbar";
+ const titleRow = header.firstElementChild as HTMLElement | null;
+ const actionRow = header.lastElementChild as HTMLElement | null;
+ if (titleRow) {
+ titleRow.className = "analysis-title-row";
+ const title = titleRow.querySelector("h1");
+ if (title) title.className = "";
+ const sync = titleRow.querySelector("p");
+ if (sync) {
+ sync.className = "analysis-sync";
+ sync.textContent = `• ${sync.textContent.trim()}`;
+ }
+ }
+ if (actionRow) actionRow.className = "analysis-top-actions";
+ }
+
+ const heading = page.querySelector("h2")?.parentElement as HTMLElement | null;
+ if (heading) {
+ heading.classList.add("student-report-shell-heading");
+ heading.querySelector("p")?.remove();
+ }
+
+ const sideNavObserver = new MutationObserver(() => {
+ const sideNav = document.querySelector(".analysis-standalone-nav") as HTMLElement | null;
+ if (sideNav) {
+ sideNav.querySelector("a.is-active")?.classList.remove("is-active");
+ sideNav.querySelector('a[href="/class-analysis"]')?.classList.add("is-active");
+ }
+ syncStudentMeta(page);
+ });
+ sideNavObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
+
+ const fetchStudentData = async () => {
+ const match = window.location.pathname.match(/\/section-analysis\/([^/]+)\/students$/);
+ if (!match) return;
+ try {
+ const res = await fetch(`/api/analysis/section/${match[1]}/overall`);
+ const json = await res.json();
+ if (json.data?.students) {
+ (window as any).__classPulseStudentData = json.data.students;
+ syncStudentMeta(page);
+ }
+ } catch {}
+ };
+ fetchStudentData();
+ syncStudentMeta(page);
+
+ (window as any).__classPulseStudentShellFixObserver = sideNavObserver;
+}
+
+export default function ClassAnalysisStudentShellFixedPage() {
+ useEffect(() => {
+ const frame = requestAnimationFrame(installFix);
+ return () => {
+ cancelAnimationFrame(frame);
+ (window as any).__classPulseStudentShellFixObserver?.disconnect?.();
+ delete (window as any).__classPulseStudentShellFixObserver;
+ document.getElementById("class-analysis-student-shell-fix-style")?.remove();
+ };
+ }, []);
+
+ return ;
+}
diff --git a/pages/class-analysis/[classId].tsx b/pages/class-analysis/[classId].tsx
index dccc470..1786d7f 100644
--- a/pages/class-analysis/[classId].tsx
+++ b/pages/class-analysis/[classId].tsx
@@ -1,87 +1,69 @@
-import { useEffect, useState } from "react";
-import { useRouter } from "next/router";
-import Link from "next/link";
+import { GetServerSideProps } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../../lib/authOptions";
+import { prisma } from "../../lib/prisma";
-interface ClassAnalysisResponse {
- className: string;
- totalStudents: number;
- subjects: {
- subjectId: string;
- subjectName: string;
- section: string;
- classAverage: number | null;
- passRate: number | null;
- computedAt: string | null;
- }[];
-}
+// Class Analysis is a class-level entry point into the existing section-based
+// analysis UI. Teachers may enter a class only through an explicit ClassAccess
+// record; teaching a subject or being listed as a proctor does not grant access.
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const classId = typeof ctx.params?.classId === "string" ? ctx.params.classId : "";
+
+ const session = await getServerSession(ctx.req, ctx.res, authOptions);
+ if (!session?.user) {
+ return { redirect: { destination: "/login", permanent: false } };
+ }
+
+ if (!classId) {
+ return { notFound: true };
+ }
+
+ const userId = (session.user as any).id as string;
+ const role = (session.user as any).role as string;
+
+ if (role === "ADMIN") {
+ const section = await prisma.section.findFirst({
+ where: { classId },
+ orderBy: { name: "asc" },
+ select: { id: true },
+ });
+
+ if (!section) return { notFound: true };
+
+ return {
+ redirect: {
+ destination: `/section-analysis/${section.id}/attendance`,
+ permanent: false,
+ },
+ };
+ }
+
+ const classAccess = await prisma.classAccess.findUnique({
+ where: { teacherId_classId: { teacherId: userId, classId } },
+ });
-export default function ClassAnalysisPage() {
- const router = useRouter();
- const { classId } = router.query;
- const [data, setData] = useState(null);
- const [error, setError] = useState("");
+ if (!classAccess) {
+ return { notFound: true };
+ }
- useEffect(() => {
- if (!classId) return;
- fetch(`/api/analysis/class/${classId}`)
- .then(async (res) => {
- const json = await res.json();
- if (!res.ok) throw new Error(json.error);
- setData(json);
- })
- .catch((e) => setError(e.message));
- }, [classId]);
+ const section = await prisma.section.findFirst({
+ where: { classId },
+ orderBy: { name: "asc" },
+ select: { id: true },
+ });
- return (
-
-
Class Analysis
- {data &&
{data.className} · {data.totalStudents} students
}
+ if (!section) {
+ return { notFound: true };
+ }
- {error && (
-
- {error}
-
- )}
+ return {
+ redirect: {
+ destination: `/section-analysis/${section.id}/attendance`,
+ permanent: false,
+ },
+ };
+};
- {data && (
-
-
-
-
- Subject
- Section
- Class Average
- Pass Rate
- Last Synced
-
-
-
-
- {data.subjects.map((s) => (
-
- {s.subjectName}
- {s.section}
-
- {s.classAverage !== null ? `${s.classAverage}%` : "—"}
-
- {s.passRate !== null ? `${s.passRate}%` : "—"}
-
- {s.computedAt ? new Date(s.computedAt).toLocaleDateString() : "Never"}
-
-
-
- View →
-
-
-
- ))}
-
-
-
- )}
-
- );
+export default function ClassAnalysisRedirect() {
+ return null;
}
diff --git a/pages/class-analysis/index.tsx b/pages/class-analysis/index.tsx
new file mode 100644
index 0000000..1ce26f2
--- /dev/null
+++ b/pages/class-analysis/index.tsx
@@ -0,0 +1,67 @@
+import { GetServerSideProps } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../../lib/authOptions";
+import { prisma } from "../../lib/prisma";
+import Link from "next/link";
+import { ArrowLeft, ArrowRight, BarChart3, BookOpen, CalendarDays, LayoutDashboard, LogOut, UserRoundPlus } from "lucide-react";
+import { signOut } from "next-auth/react";
+
+type ClassOption = { id: string; label: string };
+type Props = { classes: ClassOption[]; teacherName: string };
+
+function formatClassLabel(department: string, semester: number, classNumber: string) {
+ return `${department}-${classNumber} Sem ${semester}`;
+}
+
+export default function ClassAnalysisIndex({ classes, teacherName }: Props) {
+ const initials = teacherName.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join("") || "T";
+
+ return (
+
+
+ ClassPulse
+
+
+ Dashboard
+ Attendance Agent
+ Timetable
+ Class Analysis
+ Subject Analysis
+
+ signOut({ callbackUrl: "/login" })}> Sign out
+
+
+
+
+
Back to Dashboard
+
Choose a class Select a class assigned to you to open its analysis.
+
+
+ {classes.map((classOption) => (
+
+
+
{classOption.label} Open attendance, academic and student analysis
+
+
+ ))}
+ {!classes.length &&
No classes are assigned to you.
}
+
+
+
+
+
+ );
+}
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const session = await getServerSession(ctx.req, ctx.res, authOptions);
+ if (!session?.user) return { redirect: { destination: "/login", permanent: false } };
+ const userId = (session.user as any).id; const role = (session.user as any).role;
+ const sections = await prisma.section.findMany({
+ where: role === "ADMIN" ? undefined : { class: { classAccess: { some: { teacherId: userId } } } },
+ include: { class: { include: { department: true } } },
+ });
+ const classes = new Map();
+ for (const section of sections) classes.set(section.class.id, { id: section.class.id, label: formatClassLabel(section.class.department.name, section.class.semester, section.name) });
+ return { props: { classes: Array.from(classes.values()), teacherName: session.user.name || session.user.email } };
+};
\ No newline at end of file
diff --git a/pages/combined-analysis-fixed/[subjectId].tsx b/pages/combined-analysis-fixed/[subjectId].tsx
new file mode 100644
index 0000000..444caa9
--- /dev/null
+++ b/pages/combined-analysis-fixed/[subjectId].tsx
@@ -0,0 +1,5 @@
+import SubjectAcademicPage from "../subject-analysis/[subjectId]/academic";
+
+export default function CombinedAnalysisFixedPage() {
+ return ;
+}
diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx
index e6b4e5f..22718a2 100644
--- a/pages/dashboard.tsx
+++ b/pages/dashboard.tsx
@@ -4,6 +4,22 @@ import { authOptions } from "../lib/authOptions";
import { prisma } from "../lib/prisma";
import Link from "next/link";
import { signOut } from "next-auth/react";
+import {
+ ArrowRight,
+ BarChart3,
+ Bell,
+ BookOpen,
+ CalendarDays,
+ CheckCircle2,
+ Clock3,
+ Database,
+ FileText,
+ HelpCircle,
+ LayoutDashboard,
+ LogOut,
+ Menu,
+ UserRoundPlus,
+} from "lucide-react";
interface Props {
teacherName: string;
@@ -11,116 +27,52 @@ interface Props {
subjects: { id: string; label: string }[];
}
-export default function Dashboard({ teacherName, sections, subjects }: Props) {
- return (
-
-
-
-
-
ClassPulse
-
Welcome, {teacherName}
-
-
signOut({ callbackUrl: "/login" })}
- className="text-sm text-gray-500 hover:text-gray-900"
- >
- Sign out
-
-
-
+const quickActions = [
+ { title: "Attendance Agent", description: "Take attendance for your current class", href: "/attendance-agent", icon: UserRoundPlus, tone: "bg-[#eef1ff] text-[#4b6bff]" },
+ { title: "Timetable", description: "View your full class schedule", href: "/timetable", icon: CalendarDays, tone: "bg-[#eef7ff] text-[#4388d8]" },
+ { title: "Class Analysis", description: "Analyze performance of a class/section", href: "/class-analysis", icon: BarChart3, tone: "bg-[#eaf9f1] text-[#159b62]" },
+ { title: "Subject Analysis", description: "Deep dive into a subject in a section", href: "/subject-analysis", icon: BookOpen, tone: "bg-[#fff5e7] text-[#ee9412]" },
+];
-
- What would you like to do today?
- Choose an option below to view and analyze your data.
+const todaySchedule = [
+ { time: "10:00 AM", end: "11:00 AM", subject: "Data Analysis (DA 338 T)", section: "Section A", room: "Room 104", active: true },
+ { time: "11:15 AM", end: "12:15 PM", subject: "Discrete Structures (CS 203)", section: "Section B", room: "Room 203" },
+ { time: "1:00 PM", end: "2:00 PM", subject: "Computer Networks (CS 302)", section: "Section A", room: "Room 105" },
+ { time: "3:00 PM", end: "4:00 PM", subject: "Data Analysis (DA 338 T)", section: "Section B", room: "Room 104" },
+];
-
-
- Class Analysis
-
- Combined attendance and exam performance for a whole class/section.
-
-
- {sections.map((s) => (
-
- {s.label}
-
- ))}
- {sections.length === 0 && (
-
No classes assigned yet.
- )}
-
-
+const recentActivity = [
+ { icon: CheckCircle2, tone: "text-[#159b62] bg-[#eaf9f1]", text: "Attendance recorded for DA 338 T — Section A", time: "10:05 AM" },
+ { icon: FileText, tone: "text-[#6c55e8] bg-[#f0edff]", text: "Midsem marks updated for DA 338 T — Section A", time: "Yesterday, 4:30 PM" },
+ { icon: CheckCircle2, tone: "text-[#159b62] bg-[#eaf9f1]", text: "Attendance recorded for CS 203 — Section B", time: "Aug 28, 12:20 PM" },
+ { icon: BarChart3, tone: "text-[#ee9412] bg-[#fff5e7]", text: "Class analysis viewed for ECE — B.Tech ECE, Sem 7", time: "Aug 28, 11:15 AM" },
+ { icon: CalendarDays, tone: "text-[#4388d8] bg-[#eef7ff]", text: "Timetable updated", time: "Aug 27, 3:45 PM" },
+];
-
- Subject Analysis
-
- Detailed attendance, marks, and student reports for one subject.
-
-
- {subjects.map((s) => (
-
- {s.label}
-
- ))}
- {subjects.length === 0 && (
-
No subjects assigned yet.
- )}
-
-
-
-
+export default function Dashboard({ teacherName, sections, subjects }: Props) {
+ const initials = teacherName.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join("");
+ return (
+
+
+ ClassPulse
+
+ Dashboard Attendance Agent Timetable Class Analysis Subject Analysis Raw Data Notifications
+ Need Help?
Visit our help center or contact support.
Help Center signOut({ callbackUrl: "/login" })} className="mt-5 flex w-full items-center gap-3 border-t border-[#eeeeeb] px-3 pt-5 text-sm font-medium text-[#626b80] hover:text-[#17223b]"> Sign out
+
+
+
Quick Actions Jump straight into the tools you use most.
{quickActions.map((action) => { const Icon = action.icon; return
{action.title} {action.description} ; })}
+
Today's Timetable Your scheduled classes for today.
View full timetable
{todaySchedule.map((item, index) =>
{item.time} {item.end}
{index < todaySchedule.length - 1 &&
}
{item.subject}
{item.section} • {item.room}
{item.active &&
Next class}
)}
+ Recent Activity Your latest activity.
{recentActivity.map((item) => { const Icon = item.icon; return
; })}
+
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerSession(ctx.req, ctx.res, authOptions);
- if (!session?.user) {
- return { redirect: { destination: "/login", permanent: false } };
- }
-
- const userId = (session.user as any).id;
- const role = (session.user as any).role;
-
- // Sections the teacher can view: they proctor the class, or teach a
- // subject within that section.
- const sectionsRaw =
- role === "ADMIN"
- ? await prisma.section.findMany({ include: { class: { include: { department: true } } } })
- : await prisma.section.findMany({
- where: {
- OR: [
- { class: { proctorId: userId } },
- { subjects: { some: { assignments: { some: { teacherId: userId } } } } },
- ],
- },
- include: { class: { include: { department: true } } },
- });
-
- const subjectsRaw = await prisma.subject.findMany({
- where: role === "ADMIN" ? {} : { assignments: { some: { teacherId: userId } } },
- include: { section: { include: { class: true } } },
- });
-
- return {
- props: {
- teacherName: session.user.name || session.user.email,
- sections: sectionsRaw.map((s) => ({
- id: s.id,
- label: `${s.class.department.name} — ${s.class.program}, Sem ${s.class.semester}, Section ${s.name}`,
- })),
- subjects: subjectsRaw.map((s) => ({
- id: s.id,
- label: `${s.name} (${s.code}) — Section ${s.section.name}`,
- })),
- },
- };
+ if (!session?.user) return { redirect: { destination: "/login", permanent: false } };
+ const userId = (session.user as any).id; const role = (session.user as any).role;
+ const sections = await prisma.section.findMany({ where: role === "ADMIN" ? undefined : { OR: [{ class: { proctorId: userId } }, { subjects: { some: { assignments: { some: { teacherId: userId } } } } }] }, include: { class: { include: { department: true } } } });
+ const subjects = await prisma.subject.findMany({ where: role === "ADMIN" ? undefined : { assignments: { some: { teacherId: userId } } }, include: { section: { include: { class: true } } } });
+ return { props: { sections: sections.map((s) => ({ id: s.id, label: `${s.class.department.name} — B.Tech ${s.class.department.name}, Sem ${s.class.semester}, Section ${s.name}` })), subjects: subjects.map((s) => ({ id: s.id, label: `${s.code} — ${s.name}` })), teacherName: session.user.name || session.user.email } };
};
diff --git a/pages/section-analysis/[sectionId]/academic.tsx b/pages/section-analysis/[sectionId]/academic.tsx
index acf4eb9..40a0dcd 100644
--- a/pages/section-analysis/[sectionId]/academic.tsx
+++ b/pages/section-analysis/[sectionId]/academic.tsx
@@ -1,680 +1,200 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
-import {
- BarChart,
- Bar,
- XAxis,
- YAxis,
- Tooltip,
- ResponsiveContainer,
- CartesianGrid,
- PieChart,
- Pie,
- Cell,
-} from "recharts";
-
+import { BarChart3, BookOpen, GraduationCap, LayoutDashboard, RefreshCw } from "lucide-react";
import AnalysisNav from "../../../components/AnalysisNav";
+import { RawDataButton } from "../../../components/AnalysisWidgets";
import { SectionAnalysis } from "../../../lib/analysisClass";
-import { RawDataButton, StatCard, GradeBadge, RankedList } from "../../../components/AnalysisWidgets";
type AcademicView = "midsem1" | "midsem2" | "combined" | "summary";
-type ExamKey = "midsem1" | "midsem2" | "combined" | "max";
-
-const TIER_COLORS: Record
= {
- Excellent: "#10b981",
- Good: "#3b82f6",
- "Needs Attention": "#f59e0b",
- "Critical Risk": "#ef4444",
-};
-
-function median(values: number[]): number {
- if (values.length === 0) return 0;
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 !== 0 ? sorted[mid] : round1((sorted[mid - 1] + sorted[mid]) / 2);
+type ScoreBasis = "midsem1" | "midsem2" | "combined" | "max";
+type SortOrder = "none" | "highToLow" | "lowToHigh";
+type SummaryExam = "combined" | "midsem1" | "midsem2" | "max";
+type SummarySort = "none" | "desc" | "asc";
+type Tier = "Excellent" | "Good" | "Needs Attention" | "Critical Risk";
+
+const TIER_COLORS: Record = { Excellent: "#2563eb", Good: "#15966a", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
+const METRIC_COLORS = ["#2563eb", "#15966a", "#f59e0b", "#7c3aed"];
+const SUMMARY_COLORS = ["#2563eb", "#15966a", "#f59e0b", "#2563eb", "#15966a"];
+const round1 = (n: number) => Math.round(n * 10) / 10;
+const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
+
+function median(values: number[]) { if (!values.length) return 0; const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : round1((sorted[middle - 1] + sorted[middle]) / 2); }
+function tierFor(marks: number, max: number): Tier { if (max <= 0) return "Critical Risk"; const percentage = (marks / max) * 100; if (percentage >= 80) return "Excellent"; if (percentage >= 60) return "Good"; if (percentage >= 40) return "Needs Attention"; return "Critical Risk"; }
+function gradeFor(marks: number, max: number): Tier { return tierFor(marks, max); }
+
+function summarySubjectRows(student: any) {
+ const map = new Map();
+ (student.examMarks?.midsem1Subjects || []).forEach((s: any) => map.set(s.code, { code: s.code, first: s.marks ?? null, second: null, max: s.max || 30 }));
+ (student.examMarks?.midsem2Subjects || []).forEach((s: any) => {
+ const existing = map.get(s.code);
+ if (existing) existing.second = s.marks ?? null;
+ else map.set(s.code, { code: s.code, first: null, second: s.marks ?? null, max: s.max || 30 });
+ });
+ return Array.from(map.values()).map((s) => ({ ...s, combined: s.first != null && s.second != null ? round1((s.first + s.second) / 2) : s.first ?? s.second }));
}
-
-function round1(n: number) {
- return Math.round(n * 10) / 10;
+function summarySubjectValue(subject: any, exam: SummaryExam) {
+ if (exam === "midsem1") return subject.first;
+ if (exam === "midsem2") return subject.second;
+ if (exam === "max") return subject.first == null ? subject.second : subject.second == null ? subject.first : Math.max(subject.first, subject.second);
+ return subject.combined;
}
-// Grades a single exam score against its own max, same thresholds used
-// throughout the app (Excellent/Good/Needs Attention/Critical Risk).
-function gradeFor(marks: number, max: number): string {
- if (max <= 0) return "Critical Risk";
- const pct = (marks / max) * 100;
- if (pct >= 80) return "Excellent";
- if (pct >= 60) return "Good";
- if (pct >= 40) return "Needs Attention";
- return "Critical Risk";
+function Metric({ label, value, detail, color }: { label: string; value: string | number; detail: string; color: string }) {
+ return
+
{label}
+
{value}
+
{detail}
+
;
}
export default function AcademicPage() {
const router = useRouter();
const { sectionId } = router.query;
-
const [view, setView] = useState("midsem1");
-
+ const [scoreBasis, setScoreBasis] = useState("combined");
+ const [sortOrder, setSortOrder] = useState("none");
const [data, setData] = useState(null);
const [sheetId, setSheetId] = useState(null);
const [computedAt, setComputedAt] = useState("");
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState("");
-
- // Combined view controls
- const [gradeBasis, setGradeBasis] = useState("combined");
- const [sortDir, setSortDir] = useState<"desc" | "asc" | "none">("desc");
-
- // Summary (filter) view controls
- const [filterExam, setFilterExam] = useState<"midsem1" | "midsem2" | "both">("both");
- const [lowerBound, setLowerBound] = useState(0);
- const [upperBound, setUpperBound] = useState(100);
-
- async function loadAnalysis() {
- if (!sectionId || typeof sectionId !== "string") return;
- setLoading(true);
- setError("");
- try {
- const res = await fetch(`/api/analysis/section/${sectionId}`);
- const json = await res.json();
- if (!res.ok) {
- setError(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to load academic analysis");
- return;
- }
- setData(json.data);
- setComputedAt(json.computedAt);
- setSheetId(json.sheetId || null);
- } catch (e: any) {
- setError(e.message || "Failed to load academic analysis");
- } finally {
- setLoading(false);
- }
- }
-
- async function syncAnalysis() {
+ const [selectedTier, setSelectedTier] = useState(null);
+ const [summaryExam, setSummaryExam] = useState("combined");
+ const [summaryTier, setSummaryTier] = useState("all");
+ const [summaryLower, setSummaryLower] = useState("0");
+ const [summaryUpper, setSummaryUpper] = useState("12");
+ const [summarySort, setSummarySort] = useState("none");
+ const [summaryExamDraft, setSummaryExamDraft] = useState("combined");
+ const [summaryTierDraft, setSummaryTierDraft] = useState("all");
+ const [summaryLowerDraft, setSummaryLowerDraft] = useState("0");
+ const [summaryUpperDraft, setSummaryUpperDraft] = useState("12");
+ const [summarySortDraft, setSummarySortDraft] = useState("none");
+
+ async function loadAnalysis(sync = false) {
if (!sectionId || typeof sectionId !== "string") return;
- setSyncing(true);
- setError("");
+ sync ? setSyncing(true) : setLoading(true); setError("");
try {
- const res = await fetch(`/api/analysis/section/${sectionId}?sync=1`);
+ const res = await fetch(`/api/analysis/section/${sectionId}${sync ? "?sync=1" : ""}`);
const json = await res.json();
- if (!res.ok) {
- setError(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to sync analysis");
- return;
- }
- setData(json.data);
- setComputedAt(json.computedAt);
- setSheetId(json.sheetId || null);
- } catch (e: any) {
- setError(e.message || "Failed to sync analysis");
- } finally {
- setSyncing(false);
- }
+ if (!res.ok) throw new Error(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to load academic analysis");
+ setData(json.data); setComputedAt(json.computedAt || ""); setSheetId(json.sheetId || null);
+ } catch (e: any) { setError(e.message || "Failed to load academic analysis"); }
+ finally { setLoading(false); setSyncing(false); }
}
- useEffect(() => {
- loadAnalysis();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sectionId]);
-
+ useEffect(() => { loadAnalysis(); }, [sectionId]);
+ useEffect(() => { setSelectedTier(null); setSortOrder("none"); }, [view]);
const students = data?.students || [];
- // ---------- Single-exam view (MidSem 1 / MidSem 2) ----------
- function useSingleExamStats(examKey: "midsem1" | "midsem2") {
- return useMemo(() => {
- const rows = students
- .map((s: any) => ({
- enrollmentNo: s.enrollmentNo,
- name: s.name,
- marks: examKey === "midsem1" ? s.examMarks.midsem1 : s.examMarks.midsem2,
- max: examKey === "midsem1" ? s.examMarks.midsem1Max : s.examMarks.midsem2Max,
- subjects: examKey === "midsem1" ? s.examMarks.midsem1Subjects : s.examMarks.midsem2Subjects,
- }))
- .filter((r: any) => r.marks !== null);
-
- const marksList = rows.map((r: any) => r.marks as number);
- const max = rows[0]?.max || 1;
- const subjectCodes: string[] = rows[0]?.subjects?.map((s: any) => s.code) || [];
-
- const classAverage = marksList.length ? round1(marksList.reduce((a, b) => a + b, 0) / marksList.length) : 0;
- const classMedian = median(marksList);
- const highest = marksList.length ? Math.max(...marksList) : 0;
- // Pass = at least 40% of the exam's total max marks (consistent with the
- // 12/30-per-subject rule, scaled up across however many subjects this exam has).
- const passRate = marksList.length
- ? Math.round((rows.filter((r: any) => r.marks >= max * 0.4).length / marksList.length) * 100)
- : 0;
-
- const tiers = { Excellent: 0, Good: 0, "Needs Attention": 0, "Critical Risk": 0 } as Record;
- rows.forEach((r: any) => {
- tiers[gradeFor(r.marks, max)]++;
- });
-
- const sorted = [...rows].sort((a: any, b: any) => b.marks - a.marks);
-
- return { rows, max, subjectCodes, classAverage, classMedian, highest, passRate, tiers, sorted };
- }, [students, examKey]);
+ const activeStats = useMemo(() => {
+ const rows = students.map((student: any, index: number) => {
+ if (view === "combined") {
+ const combinedMarks = student.examMarks?.combined ?? null;
+ const combinedMax = Math.max(student.examMarks?.midsem1Max || 0, student.examMarks?.midsem2Max || 0);
+ const midsem1Subjects = student.examMarks?.midsem1Subjects || [];
+ const midsem2Subjects = student.examMarks?.midsem2Subjects || [];
+ const subjectMap = new Map();
+ [...midsem1Subjects, ...midsem2Subjects].forEach((subject: any) => {
+ const existing = subjectMap.get(subject.code);
+ if (!existing) { subjectMap.set(subject.code, { code: subject.code, marks: subject.marks ?? null, max: subject.max || 0, pass: subject.pass !== false }); return; }
+ const values = [existing.marks, subject.marks].filter((value): value is number => value !== null && value !== undefined);
+ const subjectCombined = values.length ? round1(values.reduce((a, b) => a + b, 0) / values.length) : null;
+ subjectMap.set(subject.code, { code: subject.code, marks: subjectCombined, max: Math.max(existing.max || 0, subject.max || 0), pass: subjectCombined === null ? true : subjectCombined >= Math.max(existing.max || 0, subject.max || 0) * 0.4 });
+ });
+ let marks = combinedMarks; let max = combinedMax;
+ if (scoreBasis === "midsem1") { marks = student.examMarks?.midsem1 ?? null; max = student.examMarks?.midsem1Max || 0; }
+ else if (scoreBasis === "midsem2") { marks = student.examMarks?.midsem2 ?? null; max = student.examMarks?.midsem2Max || 0; }
+ else if (scoreBasis === "max") {
+ const midsem1 = student.examMarks?.midsem1; const midsem2 = student.examMarks?.midsem2;
+ const available = [midsem1, midsem2].filter((value: any): value is number => value !== null && value !== undefined);
+ marks = available.length ? Math.max(...available) : null; max = Math.max(student.examMarks?.midsem1Max || 0, student.examMarks?.midsem2Max || 0);
+ }
+ return { sno: index + 1, enrollmentNo: student.enrollmentNo, name: student.name, marks, max, subjects: Array.from(subjectMap.values()) };
+ }
+ const exam = view === "midsem1" ? "midsem1" : "midsem2";
+ const marks = student.examMarks?.[exam] ?? null;
+ const max = student.examMarks?.[`${exam}Max`] || 0;
+ const subjects = student.examMarks?.[`${exam}Subjects`] || [];
+ return { sno: index + 1, enrollmentNo: student.enrollmentNo, name: student.name, marks, max, subjects };
+ }).filter((row: any) => row.marks !== null);
+ const marks = rows.map((row: any) => Number(row.marks));
+ const max = rows[0]?.max || 0;
+ const counts = { Excellent: 0, Good: 0, "Needs Attention": 0, "Critical Risk": 0 } as Record;
+ rows.forEach((row: any) => counts[tierFor(Number(row.marks), Number(row.max) || max)]++);
+ const subjectCodes = Array.from(new Set(rows.flatMap((row: any) => (row.subjects || []).map((subject: any) => subject.code))));
+ return { rows, max, subjectCodes, average: marks.length ? round1(marks.reduce((a, b) => a + b, 0) / marks.length) : 0, median: median(marks), highest: marks.length ? Math.max(...marks) : 0, passRate: marks.length ? Math.round(rows.filter((row: any) => Number(row.marks) >= (Number(row.max) || max) * 0.4).length / marks.length * 100) : 0, counts, sorted: [...rows].sort((a: any, b: any) => Number(b.marks) - Number(a.marks)) };
+ }, [students, view, scoreBasis]);
+
+ const filteredRows = selectedTier ? activeStats.rows.filter((row: any) => tierFor(Number(row.marks), Number(row.max) || activeStats.max) === selectedTier) : activeStats.rows;
+ const displayedRows = useMemo(() => { if (sortOrder === "highToLow") return [...filteredRows].sort((a: any, b: any) => Number(b.marks) - Number(a.marks)); if (sortOrder === "lowToHigh") return [...filteredRows].sort((a: any, b: any) => Number(a.marks) - Number(b.marks)); return filteredRows; }, [filteredRows, sortOrder]);
+ const showRank = sortOrder !== "none";
+ const totalStudents = students.length;
+ const activeLabel = view === "midsem1" ? "Midsem 1" : view === "midsem2" ? "Midsem 2" : "Combined";
+ const tierEntries = Object.entries(activeStats.counts) as [Tier, number][];
+ const highestNames = activeStats.rows.filter((row: any) => Number(row.marks) === activeStats.highest).map((row: any) => row.name);
+
+ const summaryRows = useMemo(() => students.map((student: any, index: number) => {
+ const subjects = summarySubjectRows(student);
+ const values = subjects.map((subject) => ({ ...subject, value: summarySubjectValue(subject, summaryExam) }));
+ const total = values.reduce((sum, subject) => sum + (subject.value ?? 0), 0);
+ const rowValue = summaryExam === "midsem1" ? (student.examMarks?.midsem1 ?? 0) : summaryExam === "midsem2" ? (student.examMarks?.midsem2 ?? 0) : summaryExam === "max" ? (student.examMarks?.max ?? 0) : (student.examMarks?.combined ?? 0);
+ const rowMax = summaryExam === "midsem1" ? (student.examMarks?.midsem1Max || 0) : summaryExam === "midsem2" ? (student.examMarks?.midsem2Max || 0) : summaryExam === "max" ? Math.max(student.examMarks?.midsem1Max || 0, student.examMarks?.midsem2Max || 0) : Math.max(student.examMarks?.midsem1Max || 0, student.examMarks?.midsem2Max || 0);
+ const lower = Number(summaryLower); const upper = Number(summaryUpper);
+ const matchesRange = values.some((subject) => subject.value != null && subject.value >= (Number.isFinite(lower) ? lower : 0) && subject.value <= (Number.isFinite(upper) ? upper : 30));
+ return { sno: index + 1, enrollmentNo: student.enrollmentNo, name: student.name, subjects: values, total, rowValue, rowMax, tier: tierFor(rowValue, rowMax), matchesRange };
+ }), [students, summaryExam, summaryLower, summaryUpper]);
+ const summarySubjectCodes = useMemo(() => Array.from(new Set(summaryRows.flatMap((row) => row.subjects.map((subject: any) => subject.code)))), [summaryRows]);
+ const filteredSummaryRows = useMemo(() => [...summaryRows].filter((row) => row.matchesRange && (summaryTier === "all" || row.tier === summaryTier)).sort((a, b) => summarySort === "none" ? a.sno - b.sno : summarySort === "desc" ? b.rowValue - a.rowValue : a.rowValue - b.rowValue), [summaryRows, summaryTier, summarySort]);
+ const summaryShowRank = summarySort !== "none";
+ const combinedValues = students.map((student: any) => Number(student.examMarks?.combined || 0));
+ const midsem1Values = students.map((student: any) => Number(student.examMarks?.midsem1 || 0));
+ const midsem2Values = students.map((student: any) => Number(student.examMarks?.midsem2 || 0));
+ const overallClassAverage = combinedValues.length ? round1(combinedValues.reduce((a, b) => a + b, 0) / combinedValues.length) : 0;
+ const midsem1Average = midsem1Values.length ? round1(midsem1Values.reduce((a, b) => a + b, 0) / midsem1Values.length) : 0;
+ const midsem2Average = midsem2Values.length ? round1(midsem2Values.reduce((a, b) => a + b, 0) / midsem2Values.length) : 0;
+ const highestCombined = combinedValues.length ? Math.max(...combinedValues) : 0;
+ const highestCombinedNames = students.filter((student: any) => Number(student.examMarks?.combined || 0) === highestCombined).map((student: any) => student.name);
+ const overallPassRate = students.length ? Math.round(students.filter((student: any) => Number(student.examMarks?.combined || 0) >= (Math.max(student.examMarks?.midsem1Max || 0, student.examMarks?.midsem2Max || 0) || 1) * 0.4).length / students.length * 100) : 0;
+ const increases = students.map((student: any) => ({ name: student.name, change: round1(Number(student.examMarks?.midsem2 || 0) - Number(student.examMarks?.midsem1 || 0)) })).filter((row) => row.change > 0).sort((a, b) => b.change - a.change).slice(0, 5);
+ const decreases = students.map((student: any) => ({ name: student.name, change: round1(Number(student.examMarks?.midsem2 || 0) - Number(student.examMarks?.midsem1 || 0)) })).filter((row) => row.change < 0).sort((a, b) => a.change - b.change).slice(0, 5);
+
+ function applySummaryFilters() {
+ setSummaryExam(summaryExamDraft); setSummaryTier(summaryTierDraft); setSummaryLower(summaryLowerDraft); setSummaryUpper(summaryUpperDraft); setSummarySort(summarySortDraft);
}
- const midsem1Stats = useSingleExamStats("midsem1");
- const midsem2Stats = useSingleExamStats("midsem2");
-
- function renderSingleExamView(examLabel: string, stats: ReturnType) {
- const pieData = Object.entries(stats.tiers).map(([name, value]) => ({
- name,
- value,
- color: TIER_COLORS[name],
- }));
-
- return (
- <>
-
-
{examLabel}
-
Marks, class stats, and top/at-risk students for {examLabel}.
-
-
-
-
- Data Sheet
- Pass mark per subject: 12/30 (40%)
-
-
-
-
- Name
- {stats.subjectCodes.map((code: string) => (
-
- {code}
-
- ))}
- Total
- %age
-
-
-
- {stats.rows.map((r: any) => {
- const pct = stats.max > 0 ? Math.round((r.marks / stats.max) * 100) : 0;
- return (
-
-
- {r.name}
-
- {(r.subjects || []).map((s: any) => (
-
- {s.marks === null ? "—" : s.marks}
-
- ))}
- {r.marks}
- {pct}%
-
- );
- })}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Performance Tier
-
- {Object.entries(stats.tiers).map(([name, count]) => (
-
- ))}
-
-
-
-
- {pieData.map((entry, i) => (
- |
- ))}
-
-
-
-
-
-
-
-
- ({ name: r.name, marks: r.marks }))}
- />
-
-
- ({ name: r.name, marks: r.marks }))}
- />
-
-
-
-
- >
- );
+ function resetSummaryFilters() {
+ setSummaryExam("combined"); setSummaryTier("all"); setSummaryLower("0"); setSummaryUpper("12"); setSummarySort("none");
+ setSummaryExamDraft("combined"); setSummaryTierDraft("all"); setSummaryLowerDraft("0"); setSummaryUpperDraft("12"); setSummarySortDraft("none");
}
- // ---------- Combined view ----------
- const combinedRows = useMemo(() => {
- return students.map((s: any) => {
- const values: Record = {
- midsem1: s.examMarks.midsem1 ?? 0,
- midsem2: s.examMarks.midsem2 ?? 0,
- combined: s.examMarks.combined,
- max: s.examMarks.max,
- };
- const basisValue = values[gradeBasis];
- const basisMax = Math.max(s.examMarks.midsem1Max, s.examMarks.midsem2Max) || 1;
- return {
- enrollmentNo: s.enrollmentNo,
- name: s.name,
- email: s.email,
- midsem1: s.examMarks.midsem1,
- midsem2: s.examMarks.midsem2,
- combined: s.examMarks.combined,
- max: s.examMarks.max,
- grade: gradeFor(basisValue, basisMax),
- sortValue: basisValue,
- };
- });
- }, [students, gradeBasis]);
-
- const sortedCombinedRows = useMemo(() => {
- if (sortDir === "none") return combinedRows;
- const sorted = [...combinedRows].sort((a, b) => a.sortValue - b.sortValue);
- return sortDir === "desc" ? sorted.reverse() : sorted;
- }, [combinedRows, sortDir]);
-
- const combinedGradeCounts = useMemo(() => {
- const counts = { Excellent: 0, Good: 0, "Needs Attention": 0, "Critical Risk": 0 } as Record;
- combinedRows.forEach((r) => counts[r.grade]++);
- return counts;
- }, [combinedRows]);
-
- const combinedTop5 = useMemo(
- () => [...combinedRows].sort((a, b) => b.sortValue - a.sortValue).slice(0, 5),
- [combinedRows]
- );
-
- const combinedBottom5 = useMemo(
- () => [...combinedRows].sort((a, b) => a.sortValue - b.sortValue).slice(0, 5),
- [combinedRows]
- );
-
- // ---------- Summary (filter) view ----------
- // Per-subject filter: for each student, check every subject's mark (in
- // whichever exam(s) are selected) against the bounds. One result row per
- // matching student+subject+exam — a student can appear more than once if
- // they fall in range on multiple subjects.
- const filteredSummaryRows = useMemo(() => {
- const rows: { enrollmentNo: string; name: string; exam: string; subject: string; marks: number }[] = [];
-
- students.forEach((s: any) => {
- const examsToCheck: { label: string; subjects: any[] }[] = [];
- if (filterExam === "midsem1" || filterExam === "both") {
- examsToCheck.push({ label: "Midsem 1", subjects: s.examMarks.midsem1Subjects || [] });
- }
- if (filterExam === "midsem2" || filterExam === "both") {
- examsToCheck.push({ label: "Midsem 2", subjects: s.examMarks.midsem2Subjects || [] });
- }
-
- examsToCheck.forEach((exam) => {
- exam.subjects.forEach((subj: any) => {
- if (subj.marks === null) return;
- if (subj.marks >= lowerBound && subj.marks <= upperBound) {
- rows.push({
- enrollmentNo: s.enrollmentNo,
- name: s.name,
- exam: exam.label,
- subject: subj.code,
- marks: subj.marks,
- });
- }
- });
- });
- });
-
- return rows;
- }, [students, filterExam, lowerBound, upperBound]);
-
- const summaryStats = useMemo(() => {
- const combinedValues = students.map((s: any) => s.examMarks.combined);
- const overallAverage = combinedValues.length
- ? round1(combinedValues.reduce((a: number, b: number) => a + b, 0) / combinedValues.length)
- : 0;
- const highest = combinedValues.length ? Math.max(...combinedValues) : 0;
- const lowest = combinedValues.length ? Math.min(...combinedValues) : 0;
- const passCount = combinedValues.filter((v: number) => v > 0).length;
- const passRate = combinedValues.length ? Math.round((passCount / combinedValues.length) * 100) : 0;
-
- const midsem1Values = students.map((s: any) => s.examMarks.midsem1 ?? 0);
- const midsem2Values = students.map((s: any) => s.examMarks.midsem2 ?? 0);
- const midsem1Avg = midsem1Values.length
- ? round1(midsem1Values.reduce((a: number, b: number) => a + b, 0) / midsem1Values.length)
- : 0;
- const midsem2Avg = midsem2Values.length
- ? round1(midsem2Values.reduce((a: number, b: number) => a + b, 0) / midsem2Values.length)
- : 0;
-
- const changes = students
- .map((s: any) => ({
- name: s.name,
- change: round1((s.examMarks.midsem2 ?? 0) - (s.examMarks.midsem1 ?? 0)),
- }))
- .sort((a, b) => b.change - a.change);
-
- return {
- overallAverage,
- highest,
- lowest,
- passRate,
- midsem1Avg,
- midsem2Avg,
- increases: changes.slice(0, 5),
- decreases: [...changes].reverse().slice(0, 5),
- };
- }, [students]);
-
- return (
-
-
-
-
Class / Section Analysis
- {computedAt && (
-
Last synced {new Date(computedAt).toLocaleString()}
- )}
-
-
-
-
- {syncing ? "Syncing..." : "Sync now"}
-
-
-
-
+ return
+
+
+
Class / Section Analysis {computedAt && • Last synced {new Date(computedAt).toLocaleString()} } loadAnalysis(true)} disabled={syncing}> {syncing ? "Syncing..." : "Sync now"}
{typeof sectionId === "string" && }
-
-
- {(
- [
- { key: "midsem1", label: "Midsem 1" },
- { key: "midsem2", label: "Midsem 2" },
- { key: "combined", label: "Combined" },
- { key: "summary", label: "Summary" },
- ] as { key: AcademicView; label: string }[]
- ).map((tab) => (
- setView(tab.key)}
- className={`text-sm px-4 py-2 rounded-lg font-medium ${
- view === tab.key ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
- }`}
- >
- {tab.label}
-
- ))}
-
-
- {error && {error}
}
- {loading && !data && Loading academic analysis...
}
-
- {data && view === "midsem1" && renderSingleExamView("Midsem 1", midsem1Stats)}
- {data && view === "midsem2" && renderSingleExamView("Midsem 2", midsem2Stats)}
-
- {data && view === "combined" && (
- <>
-
-
-
Midsem Combined
-
- Choose which numbers grade students, then sort to see who's ahead or behind.
-
-
-
-
- Grade / Sort By
- setGradeBasis(e.target.value as ExamKey)}
- className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white"
- >
- Midsem 1
- Midsem 2
- Combined (average)
- Max (better of the two)
-
-
-
- Order
- setSortDir(e.target.value as "desc" | "asc" | "none")}
- className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white"
- >
- No Sort (Enrollment Order)
- Highest to Lowest
- Lowest to Highest
-
-
-
-
-
-
-
- All Students
-
-
-
-
- {sortDir !== "none" && Rank }
- Name
- 1st
- 2nd
- Combined
- Max
- Grade
-
-
-
- {sortedCombinedRows.map((r, i) => (
-
- {sortDir !== "none" && (
- {i + 1}
- )}
- {r.name}
- {r.midsem1 ?? "—"}
- {r.midsem2 ?? "—"}
- {r.combined}
- {r.max}
-
-
-
-
- ))}
-
-
-
-
-
-
-
- Grade Distribution
-
- ({ name, count }))}
- >
-
-
-
-
-
- {Object.keys(combinedGradeCounts).map((name, i) => (
- |
- ))}
-
-
-
-
-
-
- ({ name: r.name, marks: r.sortValue }))}
- />
-
-
-
- ({ name: r.name, marks: r.sortValue }))}
- />
-
-
-
- >
- )}
-
- {data && view === "summary" && (
- <>
-
-
Academic Summary
-
Filter students by marks range, and see who's improving.
-
-
-
-
-
- Filter Criteria
-
-
- Exam
- setFilterExam(e.target.value as any)}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white"
- >
- Both Midsem 1 & 2
- Midsem 1
- Midsem 2
-
-
-
- Lower Bound
- setLowerBound(Number(e.target.value))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
- />
-
-
- Upper Bound
- setUpperBound(Number(e.target.value))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
- />
-
-
-
-
-
-
- Filtered Results ({filteredSummaryRows.length})
-
-
-
-
- Name
- Subject
- Exam
- Marks
-
-
-
- {filteredSummaryRows.map((r, i) => (
-
- {r.name}
- {r.subject}
- {r.exam}
- {r.marks}
-
- ))}
- {filteredSummaryRows.length === 0 && (
-
-
- No students match this filter.
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ({ name: s.name, marks: s.change }))} />
-
-
- ({ name: s.name, marks: s.change }))}
- />
-
-
-
-
- >
- )}
-
- );
+
setView("midsem1")}>Midsem 1 setView("midsem2")}>Midsem 2 setView("combined")}>Combined setView("summary")}>Summary
+ {error &&
{error}
}
+ {loading && !data &&
Loading academic analysis...
}
+ {data && view !== "summary" && <>
+
+ {activeLabel} {view === "combined" ? "Combined Midsem 1 and Midsem 2 performance across all subjects." : "Marks across all subjects, class statistics, and performance tiers."}
+
+
+
+
+
+
+ Data Sheet Pass mark: 40%{selectedTier ? ` · Filtered: ${selectedTier}` : ""}
{view === "combined" &&
Score setScoreBasis(e.target.value as ScoreBasis)} className="h-9 min-w-[125px] rounded-xl border border-[#e1e4ea] bg-white px-3 text-[11px] font-semibold normal-case tracking-normal text-[#344054] outline-none focus:border-[#5b4ee6]">Midsem 1 Midsem 2 Combined Max Sort setSortOrder(e.target.value as SortOrder)} className="h-9 min-w-[125px] rounded-xl border border-[#e1e4ea] bg-white px-3 text-[11px] font-semibold normal-case tracking-normal text-[#344054] outline-none focus:border-[#5b4ee6]">No Sort High to Low Low to High
}{selectedTier &&
setSelectedTier(null)}>Clear filter }
{activeStats.subjectCodes.map((code: string) => )}{showRank ? "Rank" : "S.No."} Student Name {activeStats.subjectCodes.map((code: string) => {code} )}Total %age Grade {displayedRows.map((row: any, index: number) => { const rowMax = Number(row.max) || activeStats.max; const pct = rowMax > 0 ? Math.round(Number(row.marks) / rowMax * 100) : 0; const tier = tierFor(Number(row.marks), rowMax); const grade = gradeFor(Number(row.marks), rowMax); return {showRank ? index + 1 : row.sno} {initials(row.name)} {row.name}
{activeStats.subjectCodes.map((code: string) => { const subject = (row.subjects || []).find((item: any) => item.code === code); return {subject?.marks ?? "—"} ; })}{row.marks} {pct}% setSelectedTier(tier)} className="inline-flex max-w-full whitespace-nowrap rounded-full px-2 py-1 text-[9px] font-bold" style={{ backgroundColor: `${TIER_COLORS[grade]}18`, color: TIER_COLORS[grade] }}>{grade} ; })}
+ Performance Tier Click a tier to filter the data sheet.
{totalStudents} Students {tierEntries.map(([tier, count]) => setSelectedTier(selectedTier === tier ? null : tier)} className={`flex w-full items-center justify-between rounded-xl border px-3 py-2.5 text-left transition ${selectedTier === tier ? "border-[#cfc7ff] bg-[#f6f4ff]" : "border-[#edf0f4] bg-white hover:bg-[#fafaff]"}`}>{tier} {count} )}
{tierEntries.map(([tier, count]) => )}
Top 5 Highest Scorers {activeStats.sorted.slice(0, 5).map((row: any, index: number) =>
{index + 1}. {row.name} {row.marks}
)}
Bottom 5 At-Risk Students {activeStats.sorted.slice(-5).reverse().map((row: any, index: number) =>
{index + 1}. {row.name} {row.marks}
)}
+
+ >}
+ {data && view === "summary" && <>
+
Academic Summary Compare Midsem 1 and Midsem 2 with the same ClassPulse academic analysis theme.
+
+
{["Overall Class Average", "Highest Combined Score", "Overall Pass Rate", "Midsem 1 Average", "Midsem 2 Average"].map((label, index) => { const values = [overallClassAverage, highestCombined, `${overallPassRate}%`, midsem1Average, midsem2Average]; const details = ["combined average", highestCombinedNames[0] || "top score", "students scoring 40% or more", "out of 180 marks", "out of 180 marks"]; return {label} {values[index]} {details[index]}
; })}
+
Filtered Students Showing {filteredSummaryRows.length} of {students.length} students
{filteredSummaryRows.length} Students {summarySubjectCodes.map((code) => )}{summaryShowRank ? "Rank" : "S.No."} Enrollment No. Student {summarySubjectCodes.map((code) => {code} )}Total Tier {filteredSummaryRows.map((row, index) => {summaryShowRank ? index + 1 : row.sno} {row.enrollmentNo} {row.name} {summarySubjectCodes.map((code) => { const subject = row.subjects.find((s: any) => s.code === code); const value = subject?.value; const lower = Number(summaryLower); const upper = Number(summaryUpper); const inRange = value != null && value >= (Number.isFinite(lower) ? lower : 0) && value <= (Number.isFinite(upper) ? upper : 30); return {inRange ? value : ""} ; })}{row.total} {row.tier} )}{!filteredSummaryRows.length && No students match the selected criteria. }
Marks Increase (Top 5) Students whose Midsem 2 score improved.
{increases.map((row, index) => {index + 1}. {row.name}
+{row.change} )}Marks Decrease (Top 5) Students whose Midsem 2 score fell.
{decreases.map((row, index) => {index + 1}. {row.name}
{row.change} )}
+ >}
+
+
;
}
diff --git a/pages/section-analysis/[sectionId]/attendance.tsx b/pages/section-analysis/[sectionId]/attendance.tsx
index ca20956..900a4bd 100644
--- a/pages/section-analysis/[sectionId]/attendance.tsx
+++ b/pages/section-analysis/[sectionId]/attendance.tsx
@@ -1,710 +1,88 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useRouter } from "next/router";
-import {
- BarChart,
- Bar,
- XAxis,
- YAxis,
- Tooltip,
- ResponsiveContainer,
- CartesianGrid,
- PieChart,
- Pie,
- Cell,
- Legend,
-} from "recharts";
-
+import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { AlertCircle, BarChart3, BookOpen, Download, Gauge, GraduationCap, LayoutDashboard, Mail, RefreshCw, Search, SlidersHorizontal, Sparkles, Users } from "lucide-react";
import AnalysisNav from "../../../components/AnalysisNav";
+import { RawDataButton } from "../../../components/AnalysisWidgets";
import { SectionAnalysis } from "../../../lib/analysisClass";
-import { RawDataButton, StatCard } from "../../../components/AnalysisWidgets";
-
-type AttendanceView = "trend" | "risk" | "summary";
-const BUCKET_COLORS = {
- above75: "#10b981", // Good Standing
- to74: "#3b82f6", // Satisfactory
- to49: "#f59e0b", // Needs Attention
- below30: "#ef4444", // Critical Risk
-};
+type AttendanceView = "trend" | "risk";
+const round1 = (n: number) => Math.round(n * 10) / 10;
+const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
-export default function AttendancePage() {
+export default function ClassAttendancePage() {
const router = useRouter();
const { sectionId } = router.query;
-
const [view, setView] = useState("trend");
-
const [data, setData] = useState(null);
const [sheetId, setSheetId] = useState(null);
const [computedAt, setComputedAt] = useState("");
-
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState("");
-
- // Month selection (Trend tab)
const [previousMonth, setPreviousMonth] = useState("");
const [currentMonth, setCurrentMonth] = useState("");
const [trendCriteria, setTrendCriteria] = useState(5);
-
- // At Risk filter state
+ const [search, setSearch] = useState("");
const [lowerBound, setLowerBound] = useState(0);
const [upperBound, setUpperBound] = useState(100);
const [riskTrendFilter, setRiskTrendFilter] = useState("All");
const [riskMonth, setRiskMonth] = useState<"previous" | "current">("current");
+ const [copied, setCopied] = useState(false);
- async function loadAnalysis(selectedPrevious?: string, selectedCurrent?: string, criteria?: number) {
+ async function loadAnalysis(sync = false, previous = previousMonth, current = currentMonth, criteria = trendCriteria) {
if (!sectionId || typeof sectionId !== "string") return;
-
- setLoading(true);
+ sync ? setSyncing(true) : setLoading(true);
setError("");
-
try {
const params = new URLSearchParams();
- if (selectedPrevious) params.set("previousMonth", selectedPrevious);
- if (selectedCurrent) params.set("currentMonth", selectedCurrent);
- if (criteria !== undefined) params.set("trendCriteria", String(criteria));
-
- const query = params.toString();
- const res = await fetch(`/api/analysis/section/${sectionId}${query ? `?${query}` : ""}`);
- const json = await res.json();
-
- if (!res.ok) {
- setError(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to load attendance analysis");
- return;
- }
-
- setData(json.data);
- setComputedAt(json.computedAt);
- setSheetId(json.sheetId || null);
-
- if (json.data?.monthsUsed) {
- if (!previousMonth && json.data.monthsUsed.previous) setPreviousMonth(json.data.monthsUsed.previous);
- if (!currentMonth && json.data.monthsUsed.current) setCurrentMonth(json.data.monthsUsed.current);
- }
- } catch (e: any) {
- setError(e.message || "Failed to load attendance analysis");
- } finally {
- setLoading(false);
- }
+ if (sync) params.set("sync", "1");
+ if (previous) params.set("previousMonth", previous);
+ if (current) params.set("currentMonth", current);
+ params.set("trendCriteria", String(criteria));
+ const response = await fetch(`/api/analysis/section/${sectionId}?${params.toString()}`);
+ const json = await response.json();
+ if (!response.ok) throw new Error(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to load attendance analysis");
+ setData(json.data); setComputedAt(json.computedAt || ""); setSheetId(json.sheetId || null);
+ setPreviousMonth(previous || json.data?.monthsUsed?.previous || ""); setCurrentMonth(current || json.data?.monthsUsed?.current || "");
+ } catch (e: any) { setError(e.message || "Failed to load attendance analysis"); }
+ finally { setLoading(false); setSyncing(false); }
}
- async function syncAnalysis() {
- if (!sectionId || typeof sectionId !== "string") return;
-
- setSyncing(true);
- setError("");
-
- try {
- const params = new URLSearchParams({
- sync: "1",
- previousMonth,
- currentMonth,
- trendCriteria: String(trendCriteria),
- });
-
- const res = await fetch(`/api/analysis/section/${sectionId}?${params.toString()}`);
- const json = await res.json();
-
- if (!res.ok) {
- setError(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to sync analysis");
- return;
- }
-
- setData(json.data);
- setComputedAt(json.computedAt);
- setSheetId(json.sheetId || null);
- } catch (e: any) {
- setError(e.message || "Failed to sync analysis");
- } finally {
- setSyncing(false);
- }
- }
-
- function applyTrendSettings() {
- loadAnalysis(previousMonth, currentMonth, trendCriteria);
- }
-
- useEffect(() => {
- loadAnalysis();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sectionId]);
-
+ useEffect(() => { loadAnalysis(); }, [sectionId]);
+ const students = data?.students || [];
const availableMonths = data?.availableMonths || [];
-
- const trendData = data
- ? [
- { name: "Increasing", count: data.trendCounts?.increasing || 0 },
- { name: "Decreasing", count: data.trendCounts?.decreasing || 0 },
- { name: "Stable", count: data.trendCounts?.stable || 0 },
- ]
- : [];
-
- const attendanceBuckets = data?.attendanceBuckets ?? {
- below30: 0,
- to49: 0,
- to74: 0,
- above75: 0,
- };
-
- const attendanceDistribution = data
- ? [
- { name: "Below 30%", count: attendanceBuckets.below30 },
- { name: "30% to 49%", count: attendanceBuckets.to49 },
- { name: "50% to 74%", count: attendanceBuckets.to74 },
- { name: "75% and above", count: attendanceBuckets.above75 },
- ]
- : [];
-
- // ---- At Risk: filtered students ----
- const riskResults = useMemo(() => {
- if (!data) return [];
- return data.students.filter((s: any) => {
- const pct = riskMonth === "current" ? s.attendancePct?.currMonth ?? 0 : s.attendancePct?.prevMonth ?? 0;
- const inBounds = pct >= lowerBound && pct <= upperBound;
- const trendMatches = riskTrendFilter === "All" || (s.attendancePct?.trend || "Stable") === riskTrendFilter;
- return inBounds && trendMatches;
- });
- }, [data, lowerBound, upperBound, riskTrendFilter, riskMonth]);
-
- const riskEmails = riskResults.map((s: any) => s.email).filter(Boolean);
- const riskEmailsText = riskEmails.join("; ");
-
- const [copied, setCopied] = useState(false);
- async function copyEmails() {
- try {
- await navigator.clipboard.writeText(riskEmailsText);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // clipboard API unavailable — the textarea below still lets them select+copy manually
- }
- }
-
- const mailtoHref = `mailto:?bcc=${encodeURIComponent(riskEmails.join(","))}&subject=${encodeURIComponent(
- "Attendance Alert"
- )}&body=${encodeURIComponent(
- "This is a reminder regarding your recent attendance. Please make sure to attend upcoming classes."
- )}`;
-
- // ---- Summary: top movers (by attendance change), computed client-side ----
- const topMovers = useMemo(() => {
- if (!data) return { improved: [], declined: [] };
- const withChange = data.students.map((s: any) => ({
- name: s.name,
- change: round1((s.attendancePct?.currMonth ?? 0) - (s.attendancePct?.prevMonth ?? 0)),
- }));
- const sorted = [...withChange].sort((a, b) => b.change - a.change);
- return {
- improved: sorted.slice(0, 5),
- declined: [...sorted].reverse().slice(0, 5),
- };
- }, [data]);
-
- function round1(n: number) {
- return Math.round(n * 10) / 10;
- }
-
- const donutData = data
- ? [
- {
- name: "Good Standing (75%+)",
- value: attendanceBuckets.above75,
- color: BUCKET_COLORS.above75,
- },
- {
- name: "Satisfactory (50-74%)",
- value: attendanceBuckets.to74,
- color: BUCKET_COLORS.to74,
- },
- {
- name: "Needs Attention (30-49%)",
- value: attendanceBuckets.to49,
- color: BUCKET_COLORS.to49,
- },
- {
- name: "Critical Risk (<30%)",
- value: attendanceBuckets.below30,
- color: BUCKET_COLORS.below30,
- },
- ]
- : [];
-
- const compareBarData = data
- ? [
- { name: data.monthsUsed?.previous || "Previous", value: data.classAveragePrevMonth },
- { name: data.monthsUsed?.current || "Current", value: data.classAverageCurrMonth },
- ]
- : [];
-
- return (
-
- {/* HEADER */}
-
-
-
Class / Section Analysis
- {computedAt && (
-
Last synced {new Date(computedAt).toLocaleString()}
- )}
-
-
-
-
- {syncing ? "Syncing..." : "Sync now"}
-
-
-
-
+ const previousAverage = students.length ? round1(students.reduce((sum, s) => sum + s.attendancePct.prevMonth, 0) / students.length) : 0;
+ const currentAverage = students.length ? round1(students.reduce((sum, s) => sum + s.attendancePct.currMonth, 0) / students.length) : 0;
+ const averageChange = round1(currentAverage - previousAverage);
+ const improvingCount = students.filter((s) => s.attendancePct.trend === "Increasing").length;
+ const filteredStudents = useMemo(() => students.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())), [students, search]);
+ const riskResults = useMemo(() => students.filter((s) => { const attendance = riskMonth === "current" ? s.attendancePct.currMonth : s.attendancePct.prevMonth; return attendance >= lowerBound && attendance <= upperBound && (riskTrendFilter === "All" || s.attendancePct.trend === riskTrendFilter); }), [students, lowerBound, upperBound, riskTrendFilter, riskMonth]);
+ const riskEmails = riskResults.map((s) => s.email).filter(Boolean);
+ const riskLowCount = riskResults.filter((s) => (riskMonth === "current" ? s.attendancePct.currMonth : s.attendancePct.prevMonth) < 50).length;
+ const riskDecreasingCount = riskResults.filter((s) => s.attendancePct.trend === "Decreasing").length;
+ const trendData = [{ name: "Increasing", count: data?.trendCounts.increasing || 0, color: "#16a56a" }, { name: "Decreasing", count: data?.trendCounts.decreasing || 0, color: "#ef4444" }, { name: "Stable", count: data?.trendCounts.stable || 0, color: "#4d75d0" }];
+ const attendanceDistribution = [{ name: "Below 30%", count: data?.attendanceBuckets.below30 || 0, color: "#ef4444" }, { name: "30% to 49%", count: data?.attendanceBuckets.to49 || 0, color: "#f97316" }, { name: "50% to 74%", count: data?.attendanceBuckets.to74 || 0, color: "#f59e0b" }, { name: "75% and above", count: data?.attendanceBuckets.above75 || 0, color: "#15966a" }];
+ function openRisk(options: { trend?: string; lower?: number; upper?: number }) { setRiskTrendFilter(options.trend || "All"); setLowerBound(options.lower ?? 0); setUpperBound(options.upper ?? 100); setView("risk"); }
+ async function copyEmails() { try { await navigator.clipboard.writeText(riskEmails.join("; ")); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch {} }
+
+ return
+
+
+
Class / Section Analysis {computedAt && • Last synced {new Date(computedAt).toLocaleString()} } loadAnalysis(true)} disabled={syncing}> {syncing ? "Syncing..." : "Sync now"}
{typeof sectionId === "string" && }
-
- {/* ATTENDANCE SUB-NAV: Trend / At Risk / Summary */}
-
- {(["trend", "risk", "summary"] as AttendanceView[]).map((v) => (
- setView(v)}
- className={`text-sm px-4 py-2 rounded-lg font-medium capitalize ${
- view === v ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
- }`}
- >
- {v === "risk" ? "At Risk" : v}
-
- ))}
-
-
- {error && {error}
}
- {loading && !data && Loading attendance analysis...
}
-
- {data && view === "trend" && (
- <>
-
-
Attendance Trend
-
- Compare attendance between two months and identify increasing, decreasing, and stable students.
-
-
-
-
- Trend Comparison Settings
-
-
- First Month
- setPreviousMonth(e.target.value)}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 bg-white"
- >
- Select month
- {availableMonths.map((m: string) => (
-
- {m}
-
- ))}
-
-
-
- Second Month
- setCurrentMonth(e.target.value)}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 bg-white"
- >
- Select month
- {availableMonths.map((m: string) => (
-
- {m}
-
- ))}
-
-
-
- Trend Criteria (%)
- setTrendCriteria(Number(e.target.value))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900"
- />
-
-
- Apply Comparison
-
-
-
-
- Trend calculation: a change of{" "}
- ±{trendCriteria} percentage points or more is
- classified as Increasing or Decreasing. Smaller changes are classified as Stable.
-
-
-
-
-
-
- Student Trend Analysis
-
-
-
-
- Name
- {data.monthsUsed?.previous || "Month 1"}
- {data.monthsUsed?.current || "Month 2"}
- Change
- Trend
-
-
-
- {data.students.map((student: any) => {
- const previous = student.attendancePct?.prevMonth ?? 0;
- const current = student.attendancePct?.currMonth ?? 0;
- const change = round1(current - previous);
- const trend = student.attendancePct?.trend || "Stable";
- return (
-
- {student.name}
- {previous}%
- {current}%
- 0 ? "text-emerald-600" : change < 0 ? "text-red-500" : "text-gray-500"
- }`}
- >
- {change > 0 ? "+" : ""}
- {change}%
-
-
-
- {trend}
-
-
-
- );
- })}
-
-
-
-
-
-
-
- Trend Distribution
-
-
-
-
-
-
-
-
-
-
-
-
- Attendance Distribution
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
- {data && view === "risk" && (
- <>
-
-
At Risk Students
-
- Filter students by attendance range and trend, then copy or email the list directly.
-
-
-
-
- Filter Criteria
-
-
- Lower Bound (%)
- setLowerBound(Number(e.target.value))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900"
- />
-
-
- Upper Bound (%)
- setUpperBound(Number(e.target.value))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900"
- />
-
-
- Trend
- setRiskTrendFilter(e.target.value)}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 bg-white"
- >
- All
- Increasing
- Decreasing
- Stable
-
-
-
- Month
- setRiskMonth(e.target.value as "previous" | "current")}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 bg-white"
- >
- {data.monthsUsed?.current || "Current month"}
- {data.monthsUsed?.previous || "Previous month"}
-
-
-
-
- Note: filtering only works across the two months currently loaded in Trend settings above — pick a
- different pair there first if you need a different month here.
-
-
-
-
-
- Filtered Students ({riskResults.length})
-
-
-
-
- Enrollment
- Name
- Email
- Attendance
- Trend
-
-
-
- {riskResults.map((s: any) => (
-
- {s.enrollmentNo}
- {s.name}
- {s.email || "—"}
-
- {riskMonth === "current" ? s.attendancePct?.currMonth : s.attendancePct?.prevMonth}%
-
- {s.attendancePct?.trend}
-
- ))}
- {riskResults.length === 0 && (
-
-
- No students match this filter.
-
-
- )}
-
-
-
-
-
-
- Copy to Mail
-
-
- {copied ? "Copied!" : "Copy Emails"}
-
-
-
-
- Send Alert Emails
-
- Opens your default email app with all {riskEmails.length} filtered students BCC'd, and a starter
- subject/message you can edit before sending.
-
-
- Send Alert Emails ↗
-
-
-
- >
- )}
-
- {data && view === "summary" && (
- <>
-
-
Attendance Summary
-
Overall class attendance health at a glance.
-
-
-
-
-
-
- 0 ? "+" : ""}${data.overallTrendPct}%`}
- positive={data.overallTrendPct >= 0}
- />
-
-
-
-
- Attendance Breakdown
-
-
-
- {donutData.map((entry, i) => (
- |
- ))}
-
-
-
-
-
-
-
-
- Class Average: Previous vs Current
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Core Summary
-
-
-
- Good Standing (≥75%)
- {data.attendanceBuckets.above75}
-
-
- Satisfactory (50-74%)
- {data.attendanceBuckets.to74}
-
-
- Needs Attention (30-49%)
- {data.attendanceBuckets.to49}
-
-
- Critical Risk (<30%)
- {data.attendanceBuckets.below30}
-
-
-
-
-
-
- Attendance Shifts
-
-
-
- Improving Students
-
- {data.trendCounts?.increasing ?? 0}
-
-
-
- Declining Students
- {data.trendCounts?.decreasing ?? 0}
-
-
- Stable Students
- {data.trendCounts?.stable ?? 0}
-
-
-
-
-
-
-
-
- Top 5 Most Improved
-
- {topMovers.improved.map((s, i) => (
-
-
- {i + 1}. {s.name}
-
-
- {s.change > 0 ? "+" : ""}
- {s.change}%
-
-
- ))}
-
-
-
-
- Top 5 Critical Decliners
-
- {topMovers.declined.map((s, i) => (
-
-
- {i + 1}. {s.name}
-
-
- {s.change > 0 ? "+" : ""}
- {s.change}%
-
-
- ))}
-
-
-
- >
- )}
-
- );
+
setView("trend")}>Trend setView("risk")}>At Risk
+ {error &&
}
+ {loading && !data &&
Loading attendance analysis...
}
+ {data && view === "trend" && <>
+
Attendance Trend Compare attendance between two months and identify increasing, decreasing, and stable students.
} label={`Class Average (${previousMonth || "Previous"})`} value={`${previousAverage}%`} change={averageChange} /> } label={`Class Average (${currentMonth || "Current"})`} value={`${currentAverage}%`} change={averageChange} /> } label="Students Improving" value={improvingCount} detail={`${students.length ? round1((improvingCount / students.length) * 100) : 0}% of total students`} />
+
Trend Comparison Settings First Month setPreviousMonth(e.target.value)}>{availableMonths.map((month) => {month} )}
Second Month setCurrentMonth(e.target.value)}>{availableMonths.map((month) => {month} )}
Trend Criteria (%) setTrendCriteria(Number(e.target.value))} />
loadAnalysis(false, previousMonth, currentMonth, trendCriteria)} disabled={loading || !previousMonth || !currentMonth || previousMonth === currentMonth}> {loading ? "Applying..." : "Apply Comparison"} Trend calculation: a change of
±{trendCriteria} percentage points or more is Increasing or Decreasing.
+
Student Name {data.monthsUsed.previous || "Month 1"} {data.monthsUsed.current || "Month 2"} Change Trend {filteredStudents.map((student) => { const previous = student.attendancePct.prevMonth; const current = student.attendancePct.currMonth; const change = round1(current - previous); const trend = student.attendancePct.trend; return {initials(student.name)} {student.name}{previous}% {current}% 0 ? "change-up" : change < 0 ? "change-down" : ""}>{change > 0 ? "+" : ""}{change}% {trend === "Increasing" ? "↑ " : trend === "Decreasing" ? "↓ " : "− "}{trend} ; })}
openRisk({ trend: entry?.name || "All" })} /> { if (entry?.name === "Below 30%") openRisk({ lower: 0, upper: 29.999 }); else if (entry?.name === "30% to 49%") openRisk({ lower: 30, upper: 49.999 }); else if (entry?.name === "50% to 74%") openRisk({ lower: 50, upper: 74.999 }); else openRisk({ lower: 75, upper: 100 }); }} />
+ >}
+ {data && view === "risk" && <>
At Risk Students Find students needing attention using attendance range and trend filters.
} label="Matching Students" value={riskResults.length} detail="Current filter result" /> } label="Below 50% Attendance" value={riskLowCount} detail="Within selected result" /> } label="Decreasing Trend" value={riskDecreasingCount} detail="Students whose attendance fell" />Filter Criteria Narrow the list before copying addresses or sending alerts.
{ setLowerBound(0); setUpperBound(100); setRiskTrendFilter("All"); setRiskMonth("current"); }}> Reset
Filtered Students {riskResults.length} Students Student Enrollment Attendance Trend Email {riskResults.length ? riskResults.map((student) => { const attendance = riskMonth === "current" ? student.attendancePct.currMonth : student.attendancePct.prevMonth; const trend = student.attendancePct.trend; return {initials(student.name)} {student.name}{student.enrollmentNo} {attendance}% {trend} {student.email || "—"} ; }) : No students match the current filters. }
>}
+
+
;
}
+
+function Metric({ icon, label, value, change, detail }: { icon: ReactNode; label: string; value: string | number; change?: number; detail?: string }) { const changeClass = change !== undefined && change > 0 ? "change-up" : change !== undefined && change < 0 ? "change-down" : ""; return {icon}
{label} {value} {change !== undefined && {change > 0 ? "↑ +" : change < 0 ? "↓ " : ""}{change}% }
{detail &&
{detail} }
; }
+function ChartPanel({ title, subtitle, data, onBarClick }: { title: string; subtitle: string; data: { name: string; count: number; color: string }[]; onBarClick: (entry: { name: string } | undefined) => void }) { return onBarClick(data[index])}>{data.map((entry) => | )}
; }
diff --git a/pages/section-analysis/[sectionId]/overall.tsx b/pages/section-analysis/[sectionId]/overall.tsx
index 2f84118..c56fb3d 100644
--- a/pages/section-analysis/[sectionId]/overall.tsx
+++ b/pages/section-analysis/[sectionId]/overall.tsx
@@ -1,65 +1,86 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
-import {
- BarChart,
- Bar,
- XAxis,
- YAxis,
- Tooltip,
- ResponsiveContainer,
- CartesianGrid,
- Cell,
-} from "recharts";
-
+import { BarChart3, BookOpen, GraduationCap, LayoutDashboard, RefreshCw } from "lucide-react";
import AnalysisNav from "../../../components/AnalysisNav";
-import { RawDataButton, StatCard, GradeBadge } from "../../../components/AnalysisWidgets";
+import { RawDataButton } from "../../../components/AnalysisWidgets";
-interface OverallStudent {
+type SortDirection = "none" | "asc" | "desc";
+type Subject = { id: string; name: string; code: string };
+type SubjectScore = {
+ subjectId: string;
+ code: string;
+ name: string;
+ attendance: number;
+ midsem1: number;
+ midsem2: number;
+ combined: number;
+ basicInternal: number;
+ moderatedInternal: number;
+ basicMax: number;
+ grade: string;
+};
+type Student = {
enrollmentNo: string;
name: string;
email: string;
+ subjects: SubjectScore[];
overallPct: number;
overallAttendance: number;
overallGrade: string;
- subjects: {
- subjectId: string;
- code: string;
- name: string;
- attendance: number;
- combined: number;
- basicInternal: number;
- grade: string;
- }[];
-}
-
-interface OverallData {
- subjects: { id: string; name: string; code: string }[];
- students: OverallStudent[];
- classAverageOverallPct: number;
-}
+};
+type OverallData = { subjects: Subject[]; students: Student[]; classAverageOverallPct: number };
+type RowSubject = Subject & { mark: number; max: number; pct: number; grade: string };
+type Row = Student & {
+ originalIndex: number;
+ subjects: RowSubject[];
+ total: number;
+ totalMax: number;
+ average: number;
+ averageMax: number;
+ overallPct: number;
+ tier: string;
+};
const TIER_COLORS: Record = {
- Excellent: "#10b981",
- Good: "#3b82f6",
+ Excellent: "#2563eb",
+ Good: "#16a34a",
"Needs Attention": "#f59e0b",
"Critical Risk": "#ef4444",
};
+const tierForPct = (pct: number) =>
+ pct >= 80 ? "Excellent" : pct >= 60 ? "Good" : pct >= 40 ? "Needs Attention" : "Critical Risk";
+
+const tierClass = (tier: string) =>
+ tier === "Excellent"
+ ? "bg-blue-50 text-blue-700"
+ : tier === "Good"
+ ? "bg-green-50 text-green-700"
+ : tier === "Needs Attention"
+ ? "bg-amber-50 text-amber-700"
+ : "bg-red-50 text-red-600";
+
+const formatMark = (mark: number) => (Number.isInteger(mark) ? mark : mark.toFixed(1));
+
export default function SectionOverallPage() {
const router = useRouter();
const { sectionId } = router.query;
-
const [data, setData] = useState(null);
const [sheetId, setSheetId] = useState(null);
const [computedAt, setComputedAt] = useState("");
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState("");
+ const [lower, setLower] = useState(0);
+ const [upper, setUpper] = useState(40);
+ const [draftLower, setDraftLower] = useState(0);
+ const [draftUpper, setDraftUpper] = useState(40);
+ const [sortDirection, setSortDirection] = useState("none");
+ const [draftSortDirection, setDraftSortDirection] = useState("none");
async function loadAnalysis(sync = false) {
- if (!sectionId || typeof sectionId !== "string") return;
- if (sync) setSyncing(true);
- else setLoading(true);
+ if (typeof sectionId !== "string") return;
+ sync ? setSyncing(true) : setLoading(true);
setError("");
try {
const res = await fetch(`/api/analysis/section/${sectionId}/overall${sync ? "?sync=1" : ""}`);
@@ -69,7 +90,7 @@ export default function SectionOverallPage() {
return;
}
setData(json.data);
- setComputedAt(json.computedAt);
+ setComputedAt(json.computedAt || "");
setSheetId(json.sheetId || null);
} catch (e: any) {
setError(e.message || "Failed to load overall analysis");
@@ -81,115 +102,364 @@ export default function SectionOverallPage() {
useEffect(() => {
loadAnalysis();
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionId]);
- const tierCounts = data
- ? data.students.reduce(
- (acc, s) => {
- acc[s.overallGrade] = (acc[s.overallGrade] || 0) + 1;
- return acc;
- },
- {} as Record
- )
- : {};
+ // The API already returns only THEORY subjects. Keep the subject identity from
+ // the database/Google-Sheets analysis instead of hard-coding abbreviations here.
+ const theorySubjects = useMemo(() => data?.subjects.slice(0, 6) || [], [data]);
+
+ const rows = useMemo(() => {
+ if (!data) return [];
+
+ return data.students.map((student, originalIndex) => {
+ const subjects: RowSubject[] = theorySubjects.map((subject) => {
+ const score = student.subjects.find(
+ (item) => item.subjectId === subject.id || item.code === subject.code
+ );
+ const mark = Number(score?.basicInternal || 0);
+ const max = Number(score?.basicMax || 0);
+ const pct = max > 0 ? (mark / max) * 100 : 0;
+ return {
+ ...subject,
+ mark,
+ max,
+ pct,
+ grade: tierForPct(pct),
+ };
+ });
+
+ const total = subjects.reduce((sum, subject) => sum + subject.mark, 0);
+ const totalMax = subjects.reduce((sum, subject) => sum + subject.max, 0);
+ const average = subjects.length ? total / subjects.length : 0;
+ const averageMax = subjects.length ? totalMax / subjects.length : 0;
+ const overallPct = averageMax > 0 ? (average / averageMax) * 100 : 0;
+
+ return {
+ ...student,
+ originalIndex,
+ subjects,
+ total,
+ totalMax,
+ average,
+ averageMax,
+ overallPct,
+ tier: tierForPct(overallPct),
+ };
+ });
+ }, [data, theorySubjects]);
+
+ const filteredRows = useMemo(() => {
+ const lo = Math.max(0, Math.min(40, Math.min(lower, upper)));
+ const hi = Math.max(0, Math.min(40, Math.max(lower, upper)));
+ const result = rows.filter((row) => row.average >= lo && row.average <= hi);
+
+ if (sortDirection === "none") {
+ return [...result].sort((a, b) => a.originalIndex - b.originalIndex);
+ }
+
+ return [...result].sort((a, b) =>
+ sortDirection === "asc"
+ ? a.average - b.average || a.name.localeCompare(b.name)
+ : b.average - a.average || a.name.localeCompare(b.name)
+ );
+ }, [rows, lower, upper, sortDirection]);
+
+ const tierCounts = useMemo(
+ () =>
+ rows.reduce((acc, row) => {
+ acc[row.tier] = (acc[row.tier] || 0) + 1;
+ return acc;
+ }, {} as Record),
+ [rows]
+ );
+
+ const classAveragePct = rows.length
+ ? rows.reduce((sum, row) => sum + row.overallPct, 0) / rows.length
+ : 0;
+ const passRate = rows.length
+ ? Math.round((rows.filter((row) => row.overallPct >= 40).length / rows.length) * 100)
+ : 0;
+ const topFive = [...rows]
+ .sort((a, b) => b.overallPct - a.overallPct || a.originalIndex - b.originalIndex)
+ .slice(0, 5);
+
+ function applyFilters() {
+ const nextLower = Math.max(0, Math.min(40, Math.min(draftLower, draftUpper)));
+ const nextUpper = Math.max(0, Math.min(40, Math.max(draftLower, draftUpper)));
+ setLower(nextLower);
+ setUpper(nextUpper);
+ setDraftLower(nextLower);
+ setDraftUpper(nextUpper);
+ setSortDirection(draftSortDirection);
+ }
+
+ function resetFilter() {
+ setDraftLower(0);
+ setDraftUpper(40);
+ setLower(0);
+ setUpper(40);
+ setDraftSortDirection("none");
+ setSortDirection("none");
+ }
return (
-
-
-
-
Class / Section Analysis
- {computedAt &&
Last synced {new Date(computedAt).toLocaleString()}
}
-
-
-
-
loadAnalysis(true)} disabled={syncing} className="text-sm bg-gray-900 text-white rounded-lg px-4 py-2 disabled:opacity-50">
- {syncing ? "Syncing..." : "Sync now"}
-
+
+
+ Dashboard
+
+ Class Analysis
+
+ Subject Analysis
+
+
+
ClassPulse Teacher Portal
+
- {typeof sectionId === "string" &&
}
+
+
+
+
Class / Section Analysis
+ {computedAt && (
+ • Last synced {new Date(computedAt).toLocaleString()}
+ )}
+
+
+ loadAnalysis(true)} disabled={syncing}>
+
+ {syncing ? "Syncing..." : "Sync now"}
+
+
+
- {error && {error}
}
- {loading && !data && Loading overall analysis...
}
+ {typeof sectionId === "string" && }
- {data && (
- <>
-
-
Overall Analysis
-
- Internal marks combined across all {data.subjects.length} subjects: {data.subjects.map((s) => s.name).join(", ")}.
-
+ {error && (
+
+ {error}
+ )}
+ {loading && !data && (
+
Loading overall analysis...
+ )}
-
- Note: each subject's tier/moderation rules aren't configured yet, so this uses each subject's basic
- internal marks (as a percentage of that subject's own max) averaged across all subjects.
-
+ {data && (
+ <>
+
+
+ Class Average
+ {classAveragePct.toFixed(1)}%
+ across all 6 theory subjects
+
+
+ Students
+ {rows.length}
+ students assessed
+
+
+ Pass Rate
+ {passRate}%
+ students at or above 40%
+
+
-
-
-
-
-
-
+
+
+
+
Filters
+
Filter by average marks and sort.
+
+
+
+ Lower bound
+ setDraftLower(Math.min(40, Math.max(0, Number(e.target.value))))}
+ />
+
+
+ Upper bound
+ setDraftUpper(Math.min(40, Math.max(0, Number(e.target.value))))}
+ />
+
+
+ Sort
+ setDraftSortDirection(e.target.value as SortDirection)}
+ >
+ No sort
+ High to Low
+ Low to High
+
+
+ Apply
+
+
+
+
+
+
+
+
+
Filtered Students
+
+ Showing {filteredRows.length} of {rows.length} students.
+
+
+
+
+ 6 Theory Subjects
+
+
+ Reset
+
+
+
-
-
- All Students
-
-
-
-
- Name
- Attendance
- Overall %
- Grade
-
-
-
- {[...data.students]
- .sort((a, b) => b.overallPct - a.overallPct)
- .map((s) => (
-
- {s.name}
- {s.overallAttendance}%
- {s.overallPct}%
-
-
+
+
+
+ {sortDirection !== "none" && }
+
+ {theorySubjects.map((subject) => )}
+
+
+
+
+
+
+
+ {sortDirection !== "none" && Rank }
+ Student
+ {theorySubjects.map((subject) => (
+
+ {subject.code || subject.name}
+
+ ))}
+ Total
+ Average
+ %AGE
+ Grade
+
+
+
+ {filteredRows.map((row, index) => (
+
+ {sortDirection !== "none" && (
+ {index + 1}
+ )}
+
+ {row.name}
+
+ {row.subjects.map((subject) => (
+
+ {formatMark(subject.mark)}
+
+ ))}
+
+ {formatMark(row.total)}
+
+
+ {formatMark(row.average)}
+
+
+ {row.overallPct.toFixed(0)}%
+
+
+
+ {row.tier}
+
))}
-
-
-
-
+
+
+
+
-
- Grade Distribution
-
- ({
- name,
- count: tierCounts[name] || 0,
- }))}
- >
-
-
-
-
-
- {["Excellent", "Good", "Needs Attention", "Critical Risk"].map((name, i) => (
- |
- ))}
-
-
-
-
-
- >
- )}
+
+
+ >
+ )}
+
);
}
diff --git a/pages/section-analysis/[sectionId]/students.tsx b/pages/section-analysis/[sectionId]/students.tsx
index 06e9b5e..32f4689 100644
--- a/pages/section-analysis/[sectionId]/students.tsx
+++ b/pages/section-analysis/[sectionId]/students.tsx
@@ -1,37 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
+import {
+ Award,
+ BarChart3,
+ CalendarCheck,
+ ChevronLeft,
+ ChevronRight,
+ RefreshCw,
+ TrendingDown,
+ TrendingUp,
+} from "lucide-react";
import AnalysisNav from "../../../components/AnalysisNav";
-import { RawDataButton, StatCard, GradeBadge } from "../../../components/AnalysisWidgets";
+import { RawDataButton } from "../../../components/AnalysisWidgets";
-interface OverallStudent {
+const SUBJECT_COUNT = 6;
+const SUBJECT_MAX = 40;
+const TOTAL_MAX = SUBJECT_COUNT * SUBJECT_MAX;
+
+const GRADE_ORDER = ["Excellent", "Good", "Needs Attention", "Critical Risk"] as const;
+type Grade = (typeof GRADE_ORDER)[number];
+
+const GRADE_TONE: Record = {
+ Excellent: { text: "text-blue-700", bg: "bg-blue-50", border: "border-blue-100", dot: "bg-blue-500" },
+ Good: { text: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-100", dot: "bg-emerald-500" },
+ "Needs Attention": { text: "text-amber-700", bg: "bg-amber-50", border: "border-amber-100", dot: "bg-amber-500" },
+ "Critical Risk": { text: "text-red-700", bg: "bg-red-50", border: "border-red-100", dot: "bg-red-500" },
+};
+
+interface SubjectScore {
+ subjectId: string;
+ code: string;
+ name: string;
+ attendance: number;
+ midsem1: number;
+ midsem2: number;
+ combined: number;
+ assignment?: { submitted: number; total: number; mark: number };
+ presentation?: { raw: number; mark: number };
+ basicInternal: number;
+ moderatedInternal: number;
+ basicMax: number;
+ grade: string;
+}
+
+interface Student {
enrollmentNo: string;
name: string;
email: string;
overallPct: number;
overallAttendance: number;
overallGrade: string;
- subjects: {
- subjectId: string;
- code: string;
- name: string;
- attendance: number;
- midsem1: number;
- midsem2: number;
- combined: number;
- basicInternal: number;
- moderatedInternal: number;
- basicMax: number;
- grade: string;
- }[];
+ subjects: SubjectScore[];
}
interface OverallData {
subjects: { id: string; name: string; code: string }[];
- students: OverallStudent[];
+ students: Student[];
classAverageOverallPct: number;
}
+const formatNumber = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
+const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
+
+function studentAverage(student: Student) {
+ const subjects = student.subjects.slice(0, SUBJECT_COUNT);
+ return subjects.length
+ ? subjects.reduce((sum, subject) => sum + (Number(subject.basicInternal) || 0), 0) / subjects.length
+ : 0;
+}
+
+function gradeFromAverage(average: number): Grade {
+ const pct = (average / SUBJECT_MAX) * 100;
+ if (pct >= 80) return "Excellent";
+ if (pct >= 60) return "Good";
+ if (pct >= 40) return "Needs Attention";
+ return "Critical Risk";
+}
+
+function GradePill({ grade, large = false }: { grade: Grade; large?: boolean }) {
+ const tone = GRADE_TONE[grade];
+ return (
+
+
+ {grade}
+
+ );
+}
+
export default function SectionStudentReportPage() {
const router = useRouter();
const { sectionId } = router.query;
@@ -43,13 +99,14 @@ export default function SectionStudentReportPage() {
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
- const [selectedEnrollment, setSelectedEnrollment] = useState("");
+ const [gradeFilter, setGradeFilter] = useState("All");
+ const [selectedEnrollment, setSelectedEnrollment] = useState("");
async function loadAnalysis(sync = false) {
- if (!sectionId || typeof sectionId !== "string") return;
- if (sync) setSyncing(true);
- else setLoading(true);
+ if (typeof sectionId !== "string") return;
+ sync ? setSyncing(true) : setLoading(true);
setError("");
+
try {
const res = await fetch(`/api/analysis/section/${sectionId}/overall${sync ? "?sync=1" : ""}`);
const json = await res.json();
@@ -57,10 +114,22 @@ export default function SectionStudentReportPage() {
setError(json.detail ? `${json.error}: ${json.detail}` : json.error || "Failed to load student report");
return;
}
+
+ const needsDetailedData = !sync && json.data?.students?.some((student: Student) =>
+ student.subjects?.some((subject) => !subject.assignment || !subject.presentation)
+ );
+
+ if (needsDetailedData) {
+ await loadAnalysis(true);
+ return;
+ }
+
setData(json.data);
- setComputedAt(json.computedAt);
+ setComputedAt(json.computedAt || "");
setSheetId(json.sheetId || null);
- if (json.data?.students?.[0]) setSelectedEnrollment(json.data.students[0].enrollmentNo);
+ if (json.data?.students?.length && !selectedEnrollment) {
+ setSelectedEnrollment(json.data.students[0].enrollmentNo);
+ }
} catch (e: any) {
setError(e.message || "Failed to load student report");
} finally {
@@ -76,126 +145,301 @@ export default function SectionStudentReportPage() {
const students = data?.students || [];
- const filteredList = useMemo(() => {
- if (!search.trim()) return students;
- const q = search.trim().toLowerCase();
- return students.filter((s) => s.name.toLowerCase().includes(q) || s.enrollmentNo.includes(q));
- }, [students, search]);
+ const studentsWithGrades = useMemo(
+ () => students.map((student) => ({
+ ...student,
+ reportAverage: studentAverage(student),
+ reportGrade: gradeFromAverage(studentAverage(student)),
+ })),
+ [students]
+ );
+
+ const classGradeCounts = useMemo(() => {
+ return GRADE_ORDER.reduce((acc, grade) => {
+ acc[grade] = studentsWithGrades.filter((student) => student.reportGrade === grade).length;
+ return acc;
+ }, {} as Record);
+ }, [studentsWithGrades]);
+
+ const filteredStudents = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ return studentsWithGrades.filter((student) => {
+ const matchesSearch = !query || student.name.toLowerCase().includes(query) || student.enrollmentNo.includes(query);
+ const matchesGrade = gradeFilter === "All" || student.reportGrade === gradeFilter;
+ return matchesSearch && matchesGrade;
+ });
+ }, [studentsWithGrades, search, gradeFilter]);
+
+ useEffect(() => {
+ if (!selectedEnrollment && students[0]) setSelectedEnrollment(students[0].enrollmentNo);
+ if (selectedEnrollment && !students.some((student) => student.enrollmentNo === selectedEnrollment) && students[0]) {
+ setSelectedEnrollment(students[0].enrollmentNo);
+ }
+ }, [students, selectedEnrollment]);
+
+ const selected = studentsWithGrades.find((student) => student.enrollmentNo === selectedEnrollment) || filteredStudents[0];
+
+ const studentStats = useMemo(() => {
+ if (!selected) return null;
+
+ const subjects = selected.subjects.slice(0, SUBJECT_COUNT).map((subject) => {
+ const assignment = subject.assignment?.mark ?? 0;
+ const presentation = subject.presentation?.mark ?? 0;
+ const attendance = clamp(((Number(subject.attendance) || 0) / 100) * 10, 0, 10);
+ const midsem1 = clamp(((Number(subject.midsem1) || 0) / 30) * 10, 0, 10);
+ const midsem2 = clamp(((Number(subject.midsem2) || 0) / 30) * 10, 0, 10);
+ const basicInternal = Number(subject.basicInternal) || assignment + presentation + attendance + midsem1 + midsem2;
+ const moderatedInternal = Number(subject.moderatedInternal);
+
+ return {
+ ...subject,
+ assignment,
+ presentation,
+ attendanceMark: attendance,
+ midsem1Mark: midsem1,
+ midsem2Mark: midsem2,
+ basicInternal,
+ moderatedInternal: Number.isFinite(moderatedInternal) ? moderatedInternal : basicInternal,
+ grade: gradeFromAverage(basicInternal),
+ };
+ });
- const selected = students.find((s) => s.enrollmentNo === selectedEnrollment);
+ const total = subjects.reduce((sum, subject) => sum + subject.basicInternal, 0);
+ const average = subjects.length ? total / subjects.length : 0;
+
+ const ranked = [...studentsWithGrades].sort(
+ (a, b) => b.reportAverage - a.reportAverage || a.name.localeCompare(b.name)
+ );
+ const rankIndex = Math.max(0, ranked.findIndex((item) => item.enrollmentNo === selected.enrollmentNo));
+
+ const highest = [...subjects].sort((a, b) => b.basicInternal - a.basicInternal)[0];
+ const lowest = [...subjects].sort((a, b) => a.basicInternal - b.basicInternal)[0];
+
+ return {
+ subjects,
+ total,
+ average,
+ averagePct: (average / SUBJECT_MAX) * 100,
+ rank: rankIndex + 1,
+ highest,
+ lowest,
+ grade: gradeFromAverage(average),
+ };
+ }, [selected, studentsWithGrades]);
+
+ const navigateStudent = (direction: -1 | 1) => {
+ if (!filteredStudents.length || !selected) return;
+ const index = filteredStudents.findIndex((student) => student.enrollmentNo === selected.enrollmentNo);
+ const nextIndex = Math.min(filteredStudents.length - 1, Math.max(0, index + direction));
+ setSelectedEnrollment(filteredStudents[nextIndex].enrollmentNo);
+ };
return (
-
-
+
+
-
Class / Section Analysis
- {computedAt &&
Last synced {new Date(computedAt).toLocaleString()}
}
+
Class / Section Analysis
+ {computedAt &&
Last synced {new Date(computedAt).toLocaleString()}
}
- loadAnalysis(true)} disabled={syncing} className="text-sm bg-gray-900 text-white rounded-lg px-4 py-2 disabled:opacity-50">
+ loadAnalysis(true)}
+ disabled={syncing}
+ className="inline-flex items-center gap-2 rounded-xl bg-[#3f2a8f] px-4 py-2.5 text-sm font-medium text-white shadow-[0_8px_22px_rgba(63,42,143,.18)] disabled:opacity-60"
+ >
+
{syncing ? "Syncing..." : "Sync now"}
{typeof sectionId === "string" &&
}
-
- {error &&
{error}
}
- {loading && !data &&
Loading student report...
}
+ {error &&
{error}
}
+ {loading && !data &&
Loading student report...
}
{data && (
<>
-
-
Student Report
-
Full report card across all {data.subjects.length} subjects for one student.
+
+
Student Report
+
Individual performance report across {Math.min(SUBJECT_COUNT, data.subjects.length)} theory subjects.
-
-
- setSearch(e.target.value)}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm mb-4"
- />
-
- {filteredList.map((s) => (
+
+
+
+
+
Students
+ {students.length}
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by name or enrollment no."
+ className="w-full rounded-lg border border-slate-200 px-3 py-2.5 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100"
+ />
+
+
+ setGradeFilter("All")} label={`All ${students.length}`} />
+ {GRADE_ORDER.map((grade) => (
+ setGradeFilter(grade)}
+ label={`${grade} ${classGradeCounts[grade] || 0}`}
+ />
+ ))}
+
+
+
+
+ {filteredStudents.map((student) => (
setSelectedEnrollment(s.enrollmentNo)}
- className={`w-full text-left text-sm px-3 py-2 rounded-lg ${
- s.enrollmentNo === selectedEnrollment ? "bg-gray-900 text-white" : "hover:bg-gray-50 text-gray-700"
- }`}
+ key={student.enrollmentNo}
+ onClick={() => setSelectedEnrollment(student.enrollmentNo)}
+ className={`w-full rounded-xl px-3 py-3 text-left transition ${student.enrollmentNo === selected?.enrollmentNo ? "bg-[#4a32a0] text-white shadow-sm" : "hover:bg-slate-50 text-slate-700"}`}
>
- {s.name}
-
- {s.enrollmentNo} · {s.overallGrade}
-
+
+
+ {student.name}
+ {student.enrollmentNo}
+
+
+
))}
- {filteredList.length === 0 &&
No matches.
}
+ {!filteredStudents.length &&
No students match the current filters.
}
+
+
+ navigateStudent(-1)} className="rounded-lg border border-slate-200 p-1.5 text-slate-500 hover:bg-slate-50">
+ {filteredStudents.length ? "Student list" : "No results"}
+ navigateStudent(1)} className="rounded-lg border border-slate-200 p-1.5 text-slate-500 hover:bg-slate-50">
-
+
-
- {!selected && Select a student to see their report card.
}
- {selected && (
-
-
-
{selected.name}
-
-
-
- {selected.enrollmentNo} · {selected.email || "no email on file"}
-
-
-
-
-
-
+ {selected && studentStats && (
+
+
+
+
+
+ {selected.name.split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase()}
+
+
+
{selected.name}
+
Student report · {Math.min(SUBJECT_COUNT, selected.subjects.length)} theory subjects
+
+
+
+
+
+
Rank by average
+
{studentStats.rank} / {students.length}
+
+
+
- Per-Subject Breakdown
+
+ } label="Average" value={`${formatNumber(studentStats.average)} / 40`} sub={`${studentStats.averagePct.toFixed(1)}%`} />
+ } label="Total" value={`${formatNumber(studentStats.total)} / ${TOTAL_MAX}`} sub="6 subjects × 40" />
+ } label="Highest Subject" value={studentStats.highest ? formatNumber(studentStats.highest.basicInternal) : "—"} sub={studentStats.highest?.name || "—"} />
+ } label="Lowest Subject" value={studentStats.lowest ? formatNumber(studentStats.lowest.basicInternal) : "—"} sub={studentStats.lowest?.name || "—"} />
+ } label="Overall Attendance" value={`${selected.overallAttendance}%`} sub="Current attendance" />
+
+
+
+
+
Subject Performance (Theory Subjects)
+
Internal marks distribution for each subject. Every subject contributes 40 marks.
+
-
+
+
+
+
+
+
+
+
+
+
+
+
-
- Subject
- Attendance
- Midsem 1
- Midsem 2
- Combined
- Internal (Basic)
- Grade
+
+ Subject
+ Assignment / 5
+ Presentation / 5
+ Attendance / 10
+ Midsem 1 / 10
+ Midsem 2 / 10
+ Basic Internal / 40
+ Moderated Internal / 40
+ Grade
- {selected.subjects.map((sub) => (
-
- {sub.name}
- {sub.attendance}%
- {sub.midsem1}
- {sub.midsem2}
- {sub.combined}
-
- {sub.basicInternal}/{sub.basicMax}
-
-
-
-
+ {studentStats.subjects.map((subject) => (
+
+ {subject.name}
+
+
+
+
+
+ {formatNumber(subject.basicInternal)} / 40
+ {formatNumber(subject.moderatedInternal)} / 40
+
))}
-
- )}
-
+
+ Assignment, presentation, attendance, Midsem 1 and Midsem 2 are shown as their weighted contribution to the 40-mark internal total. Moderated Internal is shown separately from the basic internal marks.
+
+
+
+ )}
>
)}
);
}
+
+function FilterPill({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function MarkCell({ value, max }: { value: number; max: number }) {
+ return (
+
+ {formatNumber(value)}
+ / {max}
+
+ );
+}
+
+function Metric({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: string; sub: string }) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
{sub}
+
+ );
+}
diff --git a/pages/subject-analysis-attendance-trend-fixed/[subjectId].tsx b/pages/subject-analysis-attendance-trend-fixed/[subjectId].tsx
new file mode 100644
index 0000000..0371343
--- /dev/null
+++ b/pages/subject-analysis-attendance-trend-fixed/[subjectId].tsx
@@ -0,0 +1,142 @@
+import { useEffect } from "react";
+import SubjectAttendancePage from "../subject-analysis/[subjectId]/attendance";
+
+type Student = {
+ enrollmentNo: string;
+ name: string;
+ email?: string;
+ attendancePct: { prevMonth: number; currMonth: number; trend: string };
+};
+type AttendancePayload = {
+ students?: Student[];
+ monthsUsed?: { previous?: string; current?: string };
+};
+
+const fmt = (n: number) => `${Math.round(n * 10) / 10}%`;
+const esc = (v: unknown) => String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
+const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((x) => x[0]).join("").toUpperCase();
+
+function getSubjectId() {
+ return window.location.pathname.match(/\/subject-analysis\/([^/]+)/)?.[1] || "";
+}
+
+function addStyles() {
+ if (document.getElementById("classpulse-subject-attendance-trend-fixes")) return;
+ const style = document.createElement("style");
+ style.id = "classpulse-subject-attendance-trend-fixes";
+ style.textContent = `
+ .classpulse-trend-hero{display:grid!important;grid-template-columns:300px minmax(480px,620px)!important;justify-content:space-between!important;gap:24px!important;align-items:center!important}
+ .classpulse-trend-hero-copy h2{margin:0;font-size:21px;line-height:1.2;font-weight:700;color:#17223b}
+ .classpulse-trend-hero-copy p{margin:8px 0 0;max-width:300px;font-size:12px;line-height:1.55;color:#667085}
+ .classpulse-trend-metrics{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:10px;width:100%;max-width:620px}
+ .classpulse-trend-metric{min-width:0;height:76px;padding:11px 14px;border:1px solid #e7ebf1;border-top:3px solid;border-radius:13px;background:#fff;box-shadow:0 2px 7px rgba(16,24,40,.04);box-sizing:border-box}
+ .classpulse-trend-metric-label{display:block;font-size:10px;line-height:1.25;color:#64748b;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-metric-value{margin-top:5px;font-size:19px;line-height:1.15;color:#17223b;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-metric-detail{margin-top:3px;font-size:9px;line-height:1.25;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .classpulse-trend-table-wrap{overflow-x:hidden!important;overflow-y:auto!important;height:500px!important;max-height:500px!important;scrollbar-gutter:stable}
+ .classpulse-trend-table{width:100%!important;max-width:100%!important;min-width:0!important;table-layout:fixed!important;border-collapse:collapse!important}
+ .classpulse-trend-table th,.classpulse-trend-table td{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:10px 6px!important}
+ .classpulse-trend-table th:nth-child(1),.classpulse-trend-table td:nth-child(1){width:5%!important;text-align:center}
+ .classpulse-trend-table th:nth-child(2),.classpulse-trend-table td:nth-child(2){width:28%!important}
+ .classpulse-trend-table th:nth-child(3),.classpulse-trend-table td:nth-child(3){width:18%!important}
+ .classpulse-trend-table th:nth-child(4),.classpulse-trend-table td:nth-child(4){width:13%!important}
+ .classpulse-trend-table th:nth-child(5),.classpulse-trend-table td:nth-child(5){width:13%!important}
+ .classpulse-trend-table th:nth-child(6),.classpulse-trend-table td:nth-child(6){width:10%!important}
+ .classpulse-trend-table th:nth-child(7),.classpulse-trend-table td:nth-child(7){width:13%!important}
+ .classpulse-trend-chart-stack{min-width:0}
+ .classpulse-trend-chart-stack .analysis-chart-panel{min-width:0}
+ @media(min-width:1000px){.analysis-content-grid{grid-template-columns:minmax(0,1.35fr) minmax(300px,.85fr)!important;gap:14px!important}}
+ @media(max-width:1050px){.classpulse-trend-hero{grid-template-columns:260px minmax(440px,1fr)!important}.classpulse-trend-metrics{max-width:560px}}
+ @media(max-width:900px){.classpulse-trend-hero{grid-template-columns:1fr!important}.classpulse-trend-metrics{grid-template-columns:repeat(2,minmax(0,1fr));max-width:none}.analysis-content-grid{grid-template-columns:1fr!important}}
+ @media(max-width:560px){.classpulse-trend-metrics{grid-template-columns:1fr}.classpulse-trend-table th,.classpulse-trend-table td{padding-left:4px!important;padding-right:4px!important}}
+ `;
+ document.head.appendChild(style);
+}
+
+function makeMetric(label: string, value: string, detail: string, color: string) {
+ const card = document.createElement("div");
+ card.className = "classpulse-trend-metric";
+ card.style.borderTopColor = color;
+ card.innerHTML = `${esc(label)} ${esc(value)}
${esc(detail)}
`;
+ return card;
+}
+
+function installHero(payload: AttendancePayload) {
+ const hero = document.querySelector(".analysis-hero") as HTMLElement | null;
+ if (!hero) return;
+ hero.classList.add("classpulse-trend-hero");
+ Array.from(hero.querySelectorAll(":scope > .analysis-metric")).forEach((x) => x.remove());
+ const copy = hero.querySelector(":scope > .analysis-hero-copy") as HTMLElement | null;
+ if (copy) copy.classList.add("classpulse-trend-hero-copy");
+ let metrics = hero.querySelector(":scope > .classpulse-trend-metrics") as HTMLElement | null;
+ if (!metrics) { metrics = document.createElement("div"); metrics.className = "classpulse-trend-metrics"; hero.appendChild(metrics); }
+ metrics.innerHTML = "";
+ const students = payload.students || [];
+ const prev = students.length ? students.reduce((s, x) => s + Number(x.attendancePct.prevMonth || 0), 0) / students.length : 0;
+ const curr = students.length ? students.reduce((s, x) => s + Number(x.attendancePct.currMonth || 0), 0) / students.length : 0;
+ const improving = students.filter((x) => x.attendancePct.trend === "Increasing").length;
+ const highest = [...students].sort((a, b) => Number(b.attendancePct.currMonth || 0) - Number(a.attendancePct.currMonth || 0))[0];
+ metrics.appendChild(makeMetric(`Class Average (${payload.monthsUsed?.previous || "Previous"})`, fmt(prev), "Previous month attendance", "#2563eb"));
+ metrics.appendChild(makeMetric(`Class Average (${payload.monthsUsed?.current || "Current"})`, fmt(curr), `${fmt(curr - prev)} change from previous month`, "#16a34a"));
+ metrics.appendChild(makeMetric("Students Improving", String(improving), `${students.length ? fmt((improving / students.length) * 100) : "0%"} of total students`, "#f59e0b"));
+ metrics.appendChild(makeMetric("Highest Attendance Student", highest ? fmt(Number(highest.attendancePct.currMonth || 0)) : "—", highest?.name || "Highest current-month attendance", "#7c3aed"));
+}
+
+function installTable(payload: AttendancePayload) {
+ const panel = document.querySelector(".analysis-table-panel") as HTMLElement | null;
+ const oldTable = panel?.querySelector("table.analysis-table") as HTMLTableElement | null;
+ if (!panel || !oldTable) return;
+ const students = payload.students || [];
+ const oldSearch = panel.querySelector(".analysis-panel-head input") as HTMLInputElement | null;
+ const search = oldSearch?.value || "";
+ const filtered = students.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.enrollmentNo.toLowerCase().includes(search.toLowerCase()));
+ oldTable.className = "analysis-table classpulse-trend-table";
+ oldTable.innerHTML = `S.No. Student Name Enrollment No. ${esc(payload.monthsUsed?.previous || "Month 1")} ${esc(payload.monthsUsed?.current || "Month 2")} Change Trend ${filtered.map((s, i) => { const prev = Number(s.attendancePct.prevMonth || 0); const curr = Number(s.attendancePct.currMonth || 0); const change = Math.round((curr - prev) * 10) / 10; const trend = s.attendancePct.trend; const badge = trend === "Increasing" ? "trend-up" : trend === "Decreasing" ? "trend-down" : "trend-stable"; return `${i + 1} ${esc(initials(s.name))} ${esc(s.name)}${esc(s.enrollmentNo)} ${fmt(prev)} ${fmt(curr)} ${change > 0 ? "+" : ""}${fmt(change)} ${trend === "Increasing" ? "↑ " : trend === "Decreasing" ? "↓ " : "− "}${esc(trend)} `; }).join("")} `;
+ const wrap = panel.querySelector(".analysis-table-wrap") as HTMLElement | null;
+ if (wrap) wrap.classList.add("classpulse-trend-table-wrap");
+ panel.querySelector(".classpulse-trend-pagination")?.remove();
+ const count = panel.querySelector(".analysis-count") as HTMLElement | null;
+ if (count) count.textContent = `${filtered.length} Students`;
+}
+
+function installCharts() {
+ const stack = document.querySelector(".analysis-right-stack") as HTMLElement | null;
+ if (stack) stack.classList.add("classpulse-trend-chart-stack");
+}
+
+export default function SubjectAnalysisAttendanceTrendFixedPage() {
+ useEffect(() => {
+ let dead = false;
+ let timer: number | undefined;
+ let running = false;
+ const run = async () => {
+ if (running || dead) return;
+ running = true;
+ const observer = (window as any).__subjectAttendanceObserver as MutationObserver | undefined;
+ observer?.disconnect();
+ addStyles();
+ const id = getSubjectId();
+ if (!id) { running = false; return; }
+ try {
+ const response = await fetch(`/api/analysis/subject/${id}`);
+ if (!response.ok) return;
+ const json = await response.json();
+ if (dead) return;
+ const payload: AttendancePayload = json.data || {};
+ installHero(payload);
+ installTable(payload);
+ installCharts();
+ } catch {} finally {
+ running = false;
+ if (!dead) observer?.observe(document.body, { childList: true, subtree: true });
+ }
+ };
+ const schedule = () => { window.clearTimeout(timer); timer = window.setTimeout(run, 150); };
+ const observer = new MutationObserver(schedule);
+ (window as any).__subjectAttendanceObserver = observer;
+ observer.observe(document.body, { childList: true, subtree: true });
+ schedule();
+ return () => { dead = true; window.clearTimeout(timer); observer.disconnect(); delete (window as any).__subjectAttendanceObserver; };
+ }, []);
+ return ;
+}
diff --git a/pages/subject-analysis/[subjectId]/academic.tsx b/pages/subject-analysis/[subjectId]/academic.tsx
index 36e2e65..d72d5eb 100644
--- a/pages/subject-analysis/[subjectId]/academic.tsx
+++ b/pages/subject-analysis/[subjectId]/academic.tsx
@@ -16,9 +16,8 @@ type SummarySort = "none" | "desc" | "asc";
const MAX = 30;
const TIERS: Tier[] = ["Excellent", "Good", "Needs Attention", "Critical Risk"];
-const COLORS: Record = { Excellent: "#15966a", Good: "#4d75d0", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
+const COLORS: Record = { Excellent: "#4d75d0", Good: "#15966a", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
const round1 = (n: number) => Math.round(n * 10) / 10;
-const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
function median(values: number[]) {
if (!values.length) return 0;
@@ -64,6 +63,11 @@ export default function SubjectAcademicPage() {
const [summaryLower, setSummaryLower] = useState("0");
const [summaryUpper, setSummaryUpper] = useState("30");
const [summarySort, setSummarySort] = useState("none");
+ const [summaryExamDraft, setSummaryExamDraft] = useState("combined");
+ const [summaryTierDraft, setSummaryTierDraft] = useState("all");
+ const [summaryLowerDraft, setSummaryLowerDraft] = useState("0");
+ const [summaryUpperDraft, setSummaryUpperDraft] = useState("30");
+ const [summarySortDraft, setSummarySortDraft] = useState("none");
async function loadAnalysis(sync = false) {
if (!subjectId || typeof subjectId !== "string") return;
@@ -89,7 +93,25 @@ export default function SubjectAcademicPage() {
const students = data?.students || [];
const statsFor = (exam: "midsem1" | "midsem2") => {
- const rows = students.map((s: any) => ({ enrollmentNo: s.enrollmentNo, name: s.name, marks: valueFor(s, exam) }));
+ const baseRows = students.map((s: any, index: number) => ({
+ sno: index + 1,
+ enrollmentNo: s.enrollmentNo,
+ name: s.name,
+ marks: valueFor(s, exam),
+ }));
+ const rankByEnrollment = new Map();
+ [...baseRows]
+ .sort((a, b) => b.marks - a.marks || a.name.localeCompare(b.name))
+ .forEach((row, index, sorted) => {
+ const previous = index > 0 ? sorted[index - 1].marks : null;
+ rankByEnrollment.set(
+ row.enrollmentNo,
+ previous === row.marks
+ ? (rankByEnrollment.get(sorted[index - 1].enrollmentNo) || index + 1)
+ : index + 1
+ );
+ });
+ const rows = baseRows.map((row) => ({ ...row, rank: rankByEnrollment.get(row.enrollmentNo) || 0 }));
const marks = rows.map((r) => r.marks);
const highest = marks.length ? Math.max(...marks) : 0;
const counts = TIERS.reduce((r, t) => ({ ...r, [t]: 0 }), {} as Record);
@@ -108,7 +130,8 @@ export default function SubjectAcademicPage() {
const midsem1 = useMemo(() => statsFor("midsem1"), [students]);
const midsem2 = useMemo(() => statsFor("midsem2"), [students]);
- const combinedRows = useMemo(() => students.map((s: any) => ({
+ const combinedRows = useMemo(() => students.map((s: any, index: number) => ({
+ sno: index + 1,
enrollmentNo: s.enrollmentNo,
name: s.name,
first: valueFor(s, "midsem1"),
@@ -116,23 +139,22 @@ export default function SubjectAcademicPage() {
combined: valueFor(s, "combined"),
max: valueFor(s, "max"),
})), [students]);
-
const combinedCounts = useMemo(() => {
const r = TIERS.reduce((a, t) => ({ ...a, [t]: 0 }), {} as Record);
combinedRows.forEach((row) => r[tierFor(row.combined)]++);
return r;
}, [combinedRows]);
-
const activeStats = view === "midsem2" ? midsem2 : midsem1;
const activeLabel = view === "midsem2" ? "Midsem 2" : "Midsem 1";
const activePie = TIERS.map((name) => ({ name, value: activeStats.counts[name], color: COLORS[name] }));
const displayedRows = selectedTier ? activeStats.rows.filter((r) => tierFor(r.marks) === selectedTier) : activeStats.rows;
+ const combinedGradeExam: ExamKey = sortOrder === "none" ? "combined" : combinedSort;
+ const combinedGradeValue = (row: typeof combinedRows[number]) => valueFor({ midsem: { first: row.first, second: row.second, combined: row.combined, max: row.max } }, combinedGradeExam);
const filteredCombinedRows = useMemo(
- () => selectedTier ? combinedRows.filter((row) => tierFor(row.combined) === selectedTier) : combinedRows,
- [combinedRows, selectedTier]
+ () => selectedTier ? combinedRows.filter((row) => tierFor(combinedGradeValue(row)) === selectedTier) : combinedRows,
+ [combinedRows, selectedTier, combinedGradeExam]
);
-
const sortedCombinedRows = useMemo(() => {
if (sortOrder === "none") return [...filteredCombinedRows];
const getValue = (row: typeof combinedRows[number]) => {
@@ -147,12 +169,10 @@ export default function SubjectAcademicPage() {
return a.name.localeCompare(b.name);
});
}, [filteredCombinedRows, combinedSort, sortOrder]);
-
const combinedRankRows = useMemo(() => [...combinedRows].sort((a, b) => b.combined - a.combined), [combinedRows]);
const combinedAverage = combinedRows.length ? round1(combinedRows.reduce((sum, row) => sum + row.combined, 0) / combinedRows.length) : 0;
const combinedHighest = combinedRows.length ? Math.max(...combinedRows.map((row) => row.combined)) : 0;
const combinedHighestNames = combinedRows.filter((row) => row.combined === combinedHighest).map((row) => row.name);
-
const summaryRows = useMemo(() => combinedRows.map((row) => {
const marks = summaryExam === "midsem1" ? row.first : summaryExam === "midsem2" ? row.second : summaryExam === "max" ? row.max : row.combined;
return { ...row, marks, tier: tierFor(marks), change: round1(row.second - row.first) };
@@ -161,10 +181,10 @@ export default function SubjectAcademicPage() {
const upper = Number(summaryUpper);
return row.marks >= (Number.isFinite(lower) ? lower : 0) && row.marks <= (Number.isFinite(upper) ? upper : MAX) && (summaryTier === "all" || row.tier === summaryTier);
}).sort((a, b) => {
- if (summarySort === "none") return 0;
+ if (summarySort === "none") return a.sno - b.sno;
return summarySort === "desc" ? b.marks - a.marks : a.marks - b.marks;
}), [combinedRows, summaryExam, summaryTier, summaryLower, summaryUpper, summarySort]);
-
+ const summaryShowRank = summarySort !== "none";
const summaryAverage = combinedRows.length ? round1(combinedRows.reduce((sum, row) => sum + row.combined, 0) / combinedRows.length) : 0;
const summaryPassRate = combinedRows.length ? Math.round(combinedRows.filter((row) => row.combined >= 12).length / combinedRows.length * 100) : 0;
const midsem1Average = midsem1.average;
@@ -172,199 +192,213 @@ export default function SubjectAcademicPage() {
const increases = [...combinedRows].map((row) => ({ ...row, change: round1(row.second - row.first) })).filter((row) => row.change > 0).sort((a, b) => b.change - a.change).slice(0, 5);
const decreases = [...combinedRows].map((row) => ({ ...row, change: round1(row.second - row.first) })).filter((row) => row.change < 0).sort((a, b) => a.change - b.change).slice(0, 5);
- return
-
-
-
-
-
Subject Analysis {computedAt && • Last synced {new Date(computedAt).toLocaleString()} }
- loadAnalysis(true)} disabled={syncing}> {syncing ? "Syncing..." : "Sync now"}
-
-
- {typeof subjectId === "string" && }
-
- {([["midsem1", "Midsem 1"], ["midsem2", "Midsem 2"], ["combined", "Combined"], ["summary", "Summary"]] as [AcademicView, string][]).map(([k, l]) => setView(k)}>{l} )}
-
+ function applySummaryFilters() {
+ setSummaryExam(summaryExamDraft);
+ setSummaryTier(summaryTierDraft);
+ setSummaryLower(summaryLowerDraft);
+ setSummaryUpper(summaryUpperDraft);
+ setSummarySort(summarySortDraft);
+ }
- {error && {error}
}
- {loading && !data && Loading academic analysis...
}
+ function resetSummaryFilters() {
+ setSummaryExam("combined");
+ setSummaryTier("all");
+ setSummaryLower("0");
+ setSummaryUpper("30");
+ setSummarySort("none");
+ setSummaryExamDraft("combined");
+ setSummaryTierDraft("all");
+ setSummaryLowerDraft("0");
+ setSummaryUpperDraft("30");
+ setSummarySortDraft("none");
+ }
- {data && (view === "midsem1" || view === "midsem2") && <>
-
- {activeLabel} Raw Midsem marks out of 30, class statistics, and performance tiers.
-
-
-
-
-
-
- Data Sheet Pass mark: 12/30 (40%){selectedTier ? ` · Filtered: ${selectedTier}` : ""}
{selectedTier && setSelectedTier(null)}> Clear }{displayedRows.length} Students
- Student Marks Percentage Status {displayedRows.map(row => { const pass = row.marks >= 12, p = Math.round(row.marks / MAX * 100); return {initials(row.name)} {row.name}{row.marks} {p}% {pass ? "Pass" : "Fail"} ; })}
+ return (
+
+
+
+
+
+
Subject Analysis {computedAt && • Last synced {new Date(computedAt).toLocaleString()} }
+ loadAnalysis(true)} disabled={syncing}> {syncing ? "Syncing..." : "Sync now"}
+
+
+ {typeof subjectId === "string" && }
+
+ {([["midsem1", "Midsem 1"], ["midsem2", "Midsem 2"], ["combined", "Combined"], ["summary", "Summary"]] as [AcademicView, string][]).map(([k, l]) => setView(k)}>{l} )}
+
+
+ {error && {error}
}
+ {loading && !data && Loading academic analysis...
}
+
+ {data && (view === "midsem1" || view === "midsem2") && <>
+
+ {activeLabel} Raw Midsem marks out of 30, class statistics, and performance tiers.
+
+
+
-
-
Highest Score {activeStats.highest} {activeStats.highestNames.join(", ") || "—"}
{TIERS.map(t => setSelectedTier(selectedTier === t ? null : t)} style={{ borderColor: `${COLORS[t]}55`, cursor: "pointer", boxShadow: selectedTier === t ? `0 0 0 2px ${COLORS[t]}33` : undefined }}>{t} {activeStats.counts[t]} )}
-
Performance Tier Click a chart segment to filter the student table.
{ const t = entry?.name as Tier | undefined; if (t && TIERS.includes(t)) setSelectedTier(selectedTier === t ? null : t); }}>{activePie.map(e => | )}
-
Top 5 Highest Scorers {activeStats.sorted.slice(0, 5).map((r, i) => )}Bottom 5 At-Risk Students {[...activeStats.sorted].reverse().slice(0, 5).map((r, i) => )}
-
-
- >}
-
- {data && view === "combined" && <>
-
- Midsem Combined Compare both Midsem examinations in one view.
-
-
-
-
-
- Grade / Sort By setCombinedSort(e.target.value as CombinedSort)}>Combined (average) Midsem 1 Midsem 2 Maximum score
- Order setSortOrder(e.target.value as SortOrder)}>High to Low Low to High No Sort
-
-
-
- All Students {selectedTier ? `Filtered: ${selectedTier}` : "Combined Midsem results"}
{selectedTier && setSelectedTier(null)}> Clear }{sortedCombinedRows.length} Students
- Rank Student Midsem 1 Midsem 2 Combined Max Grade {sortedCombinedRows.map((row, index) => { const tier = tierFor(row.combined); const rank = combinedRankRows.findIndex(r => r.enrollmentNo === row.enrollmentNo) + 1; return {sortOrder === "none" ? rank : index + 1} {initials(row.name)} {row.name}{row.first} {row.second} {row.combined} {row.max} setSelectedTier(selectedTier === tier ? null : tier)}>{tier} ; })}{!sortedCombinedRows.length && No students match the selected filter. }
+
+
+ Data Sheet Pass mark: 12/30 (40%){selectedTier ? ` · Filtered: ${selectedTier}` : ""}
{selectedTier && setSelectedTier(null)}> Clear }{displayedRows.length} Students
+
+
+
+ S.No. Enrollment No. Student Marks Status Rank
+ {displayedRows.map((row) => { const pass = row.marks >= 12; return {row.sno} {row.enrollmentNo} {row.name} {row.marks} {pass ? "Pass" : "Fail"} {row.rank} ; })}
+
+
+
+
+
Highest Score {activeStats.highest} {activeStats.highestNames.join(", ") || "—"}
{TIERS.map((t) => setSelectedTier(selectedTier === t ? null : t)} style={{ borderColor: `${COLORS[t]}55`, cursor: "pointer", boxShadow: selectedTier === t ? `0 0 0 2px ${COLORS[t]}33` : undefined }}>{t} {activeStats.counts[t]} )}
+
Performance Tier Click a chart segment to filter the student table.
{ const t = entry?.name as Tier | undefined; if (t && TIERS.includes(t)) setSelectedTier(selectedTier === t ? null : t); }}>{activePie.map((e) => | )}
+
Top 5 Highest Scorers {activeStats.sorted.slice(0, 5).map((r, i) => )}Bottom 5 At-Risk Students {[...activeStats.sorted].reverse().slice(0, 5).map((r, i) => )}
+
-
-
Grade Distribution Click a bar to filter the table by performance tier.
{combinedRows.length} Students ({ name: t, count: combinedCounts[t] }))} onClick={(state: any) => { const tier = state?.activeLabel as Tier | undefined; if (tier && TIERS.includes(tier)) setSelectedTier(selectedTier === tier ? null : tier); }}>{TIERS.map(t => | )}
-
Performance Tiers {TIERS.map(t => setSelectedTier(selectedTier === t ? null : t)} style={{ borderColor: `${COLORS[t]}55`, cursor: "pointer", boxShadow: selectedTier === t ? `0 0 0 2px ${COLORS[t]}33` : undefined }}>{t} {combinedCounts[t]} )}
-
Top 5 Highest Scorers {combinedRankRows.slice(0, 5).map((r, i) => {i + 1} {r.name}
{r.combined} )}Bottom 5 At-Risk Students {[...combinedRankRows].reverse().slice(0, 5).map((r, i) => {i + 1} {r.name}
{r.combined} )}
-
-
- >}
-
- {data && view === "summary" && <>
- Academic Summary Compare Midsem 1 and Midsem 2 with the same ClassPulse academic analysis theme.
-
-
-
-
-
-
- Filtered Students Showing {summaryRows.length} of {students.length} students
{summaryRows.length} Students
- Enrollment Student Marks Tier {summaryRows.map(row => {row.enrollmentNo} {initials(row.name)} {row.name}{row.marks} setSummaryTier(summaryTier === row.tier ? "all" : row.tier)}>{row.tier} )}{!summaryRows.length && No students match these filters. }
+ >}
+
+ {data && view === "combined" && <>
+ Midsem Combined Compare both Midsem examinations in one view.
+ Grade / Sort By setCombinedSort(e.target.value as CombinedSort)}>Combined (average) Midsem 1 Midsem 2 Maximum score
Order setSortOrder(e.target.value as SortOrder)}>High to Low Low to High No Sort
+
+
+ All Students {selectedTier ? `Filtered: ${selectedTier}` : "Combined Midsem results"}
{selectedTier && setSelectedTier(null)}> Clear }{sortedCombinedRows.length} Students
+
+
+ {sortOrder === "none" ? "S.No." : "Rank"} Student Enrollment No. Midsem 1 Midsem 2 Combined Max Grade
+ {sortedCombinedRows.map((row, index) => {
+ const gradeValue = combinedGradeValue(row);
+ const tier = tierFor(gradeValue);
+ const metricClass = (metric: CombinedSort, value: number) => sortOrder !== "none" && combinedSort === metric ? `tier-mark ${gradeClass(tierFor(value))}` : "";
+ return
+ {sortOrder === "none" ? row.sno : index + 1}
+ {row.name}
+ {row.enrollmentNo}
+ {row.first}
+ {row.second}
+ {row.combined}
+ {row.max}
+ setSelectedTier(selectedTier === tier ? null : tier)}>{tier}
+ ;
+ })}{!sortedCombinedRows.length && No students match the selected filter. }
+
+
+
+
+
Grade Distribution Click a bar to filter the table by performance tier.
{combinedRows.length} Students ({ name: t, count: combinedCounts[t] }))} onClick={(state: any) => { const tier = state?.activeLabel as Tier | undefined; if (tier && TIERS.includes(tier)) setSelectedTier(selectedTier === tier ? null : tier); }}>{TIERS.map((t) => | )}
+
Performance Tiers {TIERS.map((t) => setSelectedTier(selectedTier === t ? null : t)} style={{ borderColor: `${COLORS[t]}55`, cursor: "pointer", boxShadow: selectedTier === t ? `0 0 0 2px ${COLORS[t]}33` : undefined }}>{t} {combinedCounts[t]} )}
+
Top 5 Highest Scorers {combinedRankRows.slice(0, 5).map((r, i) => {i + 1} {r.name}
{r.combined} )}Bottom 5 At-Risk Students {[...combinedRankRows].reverse().slice(0, 5).map((r, i) => {i + 1} {r.name}
{r.combined} )}
+
-
-
-
Marks Increase (Top 5) Students whose Midsem 2 score improved.
{increases.length ? increases.map((row, i) => {i + 1}. {row.name}
+{row.change} ) : No increases.
}
-
Marks Decrease (Top 5) Students whose Midsem 2 score fell.
{decreases.length ? decreases.map((row, i) => {i + 1}. {row.name}
{row.change} ) : No decreases.
}
-
-
- >}
-
-
-
-
;
+ >}
+
+ {data && view === "summary" && <>
+ Academic Summary Compare Midsem 1 and Midsem 2 with the same ClassPulse academic analysis theme.
+
+
+ Filtered Students Showing {summaryRows.length} of {students.length} students
{summaryRows.length} Students {summaryShowRank ? "Rank" : "S.No."} Enrollment No. Student Marks Tier {summaryRows.map((row, index) => {summaryShowRank ? index + 1 : row.sno} {row.enrollmentNo} {row.name} {row.marks} setSummaryTier(summaryTier === row.tier ? "all" : row.tier)}>{row.tier} )}{!summaryRows.length && No students match these filters. }
Marks Increase (Top 5) Students whose Midsem 2 score improved.
{increases.length ? increases.map((row, i) => {i + 1}. {row.name}
+{row.change} ) : No increases.
}Marks Decrease (Top 5) Students whose Midsem 2 score fell.
{decreases.length ? decreases.map((row, i) => {i + 1}. {row.name}
{row.change} ) : No decreases.
}
+ >}
+
+
+
+
+ );
}
function Metric({ label, value, detail }: { label: string; value: string | number; detail: string }) {
diff --git a/pages/subject-analysis/[subjectId]/attendance.tsx b/pages/subject-analysis/[subjectId]/attendance.tsx
index 2575d36..7f58f1c 100644
--- a/pages/subject-analysis/[subjectId]/attendance.tsx
+++ b/pages/subject-analysis/[subjectId]/attendance.tsx
@@ -29,6 +29,10 @@ export default function SubjectAttendancePage() {
const [upperBound, setUpperBound] = useState(100);
const [riskTrendFilter, setRiskTrendFilter] = useState("All");
const [riskMonth, setRiskMonth] = useState<"previous" | "current">("current");
+ const [draftLowerBound, setDraftLowerBound] = useState(0);
+ const [draftUpperBound, setDraftUpperBound] = useState(100);
+ const [draftRiskTrendFilter, setDraftRiskTrendFilter] = useState("All");
+ const [draftRiskMonth, setDraftRiskMonth] = useState<"previous" | "current">("current");
const [copied, setCopied] = useState(false);
async function loadAnalysis(sync = false, previous = previousMonth, current = currentMonth, criteria = trendCriteria) {
@@ -87,12 +91,38 @@ export default function SubjectAttendancePage() {
];
function openRisk(options: { trend?: string; lower?: number; upper?: number }) {
- setRiskTrendFilter(options.trend || "All");
- setLowerBound(options.lower ?? 0);
- setUpperBound(options.upper ?? 100);
+ const nextTrend = options.trend || "All";
+ const nextLower = options.lower ?? 0;
+ const nextUpper = options.upper ?? 100;
+ setDraftRiskTrendFilter(nextTrend);
+ setDraftLowerBound(nextLower);
+ setDraftUpperBound(nextUpper);
+ setRiskTrendFilter(nextTrend);
+ setLowerBound(nextLower);
+ setUpperBound(nextUpper);
setView("risk");
}
+ function applyRiskFilters() {
+ const lo = Math.max(0, Math.min(100, Math.min(draftLowerBound, draftUpperBound)));
+ const hi = Math.max(0, Math.min(100, Math.max(draftLowerBound, draftUpperBound)));
+ setLowerBound(lo);
+ setUpperBound(hi);
+ setRiskTrendFilter(draftRiskTrendFilter);
+ setRiskMonth(draftRiskMonth);
+ }
+
+ function resetRiskFilters() {
+ setLowerBound(0);
+ setUpperBound(100);
+ setRiskTrendFilter("All");
+ setRiskMonth("current");
+ setDraftLowerBound(0);
+ setDraftUpperBound(100);
+ setDraftRiskTrendFilter("All");
+ setDraftRiskMonth("current");
+ }
+
async function copyEmails() {
try {
await navigator.clipboard.writeText(riskEmails.join("; "));
@@ -142,7 +172,7 @@ export default function SubjectAttendancePage() {
Attendance Trend
Compare attendance between two months and identify increasing, decreasing, and stable students.
- } label={`Class Average (${previousMonth || "Previous"})`} value={`${previousAverage}%`} change={averageChange} />
+ } label={`Class Average (${previousMonth || "Previous"})`} value={`${previousAverage}%`} />
} label={`Class Average (${currentMonth || "Current"})`} value={`${currentAverage}%`} change={averageChange} />
} label="Students Improving" value={improvingCount} detail={`${students.length ? round1((improvingCount / students.length) * 100) : 0}% of total students`} good />
@@ -212,13 +242,14 @@ export default function SubjectAttendancePage() {
Filter Criteria Narrow the list before copying addresses or sending alerts.
-
{ setLowerBound(0); setUpperBound(100); setRiskTrendFilter("All"); setRiskMonth("current"); }}> Reset
+
Reset
-
Lower Bound (%) setLowerBound(Math.max(0, Math.min(100, Number(e.target.value))))} />
-
Upper Bound (%) setUpperBound(Math.max(0, Math.min(100, Number(e.target.value))))} />
-
Trend setRiskTrendFilter(e.target.value)}>All Increasing Decreasing Stable
-
Month setRiskMonth(e.target.value as "previous" | "current")}>{currentMonth} {previousMonth}
+
Lower Bound (%) setDraftLowerBound(Math.max(0, Math.min(100, Number(e.target.value))))} />
+
Upper Bound (%) setDraftUpperBound(Math.max(0, Math.min(100, Number(e.target.value))))} />
+
Trend setDraftRiskTrendFilter(e.target.value)}>All Increasing Decreasing Stable
+
Month setDraftRiskMonth(e.target.value as "previous" | "current")}>{currentMonth} {previousMonth}
+
Apply
diff --git a/pages/subject-analysis/[subjectId]/combined.tsx b/pages/subject-analysis/[subjectId]/combined.tsx
index 868b79f..e903530 100644
--- a/pages/subject-analysis/[subjectId]/combined.tsx
+++ b/pages/subject-analysis/[subjectId]/combined.tsx
@@ -12,7 +12,7 @@ type SortOrder = "desc" | "asc" | "none";
const MAX = 30;
const TIERS: Tier[] = ["Excellent", "Good", "Needs Attention", "Critical Risk"];
-const COLORS: Record = { Excellent: "#15966a", Good: "#4d75d0", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
+const COLORS: Record = { Excellent: "#4d75d0", Good: "#15966a", "Needs Attention": "#f59e0b", "Critical Risk": "#ef4444" };
const initials = (name: string) => name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
const round1 = (n: number) => Math.round(n * 10) / 10;
const tierFor = (marks: number): Tier => { const p = (marks / MAX) * 100; if (p >= 80) return "Excellent"; if (p >= 60) return "Good"; if (p >= 40) return "Needs Attention"; return "Critical Risk"; };
@@ -67,14 +67,24 @@ export default function CombinedMidsemPage() {
return result;
}, [rows]);
- const filteredRows = useMemo(() => selectedTier ? rows.filter((row) => tierFor(row.combined) === selectedTier) : rows, [rows, selectedTier]);
+ const metricValue = (row: typeof rows[number], field: SortField) => {
+ if (field === "midsem1") return row.first;
+ if (field === "midsem2") return row.second;
+ if (field === "max") return row.max;
+ return row.combined;
+ };
+ const activeGradeField: SortField = sortOrder === "none" ? "combined" : sortField;
+ const filteredRows = useMemo(
+ () => selectedTier ? rows.filter((row) => tierFor(metricValue(row, activeGradeField)) === selectedTier) : rows,
+ [rows, selectedTier, activeGradeField]
+ );
const sortedRows = useMemo(() => {
if (sortOrder === "none") return [...filteredRows];
return [...filteredRows].sort((a, b) => {
- const value = (row: typeof a) => sortField === "midsem1" ? row.first : sortField === "midsem2" ? row.second : sortField === "max" ? row.max : row.combined;
- const difference = value(a) - value(b);
- return sortOrder === "asc" ? difference : -difference;
+ const difference = metricValue(a, sortField) - metricValue(b, sortField);
+ if (difference !== 0) return sortOrder === "asc" ? difference : -difference;
+ return a.name.localeCompare(b.name);
});
}, [filteredRows, sortField, sortOrder]);
@@ -167,15 +177,27 @@ export default function CombinedMidsemPage() {
-
- Rank Student Midsem 1 Midsem 2 Combined Grade
+
+
+
+
+
+
+
+
+
+ {sortOrder === "none" ? "S.No." : "Rank"} Student Midsem 1 Midsem 2 Combined Grade
{sortedRows.map((row, index) => {
- const tier = tierFor(row.combined);
+ const tier = tierFor(metricValue(row, activeGradeField));
+ const sortedColor = sortOrder !== "none" ? COLORS[tier] : undefined;
+ const serial = sortOrder === "none" ? rows.findIndex((item) => item.enrollmentNo === row.enrollmentNo) + 1 : index + 1;
return
- {sortOrder === "none" ? rankRows.findIndex((item) => item.enrollmentNo === row.enrollmentNo) + 1 : index + 1}
- {initials(row.name)} {row.name}
- {row.first} {row.second} {row.combined}
+ {serial}
+ {initials(row.name)} {row.name}
+ {row.first}
+ {row.second}
+ {row.combined}
setSelectedTier(selectedTier === tier ? null : tier)}>{tier}
;
})}
@@ -185,13 +207,6 @@ export default function CombinedMidsemPage() {
-
- Highest Score {highest} {highestNames.join(", ") || "—"}
-
- {TIERS.map((tier) => setSelectedTier(selectedTier === tier ? null : tier)} style={{ borderColor: `${COLORS[tier]}55`, cursor: "pointer", boxShadow: selectedTier === tier ? `0 0 0 2px ${COLORS[tier]}33` : undefined }}>{tier} {counts[tier]} )}
-
-
-
Grade Distribution
Click a bar to filter the student table by performance tier.
@@ -218,15 +233,24 @@ export default function CombinedMidsemPage() {