diff --git a/docker-compose.yml b/docker-compose.yml index 9f1ff7a..7a0ab0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - client_node_modules:/app/node_modules environment: # Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below. - VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8080} + VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8000} extra_hosts: - 'host.docker.internal:host-gateway' diff --git a/package-lock.json b/package-lock.json index 0bc25bb..9009d26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1541,9 +1541,9 @@ } }, "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1888,9 +1888,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2033,6 +2033,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2109,13 +2121,14 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -2480,7 +2493,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3259,6 +3271,19 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3933,7 +3958,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4429,9 +4453,9 @@ } }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4451,12 +4475,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", - "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.14.2" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/api/http.ts b/src/api/http.ts index 09ed19d..a7478de 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -11,6 +11,25 @@ export function setStoredToken(token: string | null): void { else localStorage.removeItem(TOKEN_KEY) } +export function decodeJwtPayload(token: string): T | null { + try { + const payload = token.split('.')[1] + if (!payload) return null + + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') + const json = decodeURIComponent( + atob(base64) + .split('') + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .join(''), + ) + + return JSON.parse(json) as T + } catch { + return null + } +} + export class ApiHttpError extends Error { readonly status: number readonly body: unknown @@ -30,24 +49,36 @@ export async function apiFetch( ): Promise { const attachAuth = opts.attachAuth !== false const headers = new Headers(init.headers) - if (!headers.has('Accept')) headers.set('Accept', 'application/json') + + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json') + } const token = getStoredToken() + if (attachAuth && token && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${token}`) } const url = apiUrl(path) + let res: Response + try { - res = await fetch(url, { ...init, headers }) + res = await fetch(url, { + ...init, + headers, + }) } catch (e) { const hint = import.meta.env.DEV - ? ' Start the API and ensure Vite can reach it (see vite.config.ts; default proxy is http://localhost:8080 unless VITE_PROXY_API is set).' + ? ' Start the API and ensure Vite can reach it. Default proxy is http://localhost:8000 unless VITE_PROXY_API or VITE_API_ORIGIN is set.' : '' + const reason = e instanceof Error ? e.message : 'Failed to fetch' + throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null) } + const body: unknown = await res.json().catch(() => null) if (!res.ok) { @@ -55,6 +86,7 @@ export async function apiFetch( typeof body === 'object' && body !== null && 'message' in body ? String((body as { message?: unknown }).message ?? '') : '' + throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body) } diff --git a/src/api/session.ts b/src/api/session.ts index 6d15ebd..af59d7c 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -23,7 +23,6 @@ import type { GradingOverviewResponse, IpBansResponse, LandingPageResponse, - LoginFailure, LoginResponse, LoginSuccess, LateSlipLogsResponse, @@ -97,30 +96,136 @@ import type { FamilySearchResponse, } from './types' +type LoginApiObject = Record + +function isObject(value: unknown): value is LoginApiObject { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null +} + +function numberValue(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { + return Number(value) + } + return 0 +} + +function rolesToMap(value: unknown): Record { + if (Array.isArray(value)) { + return Object.fromEntries( + value + .map((role) => String(role ?? '').trim()) + .filter(Boolean) + .map((role) => [role, true]), + ) + } + + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value) + .filter(([role, enabled]) => role.trim() !== '' && Boolean(enabled)) + .map(([role]) => [role, true]), + ) + } + + return {} +} + +function normalizeLoginSuccess(body: unknown): LoginSuccess | null { + if (!isObject(body)) return null + + const data = isObject(body.data) ? body.data : null + const jwt = data && isObject(data.jwt) ? data.jwt : null + const sanctum = data && isObject(data.sanctum) ? data.sanctum : null + + const token = + stringValue(body.token) ?? + stringValue(body.access_token) ?? + (data ? stringValue(data.token) : null) ?? + (data ? stringValue(data.access_token) : null) ?? + (jwt ? stringValue(jwt.access_token) : null) ?? + (sanctum ? stringValue(sanctum.access_token) : null) + + const rawUser = + (isObject(body.user) ? body.user : null) ?? + (data && isObject(data.user) ? data.user : null) + + if (!token || !rawUser) return null + + const firstName = stringValue(rawUser.firstname) ?? stringValue(rawUser.first_name) ?? '' + const lastName = stringValue(rawUser.lastname) ?? stringValue(rawUser.last_name) ?? '' + const name = + stringValue(rawUser.name) ?? + [firstName, lastName].filter(Boolean).join(' ').trim() ?? + stringValue(rawUser.email) ?? + 'User' + + return { + status: true, + token, + user: { + id: numberValue(rawUser.id), + name, + roles: rolesToMap(rawUser.roles), + class_section_id: + rawUser.class_section_id === null || rawUser.class_section_id === undefined + ? null + : numberValue(rawUser.class_section_id), + class_section_name: stringValue(rawUser.class_section_name), + }, + } +} + export async function loginRequest( email: string, password: string, ): Promise { - const body = await apiFetch( - '/api/v1/auth/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }, - { attachAuth: false }, - ) + const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login'] + let body: unknown = null + let lastError: unknown = null - if (!body || typeof body !== 'object' || !('status' in body)) { - throw new Error('Invalid login response from API.') + for (const path of loginPaths) { + try { + body = await apiFetch( + path, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + { attachAuth: false }, + ) + lastError = null + break + } catch (error) { + lastError = error + if (!(error instanceof ApiHttpError) || error.status !== 404) { + throw error + } + } } - if ((body as { status?: boolean }).status === false) { - const fail = body as LoginFailure - return { status: false, message: fail.message } + if (lastError) throw lastError + + if (isObject(body)) { + const explicitFailure = + body.status === false || + body.success === false || + String(body.result ?? '').toLowerCase() === 'false' + + if (explicitFailure) { + return { status: false, message: stringValue(body.message) ?? 'Login failed.' } + } } - return body as LoginSuccess + const normalized = normalizeLoginSuccess(body) + if (normalized) return normalized + + throw new Error('Invalid login response from API. Expected a token and user object.') } export async function fetchDashboardRoute(): Promise> { @@ -915,8 +1020,9 @@ export async function notifyTeacherSubmissions(payload: { ) } -export async function fetchSubjectCurriculum(): Promise { - return apiFetch('/api/v1/subjects/curriculum') +export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch(`/api/v1/subjects/curriculum${query}`) } export async function createSubjectCurriculum(payload: { @@ -925,6 +1031,7 @@ export async function createSubjectCurriculum(payload: { unit_number?: number | null unit_title?: string | null chapter_name: string + school_year?: string | null }): Promise> { return apiFetch>('/api/v1/subjects/curriculum', { method: 'POST', diff --git a/src/api/types.ts b/src/api/types.ts index 5579ddf..2a03cdb 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -979,6 +979,7 @@ export type SubjectCurriculumEntryRow = { unit_number?: number | string | null unit_title?: string | null chapter_name: string + school_year?: string | null } export type SubjectCurriculumResponse = { @@ -986,6 +987,8 @@ export type SubjectCurriculumResponse = { classes: SubjectCurriculumClassRow[] entries: SubjectCurriculumEntryRow[] subject_labels: Record + school_year?: string | null + school_years?: string[] } export type PublicCompetitionRow = { diff --git a/src/lib/apiOrigin.ts b/src/lib/apiOrigin.ts index f4fb861..76cd521 100644 --- a/src/lib/apiOrigin.ts +++ b/src/lib/apiOrigin.ts @@ -3,20 +3,23 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org' /** * Laravel API base URL. * - * **Vite dev:** same-origin `/api/...`; `vite.config.ts` proxies to the API (default `http://localhost:8080`). - * Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser). + * Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel. + * Set `VITE_API_ORIGIN` when you want the browser to call Laravel directly, e.g. + * `VITE_API_ORIGIN=http://localhost:8000 npm run dev`. * - * **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`. + * If `VITE_API_ORIGIN` is not set in dev, `vite.config.ts` proxies `/api` to + * `VITE_PROXY_API` or `http://localhost:8000` by default. */ export function apiOrigin(): string { - if (import.meta.env.DEV) { - return '' - } - const v = import.meta.env.VITE_API_ORIGIN if (typeof v === 'string' && v.trim()) { return v.replace(/\/$/, '') } + + if (import.meta.env.DEV) { + return '' + } + return PROD_API_ORIGIN } diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index cafd13e..babcf5a 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import './AdminDailyAttendance.css' import { ApiHttpError } from '../api/http' +import { CiAcademicFilter } from '../components/CiPartials' import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions' import { assignTeacherClass, @@ -3659,15 +3660,10 @@ export function AdminRemovedStudentsPage() { return ( {error ?
{error}
: null} -
{ e.preventDefault(); void load() }}> -
- - setSchoolYear(e.target.value)} placeholder="2025-2026" /> -
-
- -
-
+ void load(year)} + />
@@ -3808,7 +3804,8 @@ export function AdminSearchResultsPage() { /** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */ export function AdminSectionsPromotionTotalsPage() { - const [schoolYear, setSchoolYear] = useState('') + const [searchParams] = useSearchParams() + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -3839,26 +3836,14 @@ export function AdminSectionsPromotionTotalsPage() { sections from these totals.

-
-
- - setSchoolYear(e.target.value)} - placeholder="e.g. 2025-2026" - /> -
-
- -
-
- - Auto-distribute sections - -
+
+ void load(year)} + /> + + Auto-distribute sections +
{error ?
{error}
: null} @@ -3907,7 +3892,8 @@ export function AdminSectionsPromotionTotalsPage() { } export function AdminSectionsAutoDistributePage() { - const [schoolYear, setSchoolYear] = useState('') + const [searchParams] = useSearchParams() + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [studentsPerSection, setStudentsPerSection] = useState(20) const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) @@ -3956,15 +3942,16 @@ export function AdminSectionsAutoDistributePage() { {message ?
{message}
: null}
-
- - setSchoolYear(e.target.value)} /> +
+ void load(year)} + />
setStudentsPerSection(Number(e.target.value) || 1)} />
-
@@ -3995,6 +3982,7 @@ export function AdminSectionsAutoDistributePage() { } export function AdminStudentClassAssignmentPage() { + const [searchParams] = useSearchParams() type StudentAssignmentSortKey = | 'name' | 'age' @@ -4003,7 +3991,7 @@ export function AdminStudentClassAssignmentPage() { | 'assigned_classes' | 'registration_date' | 'school_year' - const [schoolYear, setSchoolYear] = useState('') + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [students, setStudents] = useState([]) const [classes, setClasses] = useState([]) const [selectedStudentId, setSelectedStudentId] = useState(null) @@ -4118,11 +4106,10 @@ export function AdminStudentClassAssignmentPage() { {message ?
{message}
: null}
- -
- setSchoolYear(e.target.value)} /> - -
+ void load(year)} + />
@@ -4226,6 +4213,7 @@ export function AdminStudentClassAssignmentPage() { } export function AdminStudentProfilesPage() { + const [searchParams] = useSearchParams() type StudentProfileSortKey = | 'school_id' | 'firstname' @@ -4237,7 +4225,7 @@ export function AdminStudentProfilesPage() { | 'is_active' | 'photo_consent' | 'registration_date' - const [schoolYear, setSchoolYear] = useState('') + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [students, setStudents] = useState([]) const [editing, setEditing] = useState(null) const [parentDetails, setParentDetails] = useState | null>(null) @@ -4360,10 +4348,10 @@ export function AdminStudentProfilesPage() { {message ?
{message}
: null}
-
- setSchoolYear(e.target.value)} placeholder="School year" /> - -
+ void load(year)} + />
>([]) const [entries, setEntries] = useState([]) const [subjects, setSubjects] = useState>({}) + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' }) const [editingId, setEditingId] = useState(null) const [message, setMessage] = useState(null) @@ -4477,14 +4467,15 @@ export function AdminSubjectCurriculumPage() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - async function load() { + async function load(year = schoolYear) { setLoading(true) setError(null) try { - const data = await fetchSubjectCurriculum() + const data = await fetchSubjectCurriculum(year || undefined) setClasses(data.classes ?? []) setEntries(data.entries ?? []) setSubjects(data.subject_labels ?? {}) + setSchoolYear(data.school_year ?? year ?? '') } catch (e: unknown) { setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.') } finally { @@ -4506,6 +4497,7 @@ export function AdminSubjectCurriculumPage() { unit_number: form.unit_number ? Number(form.unit_number) : null, unit_title: form.unit_title || null, chapter_name: form.chapter_name, + school_year: schoolYear || undefined, } try { if (editingId) { @@ -4543,6 +4535,10 @@ export function AdminSubjectCurriculumPage() { {message ?
{message}
: null} {error ?
{error}
: null} + void load(year)} + />
@@ -4598,17 +4594,17 @@ export function AdminSubjectCurriculumPage() {
Current curriculum entries
- + {loading ? ( - ) : entries.length === 0 ? ( - @@ -4616,6 +4612,7 @@ export function AdminSubjectCurriculumPage() { entries.map((entry) => ( + @@ -4661,7 +4658,8 @@ export function AdminSubjectCurriculumPage() { } export function AdminTeacherClassAssignmentPage() { - const [schoolYear, setSchoolYear] = useState('') + const [searchParams] = useSearchParams() + const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [teachers, setTeachers] = useState([]) const [classes, setClasses] = useState([]) const [selectedTeacherId, setSelectedTeacherId] = useState(null) @@ -4711,7 +4709,7 @@ export function AdminTeacherClassAssignmentPage() { {message ?
{message}
: null}
-
setSchoolYear(e.target.value)} />
+
void load(year)} />
@@ -4757,7 +4755,7 @@ export function AdminTeacherClassAssignmentPage() { } export function AdminTeacherSubmissionsPage() { - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const [rows, setRows] = useState([]) const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({}) const [selected, setSelected] = useState>({}) @@ -4809,6 +4807,18 @@ export function AdminTeacherSubmissionsPage() { {message ?
{message}
: null} {error ?
{error}
: null} + { + const next = new URLSearchParams(searchParams) + if (year) next.set('school_year', year) + else next.delete('school_year') + if (sem) next.set('semester', sem) + else next.delete('semester') + setSearchParams(next) + }} + />
Completion
{summary.submission_percentage ?? 0}%
Missing Items
{summary.missing_items ?? 0}
diff --git a/src/pages/attendance/BadgeScanningListPage.tsx b/src/pages/attendance/BadgeScanningListPage.tsx index b1c6991..5ac25c4 100644 --- a/src/pages/attendance/BadgeScanningListPage.tsx +++ b/src/pages/attendance/BadgeScanningListPage.tsx @@ -4,6 +4,7 @@ import { type AttendanceManagementDashboard, } from '../../api/attendanceManagement' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' const today = new Date().toISOString().slice(0, 10) @@ -13,6 +14,8 @@ function nice(value: string | null | undefined): string { } export function BadgeScanningListPage() { + const { options, selectedYear } = useSchoolYearOptions({}) + const [schoolYear, setSchoolYear] = useState(selectedYear) const [date, setDate] = useState(today) const [status, setStatus] = useState('') const [q, setQ] = useState('') @@ -22,13 +25,18 @@ export function BadgeScanningListPage() { const params = useMemo( () => ({ + school_year: schoolYear, date, attendance_status: status, q, }), - [date, status, q], + [schoolYear, date, status, q], ) + useEffect(() => { + if (!schoolYear && selectedYear) setSchoolYear(selectedYear) + }, [schoolYear, selectedYear]) + async function load() { setLoading(true) setError(null) @@ -73,6 +81,15 @@ export function BadgeScanningListPage() {
+
+ + +
+
-
+
-
+
ClassSubjectUnitUnit titleChapter / SurahActions
ClassSchool YearSubjectUnitUnit titleChapter / SurahActions
+ Loading…
+ No curriculum records yet.
{entry.class_name ?? '—'}{entry.school_year ?? schoolYear ?? '—'} {subjects[entry.subject] ?? entry.subject} {entry.unit_number ?? '—'} {entry.unit_title ?? '—'}