update the new school year model

This commit is contained in:
root
2026-06-07 20:01:56 -04:00
parent 8e79201a3c
commit b6513ab22f
9 changed files with 304 additions and 107 deletions
+1 -1
View File
@@ -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'
+43 -19
View File
@@ -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"
+35 -3
View File
@@ -11,6 +11,25 @@ export function setStoredToken(token: string | null): void {
else localStorage.removeItem(TOKEN_KEY)
}
export function decodeJwtPayload<T = unknown>(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<T>(
): Promise<T> {
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<T>(
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
}
+119 -12
View File
@@ -23,7 +23,6 @@ import type {
GradingOverviewResponse,
IpBansResponse,
LandingPageResponse,
LoginFailure,
LoginResponse,
LoginSuccess,
LateSlipLogsResponse,
@@ -97,12 +96,102 @@ import type {
FamilySearchResponse,
} from './types'
type LoginApiObject = Record<string, unknown>
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<string, boolean> {
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<LoginResponse> {
const body = await apiFetch<unknown>(
'/api/v1/auth/login',
const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
let body: unknown = null
let lastError: unknown = null
for (const path of loginPaths) {
try {
body = await apiFetch<unknown>(
path,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -110,17 +199,33 @@ export async function loginRequest(
},
{ attachAuth: false },
)
if (!body || typeof body !== 'object' || !('status' in body)) {
throw new Error('Invalid login response from API.')
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<ApiEnvelope<DashboardPayload>> {
@@ -915,8 +1020,9 @@ export async function notifyTeacherSubmissions(payload: {
)
}
export async function fetchSubjectCurriculum(): Promise<SubjectCurriculumResponse> {
return apiFetch<SubjectCurriculumResponse>('/api/v1/subjects/curriculum')
export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise<SubjectCurriculumResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<SubjectCurriculumResponse>(`/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<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
method: 'POST',
+3
View File
@@ -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<string, string>
school_year?: string | null
school_years?: string[]
}
export type PublicCompetitionRow = {
+10 -7
View File
@@ -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
}
+61 -51
View File
@@ -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 (
<AdminParityShell title="Student Removal">
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="row g-2 align-items-end mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
<div className="col-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="2025-2026" />
</div>
<div className="col-auto">
<button className="btn btn-primary" type="submit">Apply</button>
</div>
</form>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4">
<div className="col-xl-6">
<div className="card shadow-sm">
@@ -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<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -3839,27 +3836,15 @@ export function AdminSectionsPromotionTotalsPage() {
sections from these totals.
</p>
<div className="row g-2 align-items-end mb-3 flex-wrap">
<div className="col-sm-4 col-lg-3">
<label className="form-label">School year</label>
<input
className="form-control"
value={schoolYear}
onChange={(e) => setSchoolYear(e.target.value)}
placeholder="e.g. 2025-2026"
<div className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-auto">
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>
Refresh
</button>
</div>
<div className="col-auto">
<Link className="btn btn-outline-primary" to="/app/administrator/sections/auto-distribute">
<Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
Auto-distribute sections
</Link>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : 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<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
@@ -3956,15 +3942,16 @@ export function AdminSectionsAutoDistributePage() {
<AdminParityShell title="Auto-Distribute Students into Sections">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-2 align-items-end mb-3">
<div className="col-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
<div className="col-lg-6">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-sm-4 col-lg-3">
<label className="form-label">Students per section</label>
<input type="number" min={1} className="form-control" value={studentsPerSection} onChange={(e) => setStudentsPerSection(Number(e.target.value) || 1)} />
</div>
<div className="col-auto"><button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Refresh Totals</button></div>
<div className="col-auto"><button className="btn btn-primary" type="button" onClick={() => void runAll()} disabled={rows.length === 0}>Generate All</button></div>
</div>
<div className="table-responsive">
@@ -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<StudentAssignmentRow[]>([])
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
@@ -4118,11 +4106,10 @@ export function AdminStudentClassAssignmentPage() {
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4">
<div className="col-lg-4">
<label className="form-label">School Year</label>
<div className="d-flex gap-2">
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
</div>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-lg-8">
<div className="card shadow-sm">
@@ -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<StudentDirectoryRow[]>([])
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
@@ -4360,10 +4348,10 @@ export function AdminStudentProfilesPage() {
<AdminParityShell title="Student Profiles">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="d-flex flex-wrap gap-2 mb-3 justify-content-between">
<div className="d-flex gap-2">
<input className="form-control" style={{ maxWidth: 240 }} value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="School year" />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
</div>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="input-group" style={{ maxWidth: 360 }}>
<input
className="form-control"
@@ -4467,9 +4455,11 @@ export function AdminStudentProfilesPage() {
}
export function AdminSubjectCurriculumPage() {
const [searchParams] = useSearchParams()
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
const [subjects, setSubjects] = useState<Record<string, string>>({})
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<number | null>(null)
const [message, setMessage] = useState<string | null>(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() {
<AdminParityShell title="Subject Curriculum">
{message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4">
<div className="col-lg-5">
<div className="card shadow-sm">
@@ -4598,17 +4594,17 @@ export function AdminSubjectCurriculumPage() {
<div className="card-header">Current curriculum entries</div>
<div className="table-responsive">
<table className="table table-bordered table-hover align-middle mb-0">
<thead className="table-light"><tr><th>Class</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
<thead className="table-light"><tr><th>Class</th><th>School Year</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="text-muted">
<td colSpan={7} className="text-muted">
Loading
</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">
<td colSpan={7} className="text-muted">
No curriculum records yet.
</td>
</tr>
@@ -4616,6 +4612,7 @@ export function AdminSubjectCurriculumPage() {
entries.map((entry) => (
<tr key={entry.id}>
<td>{entry.class_name ?? '—'}</td>
<td>{entry.school_year ?? schoolYear ?? '—'}</td>
<td>{subjects[entry.subject] ?? entry.subject}</td>
<td>{entry.unit_number ?? '—'}</td>
<td>{entry.unit_title ?? '—'}</td>
@@ -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<TeacherClassAssignmentRow[]>([])
const [classes, setClasses] = useState<TeacherClassOption[]>([])
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
@@ -4711,7 +4709,7 @@ export function AdminTeacherClassAssignmentPage() {
<AdminParityShell title="Teacher Class Assignments">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4">
<div className="col-lg-3"><label className="form-label">School Year</label><div className="d-flex gap-2"><input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} /><button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button></div></div>
<div className="col-lg-3"><CiAcademicFilter selectedYear={schoolYear} onApply={({ schoolYear: year }) => void load(year)} /></div>
<div className="col-lg-3"><label className="form-label">Teacher</label><select className="form-select" value={selectedTeacherId ?? ''} onChange={(e) => { const id = e.target.value ? Number(e.target.value) : null; setSelectedTeacherId(id); const teacher = teachers.find((row) => row.teacher_id === id); setSelectedRole(String(teacher?.role ?? '')) }}><option value="">Select teacher</option>{teachers.map((teacher) => <option key={teacher.teacher_id} value={teacher.teacher_id}>{teacher.name ?? `${teacher.firstname ?? ''} ${teacher.lastname ?? ''}`.trim()}</option>)}</select></div>
<div className="col-lg-4"><label className="form-label">Class</label><select className="form-select" value={selectedClassId ?? ''} onChange={(e) => setSelectedClassId(e.target.value ? Number(e.target.value) : null)}><option value="">Select class</option>{classes.map((classRow) => <option key={classRow.class_section_id} value={classRow.class_section_id}>{classRow.class_section_name}</option>)}</select></div>
<div className="col-lg-2 d-grid"><button className="btn btn-primary align-self-end" type="button" onClick={() => void submitAssignment()} disabled={!selectedTeacherId || !selectedClassId}>Assign Class</button></div>
@@ -4757,7 +4755,7 @@ export function AdminTeacherClassAssignmentPage() {
}
export function AdminTeacherSubmissionsPage() {
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
const [selected, setSelected] = useState<Record<string, boolean>>({})
@@ -4809,6 +4807,18 @@ export function AdminTeacherSubmissionsPage() {
<AdminParityShell title="Teacher Submission Dashboard">
{message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={searchParams.get('school_year') ?? schoolYear}
selectedSemester={searchParams.get('semester') ?? semester}
onApply={({ schoolYear: year, semester: sem }) => {
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)
}}
/>
<div className="row g-3 mb-4">
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
+21 -4
View File
@@ -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() {
<div className="card shadow-sm mb-4">
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-md-3">
<label className="form-label">School Year</label>
<select className="form-select" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Date</label>
<input
@@ -83,7 +100,7 @@ export function BadgeScanningListPage() {
/>
</div>
<div className="col-md-3">
<div className="col-md-2">
<label className="form-label">Status</label>
<select
className="form-select"
@@ -97,7 +114,7 @@ export function BadgeScanningListPage() {
</select>
</div>
<div className="col-md-4">
<div className="col-md-3">
<label className="form-label">Search</label>
<input
className="form-control"
@@ -107,7 +124,7 @@ export function BadgeScanningListPage() {
/>
</div>
<div className="col-md-2">
<div className="col-md-1">
<button
className="btn btn-primary w-100"
type="button"
+2 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/** Laravel API for dev proxy (`/api` → this host). Override: `VITE_PROXY_API=http://localhost:8000 npm run dev`. */
const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8080'
const apiTarget = process.env.VITE_PROXY_API ?? process.env.VITE_API_ORIGIN ?? 'http://localhost:8000'
// https://vite.dev/config/
export default defineConfig({
@@ -18,6 +18,7 @@ export default defineConfig({
'/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},