diff --git a/public/attendnace.png b/public/attendnace.png new file mode 100644 index 0000000..5b7e2be Binary files /dev/null and b/public/attendnace.png differ diff --git a/src/api/session.ts b/src/api/session.ts index a208538..a278998 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -857,6 +857,33 @@ export async function fetchExamDraftAdminData(): Promise return apiFetch('/api/v1/exams/drafts/admin') } +export async function openProtectedApiFile(path: string): Promise { + const headers = new Headers() + const token = getStoredToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + + const res = await fetch(apiUrl(path), { headers }) + if (!res.ok) { + const body: unknown = await res.json().catch(() => null) + const msg = + typeof body === 'object' && body !== null && 'message' in body + ? String((body as { message?: unknown }).message ?? '') + : '' + throw new Error(msg || `HTTP ${res.status}`) + } + + const blob = await res.blob() + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + link.target = '_blank' + link.rel = 'noreferrer' + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) +} + async function fetchMultipartJson(path: string, formData: FormData): Promise { const headers = new Headers({ Accept: 'application/json' }) const token = getStoredToken() @@ -868,10 +895,26 @@ async function fetchMultipartJson(path: string, formData: FormData): Promise< }) const body: unknown = await res.json().catch(() => null) if (!res.ok) { - const msg = + let msg = typeof body === 'object' && body !== null && 'message' in body ? String((body as { message?: unknown }).message ?? '') : '' + + if ( + msg === 'Validation failed.' && + typeof body === 'object' && + body !== null && + 'errors' in body + ) { + const errors = (body as { errors?: Record }).errors + if (errors && typeof errors === 'object') { + const firstField = Object.values(errors)[0] + if (Array.isArray(firstField) && firstField.length > 0) { + msg = String(firstField[0] ?? msg) + } + } + } + throw new Error(msg || `HTTP ${res.status}`) } return body as T diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index 35256f1..9825984 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -41,6 +41,7 @@ import { fetchTeacherSubmissions, fetchUsers, notifyTeacherSubmissions, + openProtectedApiFile, removeStudentClass, reviewExamDraft, saveNotificationAlerts, @@ -75,8 +76,6 @@ import type { TeacherSubmissionReportRow, UserListRow, } from '../api/types' -import { apiUrl } from '../lib/apiOrigin' - function AdminParityShell({ title, children, @@ -104,6 +103,19 @@ function formatDateInput(value?: string | null) { return value ? String(value).slice(0, 10) : '' } +function normalizeAttendanceSearch(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() +} + +function attendanceSearchMatchesPrefix(query: string, values: string[]) { + if (query === '') return true + return values.some((value) => { + const normalized = normalizeAttendanceSearch(value) + if (normalized.startsWith(query)) return true + return normalized.split(' ').some((part) => part.startsWith(query)) + }) +} + function monthLabel(value: string) { const date = new Date(`${value}T00:00:00`) if (Number.isNaN(date.getTime())) return value @@ -140,6 +152,19 @@ type AttendanceSectionSnapshot = { dates: string[] } +type AttendanceRenderedStudentRow = { + sectionId: string + sectionName: string + student: AttendanceSectionSnapshot['students'][number] +} + +type AttendanceGradeGroup = { + key: string + label: string + order: number + sections: AttendanceSectionSnapshot[] +} + function buildAttendanceSections(data: Awaited>): AttendanceSectionSnapshot[] { const grades = data.grades ?? {} const studentsBySection = data.students_by_section ?? {} @@ -196,6 +221,39 @@ function buildAttendanceSections(data: Awaited() + + function bucketFor(section: AttendanceSectionSnapshot): { key: string; label: string; order: number } { + const name = section.className + if (/\bkg\b/i.test(name)) return { key: 'kg', label: 'KG', order: 0 } + if (/\byouth\b/i.test(name)) return { key: 'youth', label: 'Youth', order: 99 } + if (/\barabic\b/i.test(name)) return { key: 'arabic', label: 'Arabic', order: 100 } + const gradeMatch = /\bgrade\s*(\d+)\b/i.exec(name) + if (gradeMatch) { + const n = Number(gradeMatch[1]) + return { key: `g${n}`, label: `Grade ${n}`, order: n } + } + if (section.classId === 0) return { key: 'kg', label: 'KG', order: 0 } + if (section.classId === 13) return { key: 'youth', label: 'Youth', order: 99 } + return { key: `g${section.classId}`, label: `Grade ${section.classId}`, order: section.classId } + } + + for (const section of sections) { + const bucket = bucketFor(section) + const existing = groups.get(bucket.key) ?? { ...bucket, sections: [] } + existing.sections.push(section) + groups.set(bucket.key, existing) + } + + return Array.from(groups.values()) + .map((group) => ({ + ...group, + sections: group.sections.sort((a, b) => a.className.localeCompare(b.className)), + })) + .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) +} + function buildAttendanceAnalysis(sections: AttendanceSectionSnapshot[]) { const overall = { present: 0, late: 0, absent: 0 } const byClass = new Map() @@ -1441,20 +1499,35 @@ export function AdminClassSectionPage() { } export function AdminDailyAttendancePage() { + const [searchParams, setSearchParams] = useSearchParams() + const semesterParam = searchParams.get('semester') ?? '' + const schoolYearParam = searchParams.get('school_year') ?? '' const [data, setData] = useState> | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [studentSearch, setStudentSearch] = useState('') + const [tableSearch, setTableSearch] = useState('') + const [activeGradeKey, setActiveGradeKey] = useState('') const [activeSectionId, setActiveSectionId] = useState('') + const [termFilters, setTermFilters] = useState({ + semester: semesterParam, + school_year: schoolYearParam, + }) useEffect(() => { let cancelled = false ;(async () => { setLoading(true) try { - const result = await fetchAdministratorDailyAttendance() + const result = await fetchAdministratorDailyAttendance({ + semester: semesterParam || null, + schoolYear: schoolYearParam || null, + }) if (cancelled) return setData(result) const sections = buildAttendanceSections(result) + const groups = buildAttendanceGradeGroups(sections) + setActiveGradeKey(groups[0]?.key ?? '') setActiveSectionId(sections[0]?.sectionId ?? '') setError(null) } catch (e) { @@ -1466,21 +1539,233 @@ export function AdminDailyAttendancePage() { return () => { cancelled = true } - }, []) + }, [schoolYearParam, semesterParam]) + + useEffect(() => { + setTermFilters({ + semester: semesterParam, + school_year: schoolYearParam, + }) + }, [schoolYearParam, semesterParam]) const sections = useMemo(() => (data ? buildAttendanceSections(data) : []), [data]) - const activeSection = sections.find((row) => row.sectionId === activeSectionId) ?? sections[0] - const visibleDates = activeSection?.dates.slice(-8) ?? [] + const gradeGroups = useMemo(() => buildAttendanceGradeGroups(sections), [sections]) + const normalizedSearch = normalizeAttendanceSearch(studentSearch) + const searchMatches = useMemo(() => { + if (normalizedSearch === '') return [] + return sections.flatMap((section) => + section.students + .filter((student) => { + return attendanceSearchMatchesPrefix(normalizedSearch, [ + student.schoolId, + student.name, + `${student.schoolId} ${student.name}`, + `${student.name} ${student.schoolId}`, + `${student.name} ${section.className}`, + ]) + }) + .map((student) => ({ + section, + student, + })), + ) + }, [normalizedSearch, sections]) + const studentSuggestions = useMemo(() => { + const seen = new Set() + return sections.flatMap((section) => + section.students.flatMap((student) => { + const label = `${student.schoolId} - ${student.name}${section.className ? ` (${section.className})` : ''}` + if (seen.has(label)) return [] + seen.add(label) + return [label] + }), + ) + }, [sections]) + const selectedSearchMatch = searchMatches[0] ?? null + const displayedGrade = gradeGroups.find((group) => group.key === activeGradeKey) ?? gradeGroups[0] + const gradeSections = displayedGrade?.sections ?? [] + const displayedSection = + gradeSections.find((row) => row.sectionId === activeSectionId) ?? + gradeSections[0] ?? + sections.find((row) => row.sectionId === activeSectionId) ?? + sections[0] + const sectionFilter = normalizeAttendanceSearch(tableSearch) + const renderedStudents = useMemo(() => { + if (normalizedSearch !== '') { + const seen = new Set() + return searchMatches.flatMap((match) => { + const key = `${match.section.sectionId}:${match.student.id}` + if (seen.has(key)) return [] + seen.add(key) + return [{ + sectionId: match.section.sectionId, + sectionName: match.section.className, + student: match.student, + }] + }) + } + + if (!displayedSection) return [] + + return displayedSection.students + .filter((student) => { + if (sectionFilter === '') return true + return attendanceSearchMatchesPrefix(sectionFilter, [ + student.schoolId, + student.name, + `${student.schoolId} ${student.name}`, + `${student.name} ${student.schoolId}`, + `${student.name} ${displayedSection.className}`, + ]) + }) + .map((student) => ({ + sectionId: displayedSection.sectionId, + sectionName: displayedSection.className, + student, + })) + }, [displayedSection, normalizedSearch, searchMatches, sectionFilter]) + const visibleDates = useMemo(() => { + if (normalizedSearch !== '') { + const dates = new Set() + for (const match of searchMatches) { + for (const date of match.section.dates) dates.add(date) + } + return Array.from(dates).sort() + } + return displayedSection?.dates ?? [] + }, [displayedSection, normalizedSearch, searchMatches]) + const visibleStudents = useMemo(() => { + return renderedStudents.filter(({ student, sectionName }) => { + if (sectionFilter === '') return true + return attendanceSearchMatchesPrefix(sectionFilter, [ + student.schoolId, + student.name, + `${student.schoolId} ${student.name}`, + `${student.name} ${student.schoolId}`, + `${student.name} ${sectionName}`, + ]) + }) + }, [renderedStudents, sectionFilter]) + + useEffect(() => { + if (!displayedGrade) return + if (!gradeSections.some((section) => section.sectionId === activeSectionId)) { + setActiveSectionId(displayedGrade.sections[0]?.sectionId ?? '') + } + }, [activeSectionId, displayedGrade, gradeSections]) return ( -
- - Attendance Analysis - +
+

Attendance Management

+
+ +
{ + event.preventDefault() + const next = new URLSearchParams() + if (termFilters.school_year.trim() !== '') next.set('school_year', termFilters.school_year.trim()) + if (termFilters.semester.trim() !== '') next.set('semester', termFilters.semester.trim()) + setSearchParams(next) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Search Student + setStudentSearch(event.target.value)} + /> + +
+ + {studentSuggestions.map((label) => ( + + Searches all grades. + {normalizedSearch !== '' ? ( +
0 ? 'text-success' : 'text-danger'}`}> + {searchMatches.length > 0 + ? `${searchMatches.length} match${searchMatches.length === 1 ? '' : 'es'} found.${selectedSearchMatch ? ` First match: ${selectedSearchMatch.section.className}.` : ''}` + : 'No student matched the current search.'} +
+ ) : null} + {normalizedSearch !== '' && searchMatches.length > 0 ? ( +
+ {searchMatches.slice(0, 12).map((match) => ( + + ))} +
+ ) : null} +
+
+ +
+
+ + Attendance Analysis + +
{error ?
{error}
: null} {loading ? ( @@ -1489,66 +1774,150 @@ export function AdminDailyAttendancePage() {

No attendance sections found.

) : ( <> -
    - {sections.map((section) => ( -
  • +
      + {gradeGroups.map((group) => ( +
    • ))}
    - {activeSection ? ( -
    - - - - - - {visibleDates.map((date) => ( - - ))} - - - - - - - {activeSection.students.map((student) => ( - - - - {visibleDates.map((date) => { - const entry = student.entriesByDate[date] - return ( - - ) - })} - - - +
    + {gradeSections.map((section) => ( + + ))} +
    + + {displayedSection ? ( + <> +
    +

    {normalizedSearch !== '' ? 'Search Results' : displayedSection.className}

    +
    + + + {visibleDates[0] ? formatDate(visibleDates[0]) : '—'} {visibleDates.length > 1 ? `– ${formatDate(visibleDates[visibleDates.length - 1])}` : ''} + + +
    +
    + +
    +
    + Show + + entries +
    +
    + + setTableSearch(event.target.value)} + /> +
    +
    + +
    +
    School IDStudent Name{formatDate(date)}PresentLateAbsent
    {student.schoolId}{student.name} - - {entry?.status ? entry.status : '—'} - - {student.summary.present}{student.summary.late}{student.summary.absent}
    + + + + {normalizedSearch !== '' ? : null} + + {visibleDates.map((date) => ( + + ))} + + + + - ))} - -
    School IDSectionStudent Name{formatDate(date)}PresentLateAbsentT
    -
    + + + {visibleStudents.map(({ sectionId, sectionName, student }) => ( + + {student.schoolId} + {normalizedSearch !== '' ? {sectionName} : null} + {student.name} + {visibleDates.map((date) => { + const entry = student.entriesByDate[date] + const status = entry?.status ?? '' + const badgeClass = + status === 'present' + ? 'success' + : status === 'late' + ? 'warning' + : status === 'absent' + ? 'danger' + : 'secondary' + const shortLabel = + status === 'present' + ? 'P' + : status === 'late' + ? 'L' + : status === 'absent' + ? 'A' + : '—' + return ( + +
    + +
    + {shortLabel} +
    +
    + + ) + })} + {student.summary.present} + {student.summary.late} + {student.summary.absent} + {student.summary.total} + + ))} + {visibleStudents.length === 0 ? ( + + + No students match the current search. + + + ) : null} + + +
+ ) : null} )} @@ -1688,6 +2057,7 @@ export function AdminExamDraftsPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const [activeTab, setActiveTab] = useState<'submissions' | 'legacy'>('submissions') const [legacyFile, setLegacyFile] = useState(null) const [legacyForm, setLegacyForm] = useState({ class_section_id: '', @@ -1721,8 +2091,16 @@ export function AdminExamDraftsPage() { async function submitLegacy(event: FormEvent) { event.preventDefault() - if (!legacyFile) return + if (!legacyForm.class_section_id) { + setError('Select a class section before uploading a legacy exam.') + return + } + if (!legacyFile) { + setError('Choose a file to upload.') + return + } try { + setError(null) const result = await uploadLegacyExamDraft({ class_section_id: Number(legacyForm.class_section_id), school_year: legacyForm.school_year, @@ -1754,120 +2132,419 @@ export function AdminExamDraftsPage() { } } - const teacherFileUrl = (name?: string | null) => (name ? apiUrl(`/api/v1/files/exams/teacher/${encodeURIComponent(name)}`) : null) - const finalFileUrl = (name?: string | null) => (name ? apiUrl(`/api/v1/files/exams/final/${encodeURIComponent(name)}`) : null) + const teacherFilePath = (name?: string | null) => (name ? `/api/v1/files/exams/teacher/${encodeURIComponent(name)}` : null) + const finalFilePath = (name?: string | null) => (name ? `/api/v1/files/exams/final/${encodeURIComponent(name)}` : null) + const allowedExtensions = data?.allowed_extensions ?? ['doc', 'docx', 'pdf'] + const maxUploadMb = Math.max(1, Math.round((data?.max_upload_bytes ?? 12 * 1024 * 1024) / 1024 / 1024)) + const classSections = data?.class_sections ?? [] + const drafts = (data?.drafts ?? []).filter((draft) => !Boolean(draft.is_legacy)) + const legacyByClass = data?.legacy_by_class ?? {} + + const draftsByClass = useMemo(() => { + const grouped = new Map() + for (const draft of drafts) { + const sectionId = Number(draft.class_section_id ?? 0) + if (!sectionId) continue + const rows = grouped.get(sectionId) ?? [] + rows.push(draft) + grouped.set(sectionId, rows) + } + for (const rows of grouped.values()) { + rows.sort((a, b) => { + const aTime = String(a.reviewed_at ?? '') + const bTime = String(b.reviewed_at ?? '') + return bTime.localeCompare(aTime) + }) + } + return grouped + }, [drafts]) + + const visibleClasses = useMemo(() => { + const base = classSections + .map((section) => ({ + class_section_id: Number(section.class_section_id ?? 0), + class_section_name: section.class_section_name ?? '', + })) + .filter((section) => section.class_section_id > 0) + + const seen = new Set() + const merged: Array<{ class_section_id: number; class_section_name: string }> = [] + + for (const section of base) { + if (seen.has(section.class_section_id)) continue + seen.add(section.class_section_id) + merged.push(section) + } + + for (const draft of drafts) { + const sectionId = Number(draft.class_section_id ?? 0) + if (!sectionId || seen.has(sectionId)) continue + seen.add(sectionId) + merged.push({ + class_section_id: sectionId, + class_section_name: draft.class_section_name ?? `Class ${sectionId}`, + }) + } + + return merged.sort((a, b) => a.class_section_name.localeCompare(b.class_section_name)) + }, [classSections, drafts]) + + function statusBadgeClass(status?: string | null): string { + switch (String(status ?? '').toLowerCase()) { + case 'submitted': + return 'bg-warning text-dark' + case 'reviewed': + return 'bg-info text-dark' + case 'finalized': + return 'bg-success' + case 'rejected': + return 'bg-danger' + default: + return 'bg-secondary' + } + } return ( {message ?
{message}
: null} {error ?
{error}
: null} -
-
Upload Legacy Exam
-
-
-
- - -
-
- - setLegacyForm((current) => ({ ...current, school_year: event.target.value }))} required /> -
-
- - setLegacyForm((current) => ({ ...current, semester: event.target.value }))} required /> -
-
- - -
-
- - `.${ext}`).join(',')} onChange={(event) => setLegacyFile(event.target.files?.[0] ?? null)} required /> -
-
- -
-
+
+
+

{data?.semester || 'Semester'} {data?.school_year || ''}

+
+
+ Allowed files: {allowedExtensions.join(', ')} + Max size: {maxUploadMb} MB
-
-
Submissions
-
-
- - - - - - - - - - - - - - - {loading ? ( - - ) : (data?.drafts ?? []).length === 0 ? ( - - ) : ( - (data?.drafts ?? []).map((draft) => { - const teacherName = `${draft.teacher_first ?? ''} ${draft.teacher_last ?? ''}`.trim() || 'Admin upload' - const state = reviewState[draft.id] ?? { review_status: draft.status ?? '', admin_comments: draft.admin_comments ?? '', final_file: null } - return ( - - - - - - - - -
TeacherClassTitleTypeVersionStatusFilesReview
Loading drafts…
No exam drafts found.
{teacherName}{draft.class_section_name ?? '—'} -
{draft.draft_title ?? 'Untitled'}
-
{draft.description ?? ''}
-
{draft.exam_type ?? '—'}{draft.version ?? 1}{draft.status ?? '—'} -
- {teacherFileUrl(draft.teacher_file) ? Teacher file : null} - {finalFileUrl(draft.final_pdf_file ?? draft.final_file) ? Final file : null} -
-
-
- -