update the new school year model
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ services:
|
|||||||
- client_node_modules:/app/node_modules
|
- client_node_modules:/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
# Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below.
|
# 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:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-19
@@ -1541,9 +1541,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": {
|
"node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -1888,9 +1888,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2033,6 +2033,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -2109,13 +2121,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.15.2",
|
"version": "1.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2480,7 +2493,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -3259,6 +3271,19 @@
|
|||||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -3933,7 +3958,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -4429,9 +4453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.14.2",
|
"version": "7.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -4451,12 +4475,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.14.2",
|
"version": "7.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
||||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.14.2"
|
"react-router": "7.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
+35
-3
@@ -11,6 +11,25 @@ export function setStoredToken(token: string | null): void {
|
|||||||
else localStorage.removeItem(TOKEN_KEY)
|
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 {
|
export class ApiHttpError extends Error {
|
||||||
readonly status: number
|
readonly status: number
|
||||||
readonly body: unknown
|
readonly body: unknown
|
||||||
@@ -30,24 +49,36 @@ export async function apiFetch<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const attachAuth = opts.attachAuth !== false
|
const attachAuth = opts.attachAuth !== false
|
||||||
const headers = new Headers(init.headers)
|
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()
|
const token = getStoredToken()
|
||||||
|
|
||||||
if (attachAuth && token && !headers.has('Authorization')) {
|
if (attachAuth && token && !headers.has('Authorization')) {
|
||||||
headers.set('Authorization', `Bearer ${token}`)
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = apiUrl(path)
|
const url = apiUrl(path)
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, { ...init, headers })
|
res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const hint = import.meta.env.DEV
|
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'
|
const reason = e instanceof Error ? e.message : 'Failed to fetch'
|
||||||
|
|
||||||
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
|
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -55,6 +86,7 @@ export async function apiFetch<T>(
|
|||||||
typeof body === 'object' && body !== null && 'message' in body
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
? String((body as { message?: unknown }).message ?? '')
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
-18
@@ -23,7 +23,6 @@ import type {
|
|||||||
GradingOverviewResponse,
|
GradingOverviewResponse,
|
||||||
IpBansResponse,
|
IpBansResponse,
|
||||||
LandingPageResponse,
|
LandingPageResponse,
|
||||||
LoginFailure,
|
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginSuccess,
|
LoginSuccess,
|
||||||
LateSlipLogsResponse,
|
LateSlipLogsResponse,
|
||||||
@@ -97,30 +96,136 @@ import type {
|
|||||||
FamilySearchResponse,
|
FamilySearchResponse,
|
||||||
} from './types'
|
} 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(
|
export async function loginRequest(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
const body = await apiFetch<unknown>(
|
const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
|
||||||
'/api/v1/auth/login',
|
let body: unknown = null
|
||||||
{
|
let lastError: unknown = null
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
},
|
|
||||||
{ attachAuth: false },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!body || typeof body !== 'object' || !('status' in body)) {
|
for (const path of loginPaths) {
|
||||||
throw new Error('Invalid login response from API.')
|
try {
|
||||||
|
body = await apiFetch<unknown>(
|
||||||
|
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) {
|
if (lastError) throw lastError
|
||||||
const fail = body as LoginFailure
|
|
||||||
return { status: false, message: fail.message }
|
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>> {
|
export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayload>> {
|
||||||
@@ -915,8 +1020,9 @@ export async function notifyTeacherSubmissions(payload: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubjectCurriculum(): Promise<SubjectCurriculumResponse> {
|
export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise<SubjectCurriculumResponse> {
|
||||||
return apiFetch<SubjectCurriculumResponse>('/api/v1/subjects/curriculum')
|
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
|
||||||
|
return apiFetch<SubjectCurriculumResponse>(`/api/v1/subjects/curriculum${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSubjectCurriculum(payload: {
|
export async function createSubjectCurriculum(payload: {
|
||||||
@@ -925,6 +1031,7 @@ export async function createSubjectCurriculum(payload: {
|
|||||||
unit_number?: number | null
|
unit_number?: number | null
|
||||||
unit_title?: string | null
|
unit_title?: string | null
|
||||||
chapter_name: string
|
chapter_name: string
|
||||||
|
school_year?: string | null
|
||||||
}): Promise<ApiEnvelope<{ entry?: unknown }>> {
|
}): Promise<ApiEnvelope<{ entry?: unknown }>> {
|
||||||
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
|
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -979,6 +979,7 @@ export type SubjectCurriculumEntryRow = {
|
|||||||
unit_number?: number | string | null
|
unit_number?: number | string | null
|
||||||
unit_title?: string | null
|
unit_title?: string | null
|
||||||
chapter_name: string
|
chapter_name: string
|
||||||
|
school_year?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SubjectCurriculumResponse = {
|
export type SubjectCurriculumResponse = {
|
||||||
@@ -986,6 +987,8 @@ export type SubjectCurriculumResponse = {
|
|||||||
classes: SubjectCurriculumClassRow[]
|
classes: SubjectCurriculumClassRow[]
|
||||||
entries: SubjectCurriculumEntryRow[]
|
entries: SubjectCurriculumEntryRow[]
|
||||||
subject_labels: Record<string, string>
|
subject_labels: Record<string, string>
|
||||||
|
school_year?: string | null
|
||||||
|
school_years?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublicCompetitionRow = {
|
export type PublicCompetitionRow = {
|
||||||
|
|||||||
+10
-7
@@ -3,20 +3,23 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
|
|||||||
/**
|
/**
|
||||||
* Laravel API base URL.
|
* Laravel API base URL.
|
||||||
*
|
*
|
||||||
* **Vite dev:** same-origin `/api/...`; `vite.config.ts` proxies to the API (default `http://localhost:8080`).
|
* Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel.
|
||||||
* Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser).
|
* 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 {
|
export function apiOrigin(): string {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const v = import.meta.env.VITE_API_ORIGIN
|
const v = import.meta.env.VITE_API_ORIGIN
|
||||||
if (typeof v === 'string' && v.trim()) {
|
if (typeof v === 'string' && v.trim()) {
|
||||||
return v.replace(/\/$/, '')
|
return v.replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
return PROD_API_ORIGIN
|
return PROD_API_ORIGIN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're
|
|||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import './AdminDailyAttendance.css'
|
import './AdminDailyAttendance.css'
|
||||||
import { ApiHttpError } from '../api/http'
|
import { ApiHttpError } from '../api/http'
|
||||||
|
import { CiAcademicFilter } from '../components/CiPartials'
|
||||||
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
assignTeacherClass,
|
assignTeacherClass,
|
||||||
@@ -3659,15 +3660,10 @@ export function AdminRemovedStudentsPage() {
|
|||||||
return (
|
return (
|
||||||
<AdminParityShell title="Student Removal">
|
<AdminParityShell title="Student Removal">
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{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() }}>
|
<CiAcademicFilter
|
||||||
<div className="col-sm-4 col-lg-3">
|
selectedYear={schoolYear}
|
||||||
<label className="form-label">School Year</label>
|
onApply={({ schoolYear: year }) => void load(year)}
|
||||||
<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>
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
<div className="col-xl-6">
|
<div className="col-xl-6">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
@@ -3808,7 +3804,8 @@ export function AdminSearchResultsPage() {
|
|||||||
|
|
||||||
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
|
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
|
||||||
export function AdminSectionsPromotionTotalsPage() {
|
export function AdminSectionsPromotionTotalsPage() {
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [searchParams] = useSearchParams()
|
||||||
|
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
|
||||||
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -3839,26 +3836,14 @@ export function AdminSectionsPromotionTotalsPage() {
|
|||||||
sections from these totals.
|
sections from these totals.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="row g-2 align-items-end mb-3 flex-wrap">
|
<div className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
|
||||||
<div className="col-sm-4 col-lg-3">
|
<CiAcademicFilter
|
||||||
<label className="form-label">School year</label>
|
selectedYear={schoolYear}
|
||||||
<input
|
onApply={({ schoolYear: year }) => void load(year)}
|
||||||
className="form-control"
|
/>
|
||||||
value={schoolYear}
|
<Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
|
||||||
onChange={(e) => setSchoolYear(e.target.value)}
|
Auto-distribute sections
|
||||||
placeholder="e.g. 2025-2026"
|
</Link>
|
||||||
/>
|
|
||||||
</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">
|
|
||||||
Auto-distribute sections
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
@@ -3907,7 +3892,8 @@ export function AdminSectionsPromotionTotalsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSectionsAutoDistributePage() {
|
export function AdminSectionsAutoDistributePage() {
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [searchParams] = useSearchParams()
|
||||||
|
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
|
||||||
const [studentsPerSection, setStudentsPerSection] = useState(20)
|
const [studentsPerSection, setStudentsPerSection] = useState(20)
|
||||||
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -3956,15 +3942,16 @@ export function AdminSectionsAutoDistributePage() {
|
|||||||
<AdminParityShell title="Auto-Distribute Students into Sections">
|
<AdminParityShell title="Auto-Distribute Students into Sections">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
<div className="row g-2 align-items-end mb-3">
|
<div className="row g-2 align-items-end mb-3">
|
||||||
<div className="col-sm-4 col-lg-3">
|
<div className="col-lg-6">
|
||||||
<label className="form-label">School Year</label>
|
<CiAcademicFilter
|
||||||
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
|
selectedYear={schoolYear}
|
||||||
|
onApply={({ schoolYear: year }) => void load(year)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4 col-lg-3">
|
<div className="col-sm-4 col-lg-3">
|
||||||
<label className="form-label">Students per section</label>
|
<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)} />
|
<input type="number" min={1} className="form-control" value={studentsPerSection} onChange={(e) => setStudentsPerSection(Number(e.target.value) || 1)} />
|
||||||
</div>
|
</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 className="col-auto"><button className="btn btn-primary" type="button" onClick={() => void runAll()} disabled={rows.length === 0}>Generate All</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -3995,6 +3982,7 @@ export function AdminSectionsAutoDistributePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminStudentClassAssignmentPage() {
|
export function AdminStudentClassAssignmentPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
type StudentAssignmentSortKey =
|
type StudentAssignmentSortKey =
|
||||||
| 'name'
|
| 'name'
|
||||||
| 'age'
|
| 'age'
|
||||||
@@ -4003,7 +3991,7 @@ export function AdminStudentClassAssignmentPage() {
|
|||||||
| 'assigned_classes'
|
| 'assigned_classes'
|
||||||
| 'registration_date'
|
| 'registration_date'
|
||||||
| 'school_year'
|
| 'school_year'
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
|
||||||
const [students, setStudents] = useState<StudentAssignmentRow[]>([])
|
const [students, setStudents] = useState<StudentAssignmentRow[]>([])
|
||||||
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
|
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
|
||||||
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
||||||
@@ -4118,11 +4106,10 @@ export function AdminStudentClassAssignmentPage() {
|
|||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4">
|
||||||
<div className="col-lg-4">
|
<div className="col-lg-4">
|
||||||
<label className="form-label">School Year</label>
|
<CiAcademicFilter
|
||||||
<div className="d-flex gap-2">
|
selectedYear={schoolYear}
|
||||||
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
|
onApply={({ schoolYear: year }) => void load(year)}
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
@@ -4226,6 +4213,7 @@ export function AdminStudentClassAssignmentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminStudentProfilesPage() {
|
export function AdminStudentProfilesPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
type StudentProfileSortKey =
|
type StudentProfileSortKey =
|
||||||
| 'school_id'
|
| 'school_id'
|
||||||
| 'firstname'
|
| 'firstname'
|
||||||
@@ -4237,7 +4225,7 @@ export function AdminStudentProfilesPage() {
|
|||||||
| 'is_active'
|
| 'is_active'
|
||||||
| 'photo_consent'
|
| 'photo_consent'
|
||||||
| 'registration_date'
|
| 'registration_date'
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
|
||||||
const [students, setStudents] = useState<StudentDirectoryRow[]>([])
|
const [students, setStudents] = useState<StudentDirectoryRow[]>([])
|
||||||
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
|
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
|
||||||
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
|
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
|
||||||
@@ -4360,10 +4348,10 @@ export function AdminStudentProfilesPage() {
|
|||||||
<AdminParityShell title="Student Profiles">
|
<AdminParityShell title="Student Profiles">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{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 flex-wrap gap-2 mb-3 justify-content-between">
|
||||||
<div className="d-flex gap-2">
|
<CiAcademicFilter
|
||||||
<input className="form-control" style={{ maxWidth: 240 }} value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="School year" />
|
selectedYear={schoolYear}
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
|
onApply={({ schoolYear: year }) => void load(year)}
|
||||||
</div>
|
/>
|
||||||
<div className="input-group" style={{ maxWidth: 360 }}>
|
<div className="input-group" style={{ maxWidth: 360 }}>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -4467,9 +4455,11 @@ export function AdminStudentProfilesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSubjectCurriculumPage() {
|
export function AdminSubjectCurriculumPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
|
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
|
||||||
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
|
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
|
||||||
const [subjects, setSubjects] = useState<Record<string, string>>({})
|
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 [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
@@ -4477,14 +4467,15 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
async function load() {
|
async function load(year = schoolYear) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await fetchSubjectCurriculum()
|
const data = await fetchSubjectCurriculum(year || undefined)
|
||||||
setClasses(data.classes ?? [])
|
setClasses(data.classes ?? [])
|
||||||
setEntries(data.entries ?? [])
|
setEntries(data.entries ?? [])
|
||||||
setSubjects(data.subject_labels ?? {})
|
setSubjects(data.subject_labels ?? {})
|
||||||
|
setSchoolYear(data.school_year ?? year ?? '')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4506,6 +4497,7 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
unit_number: form.unit_number ? Number(form.unit_number) : null,
|
unit_number: form.unit_number ? Number(form.unit_number) : null,
|
||||||
unit_title: form.unit_title || null,
|
unit_title: form.unit_title || null,
|
||||||
chapter_name: form.chapter_name,
|
chapter_name: form.chapter_name,
|
||||||
|
school_year: schoolYear || undefined,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -4543,6 +4535,10 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
<AdminParityShell title="Subject Curriculum">
|
<AdminParityShell title="Subject Curriculum">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
{error ? <div className="alert alert-danger">{error}</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="row g-4">
|
||||||
<div className="col-lg-5">
|
<div className="col-lg-5">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
@@ -4598,17 +4594,17 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
<div className="card-header">Current curriculum entries</div>
|
<div className="card-header">Current curriculum entries</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-bordered table-hover align-middle mb-0">
|
<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>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-muted">
|
<td colSpan={7} className="text-muted">
|
||||||
Loading…
|
Loading…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-muted">
|
<td colSpan={7} className="text-muted">
|
||||||
No curriculum records yet.
|
No curriculum records yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -4616,6 +4612,7 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td>{entry.class_name ?? '—'}</td>
|
<td>{entry.class_name ?? '—'}</td>
|
||||||
|
<td>{entry.school_year ?? schoolYear ?? '—'}</td>
|
||||||
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
||||||
<td>{entry.unit_number ?? '—'}</td>
|
<td>{entry.unit_number ?? '—'}</td>
|
||||||
<td>{entry.unit_title ?? '—'}</td>
|
<td>{entry.unit_title ?? '—'}</td>
|
||||||
@@ -4661,7 +4658,8 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminTeacherClassAssignmentPage() {
|
export function AdminTeacherClassAssignmentPage() {
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [searchParams] = useSearchParams()
|
||||||
|
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
|
||||||
const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([])
|
const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([])
|
||||||
const [classes, setClasses] = useState<TeacherClassOption[]>([])
|
const [classes, setClasses] = useState<TeacherClassOption[]>([])
|
||||||
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
|
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
|
||||||
@@ -4711,7 +4709,7 @@ export function AdminTeacherClassAssignmentPage() {
|
|||||||
<AdminParityShell title="Teacher Class Assignments">
|
<AdminParityShell title="Teacher Class Assignments">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
<div className="row g-3 mb-4">
|
<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-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-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>
|
<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() {
|
export function AdminTeacherSubmissionsPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
|
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
|
||||||
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
|
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
|
||||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||||
@@ -4809,6 +4807,18 @@ export function AdminTeacherSubmissionsPage() {
|
|||||||
<AdminParityShell title="Teacher Submission Dashboard">
|
<AdminParityShell title="Teacher Submission Dashboard">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
{error ? <div className="alert alert-danger">{error}</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="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">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>
|
<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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AttendanceManagementDashboard,
|
type AttendanceManagementDashboard,
|
||||||
} from '../../api/attendanceManagement'
|
} from '../../api/attendanceManagement'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ function nice(value: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BadgeScanningListPage() {
|
export function BadgeScanningListPage() {
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({})
|
||||||
|
const [schoolYear, setSchoolYear] = useState(selectedYear)
|
||||||
const [date, setDate] = useState(today)
|
const [date, setDate] = useState(today)
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
@@ -22,13 +25,18 @@ export function BadgeScanningListPage() {
|
|||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
school_year: schoolYear,
|
||||||
date,
|
date,
|
||||||
attendance_status: status,
|
attendance_status: status,
|
||||||
q,
|
q,
|
||||||
}),
|
}),
|
||||||
[date, status, q],
|
[schoolYear, date, status, q],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schoolYear && selectedYear) setSchoolYear(selectedYear)
|
||||||
|
}, [schoolYear, selectedYear])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -73,6 +81,15 @@ export function BadgeScanningListPage() {
|
|||||||
<div className="card shadow-sm mb-4">
|
<div className="card shadow-sm mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="row g-3 align-items-end">
|
<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">
|
<div className="col-md-3">
|
||||||
<label className="form-label">Date</label>
|
<label className="form-label">Date</label>
|
||||||
<input
|
<input
|
||||||
@@ -83,7 +100,7 @@ export function BadgeScanningListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-md-3">
|
<div className="col-md-2">
|
||||||
<label className="form-label">Status</label>
|
<label className="form-label">Status</label>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
@@ -97,7 +114,7 @@ export function BadgeScanningListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
<label className="form-label">Search</label>
|
<label className="form-label">Search</label>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -107,7 +124,7 @@ export function BadgeScanningListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-md-2">
|
<div className="col-md-1">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary w-100"
|
className="btn btn-primary w-100"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
/** Laravel API for dev proxy (`/api` → this host). Override: `VITE_PROXY_API=http://localhost:8000 npm run dev`. */
|
/** 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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user