add certificate

This commit is contained in:
root
2026-05-18 00:07:40 -04:00
parent dc7acf2536
commit 0d3cdd3857
13 changed files with 869 additions and 44 deletions
+1 -1
View File
@@ -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:8000} VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8080}
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
+3 -3
View File
@@ -10,8 +10,8 @@
# #
# Windows D: drive in WSL is usually: /mnt/d/Test/alrahma/client_laravel # Windows D: drive in WSL is usually: /mnt/d/Test/alrahma/client_laravel
# #
# Laravel API proxy: defaults to host port 8000. Override if requests fail: # Laravel API proxy: defaults to host port 8080. Override if requests fail:
# export VITE_PROXY_API=http://172.17.0.1:8000 # export VITE_PROXY_API=http://172.17.0.1:8080
# ./scripts/docker-up-wsl.sh # ./scripts/docker-up-wsl.sh
# #
set -euo pipefail set -euo pipefail
@@ -24,7 +24,7 @@ if [[ "$(pwd)" == /mnt/* ]]; then
echo "Note: Project is on a Windows mount (/mnt/...). File sync can be slower; Vite uses polling." >&2 echo "Note: Project is on a Windows mount (/mnt/...). File sync can be slower; Vite uses polling." >&2
fi fi
export VITE_PROXY_API="${VITE_PROXY_API:-http://host.docker.internal:8000}" export VITE_PROXY_API="${VITE_PROXY_API:-http://host.docker.internal:8080}"
echo "Using VITE_PROXY_API=${VITE_PROXY_API}" >&2 echo "Using VITE_PROXY_API=${VITE_PROXY_API}" >&2
echo "App → http://localhost:5173 (ensure Laravel is reachable from the container at the proxy target)" >&2 echo "App → http://localhost:5173 (ensure Laravel is reachable from the container at the proxy target)" >&2
+4 -4
View File
@@ -1,9 +1,9 @@
/** /**
* Smoke-test the same API routes the Vite client uses. * Smoke-test the same API routes the Vite client uses.
* Usage: node scripts/test-api.mjs * Usage: node scripts/test-api.mjs
* $env:API_BASE="http://127.0.0.1:5173"; node scripts/test-api.mjs # via Vite proxy * $env:API_BASE="http://127.0.0.1:5173"; node scripts/test-api.mjs # via Vite proxy
*/ */
const base = (process.env.API_BASE ?? 'http://127.0.0.1:8000').replace(/\/$/, '') const base = (process.env.API_BASE ?? 'http://127.0.0.1:8080').replace(/\/$/, '')
const tests = [ const tests = [
['GET', '/up', null], ['GET', '/up', null],
+6
View File
@@ -2,6 +2,7 @@ import { Suspense, lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider' import { AuthProvider } from './auth/AuthProvider'
import { RequireAuth } from './auth/RequireAuth' import { RequireAuth } from './auth/RequireAuth'
import { GlobalTableSorting } from './components/GlobalTableSorting'
import './App.css' import './App.css'
const PublicLayout = lazy(() => const PublicLayout = lazy(() =>
@@ -487,6 +488,9 @@ const TeacherPrintRequestsPage = lazy(() =>
default: m.TeacherPrintRequestsPage, default: m.TeacherPrintRequestsPage,
})), })),
) )
const CertificatesPage = lazy(() =>
import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })),
)
const StickerFormPage = lazy(() => const StickerFormPage = lazy(() =>
import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })), import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })),
) )
@@ -946,6 +950,7 @@ export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<GlobalTableSorting />
<Suspense fallback={<RouteFallback />}> <Suspense fallback={<RouteFallback />}>
<Routes> <Routes>
<Route element={<PublicLayout />}> <Route element={<PublicLayout />}>
@@ -1216,6 +1221,7 @@ export default function App() {
path="administrator/printables/report-cards" path="administrator/printables/report-cards"
element={<ReportCardManagementPage />} element={<ReportCardManagementPage />}
/> />
<Route path="administrator/certificates" element={<CertificatesPage />} />
<Route <Route
path="administrator/score-analysis/score-prediction" path="administrator/score-analysis/score-prediction"
element={<ScorePredictionPage />} element={<ScorePredictionPage />}
+65
View File
@@ -0,0 +1,65 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import type { ApiEnvelope } from './types'
export type CertClassSection = {
class_section_id: number
class_section_name: string
}
export type CertStudent = {
id: number
firstname: string
lastname: string
grade: string
}
export type CertFormOptions = {
class_sections: CertClassSection[]
students: CertStudent[]
school_year: string
selected_class_id: string | null
}
export async function fetchCertFormOptions(params?: {
school_year?: string
class_section_id?: string | number
}): Promise<ApiEnvelope<CertFormOptions>> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.class_section_id != null && String(params.class_section_id) !== '')
qs.set('class_section_id', String(params.class_section_id))
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch<ApiEnvelope<CertFormOptions>>(`/api/v1/administrator/certificates/form-options${suffix}`)
}
export async function postCertificateGenerate(payload: {
student_ids: number[]
cert_date: string
class_section_id?: number | string | null
}): Promise<Blob> {
const headers = new Headers({
Accept: 'application/pdf,*/*',
'Content-Type': 'application/json',
})
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl('/api/v1/administrator/certificates/generate'), {
method: 'POST',
headers,
body: JSON.stringify(payload),
})
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
}
const raw = await res.blob()
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
}
+1 -1
View File
@@ -43,7 +43,7 @@ export async function apiFetch<T>(
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://192.168.3.100:8000).' ? ' 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).'
: '' : ''
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)
+265
View File
@@ -0,0 +1,265 @@
import { useEffect } from 'react'
type SortDirection = 'ascending' | 'descending'
type TableSortState = {
columnIndex: number
direction: SortDirection
}
const ACTION_HEADER_PATTERN = /^(action|actions|option|options|edit|delete|remove|preview|print|open|link)$/i
const CUSTOM_SORT_SELECTOR = [
'thead th[role="button"]',
'thead th button',
'thead th a',
'thead th input',
'thead th select',
'thead th textarea',
].join(', ')
function headerText(cell: HTMLTableCellElement): string {
return (cell.textContent ?? '').replace(/\s+/g, ' ').trim()
}
function parseBooleanValue(value: string): number | null {
const normalized = value.trim().toLowerCase()
if (!normalized) return null
if (['yes', 'true', 'active', 'enabled', 'paid', 'present'].includes(normalized)) return 1
if (['no', 'false', 'inactive', 'disabled', 'unpaid', 'absent'].includes(normalized)) return 0
return null
}
function parseNumberValue(value: string): number | null {
const normalized = value.trim()
if (!normalized) return null
if (!/^\(?\s*[$€£]?\s*-?[\d,.]+%?\s*\)?$/.test(normalized)) return null
const negative = normalized.includes('(') && normalized.includes(')')
const cleaned = normalized.replace(/[%,$€£()\s]/g, '').replace(/,/g, '')
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return null
const parsed = Number(cleaned)
if (!Number.isFinite(parsed)) return null
return negative ? -parsed : parsed
}
function parseDateValue(value: string): number | null {
const normalized = value.trim()
if (!normalized) return null
const parsed = Date.parse(normalized)
return Number.isNaN(parsed) ? null : parsed
}
function cellSortValue(cell: HTMLTableCellElement | null): string {
if (!cell) return ''
const dataValue = cell.getAttribute('data-sort-value')
if (dataValue != null) return dataValue.trim()
return (cell.textContent ?? '').replace(/\s+/g, ' ').trim()
}
function getLogicalCellForColumn(row: HTMLTableRowElement, columnIndex: number): HTMLTableCellElement | null {
let cursor = 0
for (const cell of Array.from(row.cells)) {
const span = Math.max(1, cell.colSpan || 1)
if (columnIndex >= cursor && columnIndex < cursor + span) {
return cell
}
cursor += span
}
return null
}
function compareValues(left: string, right: string): number {
const leftBoolean = parseBooleanValue(left)
const rightBoolean = parseBooleanValue(right)
if (leftBoolean != null && rightBoolean != null) return leftBoolean - rightBoolean
const leftNumber = parseNumberValue(left)
const rightNumber = parseNumberValue(right)
if (leftNumber != null && rightNumber != null) return leftNumber - rightNumber
const leftDate = parseDateValue(left)
const rightDate = parseDateValue(right)
if (leftDate != null && rightDate != null) return leftDate - rightDate
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
}
function tableSupportsAutoSort(table: HTMLTableElement): boolean {
if (table.classList.contains('ci-no-auto-sort')) return false
if (table.querySelector(CUSTOM_SORT_SELECTOR)) return false
if (table.querySelector('tbody td[rowspan], tbody th[rowspan]')) return false
const head = table.tHead
const headerRow = head?.rows.item(head.rows.length - 1)
if (!headerRow || headerRow.cells.length === 0) return false
const bodyRows = Array.from(table.tBodies).flatMap((body) => Array.from(body.rows))
if (bodyRows.length < 2) return false
return !Array.from(headerRow.cells).some((cell) => window.getComputedStyle(cell).cursor === 'pointer')
}
function updateHeaderState(
headers: Array<{ cell: HTMLTableCellElement; columnIndex: number }>,
sortState: TableSortState | undefined,
) {
for (const { cell, columnIndex } of headers) {
const active = sortState?.columnIndex === columnIndex
const direction = active ? sortState?.direction : null
cell.dataset.ciSortDirection =
direction === 'ascending' ? 'ascending' : direction === 'descending' ? 'descending' : 'none'
cell.setAttribute('aria-sort', direction ?? 'none')
}
}
function sortTableRows(table: HTMLTableElement, columnIndex: number, direction: SortDirection) {
const factor = direction === 'ascending' ? 1 : -1
for (const body of Array.from(table.tBodies)) {
const rows = Array.from(body.rows)
if (rows.length < 2) continue
const sortedRows = [...rows].sort((left, right) => {
const leftValue = cellSortValue(getLogicalCellForColumn(left, columnIndex))
const rightValue = cellSortValue(getLogicalCellForColumn(right, columnIndex))
return compareValues(leftValue, rightValue) * factor
})
for (const row of sortedRows) {
body.appendChild(row)
}
}
}
export function GlobalTableSorting() {
useEffect(() => {
if (typeof document === 'undefined') return
const enhancedHeaders = new WeakSet<HTMLTableCellElement>()
const tableSortStates = new WeakMap<HTMLTableElement, TableSortState>()
const cleanupCallbacks = new WeakMap<HTMLTableCellElement, () => void>()
let applyingSort = false
let frameId = 0
function bindTables() {
frameId = 0
for (const table of Array.from(document.querySelectorAll('table'))) {
if (!(table instanceof HTMLTableElement)) continue
if (!tableSupportsAutoSort(table)) continue
const head = table.tHead
const headerRow = head?.rows.item(head.rows.length - 1)
if (!headerRow) continue
const headers: Array<{ cell: HTMLTableCellElement; columnIndex: number }> = []
let columnCursor = 0
for (const headerCell of Array.from(headerRow.cells)) {
const span = Math.max(1, headerCell.colSpan || 1)
const label = headerText(headerCell)
const columnIndex = columnCursor
const isActionColumn = ACTION_HEADER_PATTERN.test(label)
columnCursor += span
if (span !== 1 || isActionColumn) {
headerCell.classList.remove('ci-sortable-column')
headerCell.removeAttribute('tabindex')
headerCell.removeAttribute('aria-sort')
delete headerCell.dataset.ciSortDirection
continue
}
headers.push({ cell: headerCell, columnIndex })
if (enhancedHeaders.has(headerCell)) continue
enhancedHeaders.add(headerCell)
headerCell.classList.add('ci-sortable-column')
headerCell.tabIndex = 0
headerCell.setAttribute('aria-label', label ? `Sort by ${label}` : 'Sort table')
const activateSort = () => {
const current = tableSortStates.get(table)
const nextDirection: SortDirection =
current?.columnIndex === columnIndex && current.direction === 'ascending'
? 'descending'
: 'ascending'
const nextState = { columnIndex, direction: nextDirection }
tableSortStates.set(table, nextState)
updateHeaderState(headers, nextState)
applyingSort = true
sortTableRows(table, columnIndex, nextDirection)
queueMicrotask(() => {
applyingSort = false
})
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
activateSort()
}
headerCell.addEventListener('click', activateSort)
headerCell.addEventListener('keydown', onKeyDown)
cleanupCallbacks.set(headerCell, () => {
headerCell.removeEventListener('click', activateSort)
headerCell.removeEventListener('keydown', onKeyDown)
})
}
const sortState = tableSortStates.get(table)
updateHeaderState(headers, sortState)
if (sortState) {
applyingSort = true
sortTableRows(table, sortState.columnIndex, sortState.direction)
queueMicrotask(() => {
applyingSort = false
})
}
}
}
const scheduleBind = () => {
if (frameId !== 0) return
frameId = window.requestAnimationFrame(bindTables)
}
bindTables()
const observer = new MutationObserver(() => {
if (applyingSort) return
scheduleBind()
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (frameId !== 0) {
window.cancelAnimationFrame(frameId)
}
for (const table of Array.from(document.querySelectorAll('table'))) {
if (!(table instanceof HTMLTableElement)) continue
const head = table.tHead
const headerRow = head?.rows.item(head.rows.length - 1)
if (!headerRow) continue
for (const headerCell of Array.from(headerRow.cells)) {
cleanupCallbacks.get(headerCell)?.()
}
}
}
}, [])
return null
}
+28
View File
@@ -1070,6 +1070,34 @@ body {
border-bottom: 1px solid var(--mgmt-thead-border); border-bottom: 1px solid var(--mgmt-thead-border);
} }
table thead .ci-sortable-column {
cursor: pointer;
user-select: none;
}
table thead .ci-sortable-column:focus-visible {
outline: 2px solid rgba(34, 160, 71, 0.45);
outline-offset: -2px;
}
table thead .ci-sortable-column::after {
content: '⇅';
display: inline-block;
margin-left: 0.35rem;
font-size: 0.8em;
opacity: 0.45;
}
table thead .ci-sortable-column[data-ci-sort-direction='ascending']::after {
content: '▲';
opacity: 0.9;
}
table thead .ci-sortable-column[data-ci-sort-direction='descending']::after {
content: '▼';
opacity: 0.9;
}
.management-app .table-responsive { .management-app .table-responsive {
width: 100%; width: 100%;
position: relative; position: relative;
+1 -1
View File
@@ -3,7 +3,7 @@ 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://192.168.3.100:8000`). * **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). * Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser).
* *
* **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`. * **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`.
+109 -25
View File
@@ -114,6 +114,21 @@ function formatDateInput(value?: string | null) {
return value ? String(value).slice(0, 10) : '' return value ? String(value).slice(0, 10) : ''
} }
function SortTh({
col, label, sortCol, sortDir, onSort, style,
}: {
col: string; label: string; sortCol: string; sortDir: 'asc' | 'desc'
onSort: (c: string) => void; style?: React.CSSProperties
}) {
const active = sortCol === col
return (
<th style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} onClick={() => onSort(col)}>
{label}{' '}
<span style={{ opacity: active ? 1 : 0.3 }}>{active && sortDir === 'desc' ? '▼' : '▲'}</span>
</th>
)
}
function normalizeAttendanceSearch(value: string) { function normalizeAttendanceSearch(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
} }
@@ -3043,12 +3058,17 @@ export function AdminIpBansPage() {
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]) const [rows, setRows] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sortCol, setSortCol] = useState('id')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [pageSize, setPageSize] = useState(25)
const [page, setPage] = useState(1)
async function load(currentStatus = status) { async function load(currentStatus = status) {
setLoading(true) setLoading(true)
try { try {
const data = await fetchIpBans({ status: currentStatus, perPage: 100 }) const data = await fetchIpBans({ status: currentStatus, perPage: 200 })
setRows((data.data?.bans as Array<Record<string, unknown>>) ?? []) setRows((data.data?.bans as Array<Record<string, unknown>>) ?? [])
setPage(1)
setError(null) setError(null)
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load IP bans.') setError(e instanceof Error ? e.message : 'Unable to load IP bans.')
@@ -3057,9 +3077,36 @@ export function AdminIpBansPage() {
} }
} }
useEffect(() => { useEffect(() => { void load(status) }, [status])
void load(status)
}, [status]) function handleSort(col: string) {
if (col === sortCol) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
setPage(1)
}
const sortedRows = useMemo(() => {
const copy = [...rows]
copy.sort((a, b) => {
let av: string | number = String(a[sortCol] ?? '')
let bv: string | number = String(b[sortCol] ?? '')
if (['id', 'attempts'].includes(sortCol)) {
av = Number(av)
bv = Number(bv)
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return copy
}, [rows, sortCol, sortDir])
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize))
const pagedRows = sortedRows.slice((page - 1) * pageSize, page * pageSize)
async function onUnban(id?: number, all = false) { async function onUnban(id?: number, all = false) {
await unbanIp(all ? { all: true } : { id }) await unbanIp(all ? { all: true } : { id })
@@ -3074,13 +3121,17 @@ export function AdminIpBansPage() {
return ( return (
<AdminParityShell title="IP Bans"> <AdminParityShell title="IP Bans">
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-3"> <div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<label>Show</label> <label>Show</label>
<select className="form-select form-select-sm" style={{ minWidth: 160 }} value={status} onChange={(e) => setStatus(e.target.value as 'all' | 'active')}> <select className="form-select form-select-sm" style={{ minWidth: 160 }} value={status} onChange={(e) => { setStatus(e.target.value as 'all' | 'active'); setPage(1) }}>
<option value="all">All entries</option> <option value="all">All entries</option>
<option value="active">Active bans only</option> <option value="active">Active bans only</option>
</select> </select>
<label className="ms-2">Per page</label>
<select className="form-select form-select-sm" style={{ width: 80 }} value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1) }}>
{[10, 25, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div> </div>
<button className="btn btn-danger btn-sm" type="button" onClick={() => void onUnban(undefined, true)}> <button className="btn btn-danger btn-sm" type="button" onClick={() => void onUnban(undefined, true)}>
Unban All Unban All
@@ -3090,23 +3141,23 @@ export function AdminIpBansPage() {
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
<thead className="table-dark"> <thead className="table-dark">
<tr> <tr>
<th>ID</th> <SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>IP Address</th> <SortTh col="ip_address" label="IP Address" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Attempts</th> <SortTh col="attempts" label="Attempts" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Last Attempt</th> <SortTh col="last_attempt_at" label="Last Attempt" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Blocked Until</th> <SortTh col="blocked_until" label="Blocked Until" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Status</th> <SortTh col="is_active" label="Status" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Created</th> <SortTh col="created_at" label="Created" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Updated</th> <SortTh col="updated_at" label="Updated" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={9} className="text-muted">Loading IP bans</td></tr> <tr><td colSpan={9} className="text-muted">Loading IP bans</td></tr>
) : rows.length === 0 ? ( ) : pagedRows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No IP ban records found.</td></tr> <tr><td colSpan={9} className="text-muted">No IP ban records found.</td></tr>
) : rows.map((row) => { ) : pagedRows.map((row) => {
const id = Number(row.id ?? 0) const id = Number(row.id ?? 0)
const active = Boolean(row.is_active) const active = Boolean(row.is_active)
return ( return (
@@ -3121,17 +3172,11 @@ export function AdminIpBansPage() {
<td>{formatDate(String(row.updated_at ?? ''))}</td> <td>{formatDate(String(row.updated_at ?? ''))}</td>
<td className="d-flex gap-2"> <td className="d-flex gap-2">
{active ? ( {active ? (
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}> <button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>Unban</button>
Unban
</button>
) : ( ) : (
<> <>
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}> <button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>Ban 24h</button>
Ban 24h <button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>Clear Attempts</button>
</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>
Clear Attempts
</button>
</> </>
)} )}
</td> </td>
@@ -3141,6 +3186,45 @@ export function AdminIpBansPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{totalPages > 1 && (
<div className="d-flex align-items-center justify-content-between mt-2 flex-wrap gap-2">
<small className="text-muted">
Showing {Math.min((page - 1) * pageSize + 1, sortedRows.length)}{Math.min(page * pageSize, sortedRows.length)} of {sortedRows.length}
</small>
<nav>
<ul className="pagination pagination-sm mb-0">
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(1)}>&laquo;</button>
</li>
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p - 1)}>&lsaquo;</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
.reduce<(number | '…')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('…')
acc.push(p)
return acc
}, [])
.map((p, i) =>
p === '…' ? (
<li key={`ellipsis-${i}`} className="page-item disabled"><span className="page-link"></span></li>
) : (
<li key={p} className={`page-item ${page === p ? 'active' : ''}`}>
<button className="page-link" onClick={() => setPage(p as number)}>{p}</button>
</li>
)
)}
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p + 1)}>&rsaquo;</button>
</li>
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(totalPages)}>&raquo;</button>
</li>
</ul>
</nav>
</div>
)}
</AdminParityShell> </AdminParityShell>
) )
} }
+320
View File
@@ -0,0 +1,320 @@
import { type FormEvent, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchCertFormOptions,
postCertificateGenerate,
type CertClassSection,
type CertStudent,
} from '../../api/certificates'
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const classId = searchParams.get('class_section_id') ?? ''
const schoolYear = searchParams.get('school_year') ?? ''
const [classSections, setClassSections] = useState<CertClassSection[]>([])
const [students, setStudents] = useState<CertStudent[]>([])
const [currentYear, setCurrentYear] = useState('')
const [loadingStudents, setLoadingStudents] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [certDate, setCertDate] = useState(() => {
const d = new Date()
return d.toISOString().slice(0, 10)
})
const [generating, setGenerating] = useState(false)
const selectAllRef = useRef<HTMLInputElement>(null)
// Load class sections on mount / school_year change
useEffect(() => {
fetchCertFormOptions({ school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setClassSections(Array.isArray(payload?.class_sections) ? payload.class_sections : [])
setCurrentYear(typeof payload?.school_year === 'string' ? payload.school_year : '')
})
.catch((err: unknown) => {
console.error('[Certificates] form-options error:', err)
if (err instanceof ApiHttpError) {
setError(`API error ${err.status}: ${err.message}`)
} else if (err instanceof Error) {
setError(`Error: ${err.message}`)
} else {
setError('Failed to load classes.')
}
})
}, [schoolYear])
// Load students when class changes
useEffect(() => {
if (!classId) {
setStudents([])
setSelectedIds(new Set())
return
}
setLoadingStudents(true)
setError(null)
fetchCertFormOptions({ class_section_id: classId, school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setStudents(Array.isArray(payload?.students) ? payload.students : [])
setSelectedIds(new Set())
})
.catch(() => setStudents([]))
.finally(() => setLoadingStudents(false))
}, [classId, schoolYear])
// Keep select-all checkbox tri-state in sync
useEffect(() => {
const el = selectAllRef.current
if (!el) return
if (students.length === 0) {
el.checked = false
el.indeterminate = false
} else if (selectedIds.size === students.length) {
el.checked = true
el.indeterminate = false
} else if (selectedIds.size === 0) {
el.checked = false
el.indeterminate = false
} else {
el.checked = false
el.indeterminate = true
}
}, [selectedIds, students])
function handleClassChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('class_section_id', value)
else next.delete('class_section_id')
setSearchParams(next, { replace: true })
}
function handleSchoolYearChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('school_year', value)
else next.delete('school_year')
next.delete('class_section_id')
setSearchParams(next, { replace: true })
}
function toggleStudent(id: number) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll(checked: boolean) {
if (checked) setSelectedIds(new Set(students.map((s) => s.id)))
else setSelectedIds(new Set())
}
function formatCertDate(isoDate: string): string {
const d = new Date(isoDate + 'T00:00:00')
if (isNaN(d.getTime())) return isoDate
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}/${dd}/${d.getFullYear()}`
}
async function handleGenerate(e: FormEvent) {
e.preventDefault()
if (selectedIds.size === 0) return
setGenerating(true)
setError(null)
try {
const blob = await postCertificateGenerate({
student_ids: Array.from(selectedIds),
cert_date: formatCertDate(certDate),
class_section_id: classId ? Number(classId) : null,
})
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
} catch (err) {
setError(err instanceof ApiHttpError ? err.message : 'Failed to generate certificates.')
} finally {
setGenerating(false)
}
}
const hasStudents = students.length > 0
return (
<div className="container-fluid py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Select a class, choose students, then generate and print their certificates.
</div>
</div>
</div>
{error && (
<div className="alert alert-danger alert-dismissible fade show" role="alert">
{error}
<button
type="button"
className="btn-close"
onClick={() => setError(null)}
aria-label="Close"
/>
</div>
)}
{/* Filter */}
<div className="card shadow-sm mb-4">
<div className="card-header fw-semibold">Filter Students</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-5">
<label className="form-label fw-semibold mb-1">Class / Section</label>
<select
className="form-select"
value={classId}
onChange={(e) => handleClassChange(e.target.value)}
>
<option value=""> Select a class </option>
{classSections.map((cs) => (
<option key={cs.class_section_id} value={String(cs.class_section_id)}>
{cs.class_section_name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label fw-semibold mb-1">School Year</label>
<input
type="text"
className="form-control"
placeholder="e.g. 2024-2025"
value={schoolYear || currentYear}
onChange={(e) => handleSchoolYearChange(e.target.value)}
/>
</div>
</div>
</div>
</div>
{loadingStudents && (
<div className="text-muted">Loading students</div>
)}
{!loadingStudents && hasStudents && (
<form onSubmit={handleGenerate}>
<div className="card shadow-sm mb-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span className="fw-semibold">
Students
<span className="badge bg-secondary ms-1">{students.length}</span>
</span>
<div className="d-flex align-items-center gap-3">
<div className="input-group input-group-sm" style={{ width: 200 }}>
<span className="input-group-text">
<i className="bi bi-calendar3" />
</span>
<input
type="date"
className="form-control"
value={certDate}
onChange={(e) => setCertDate(e.target.value)}
title="Certificate date"
/>
</div>
<div className="form-check mb-0">
<input
className="form-check-input"
type="checkbox"
id="selectAll"
ref={selectAllRef}
onChange={(e) => toggleAll(e.target.checked)}
/>
<label className="form-check-label" htmlFor="selectAll">
Select all
</label>
</div>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} />
<th>Last Name</th>
<th>First Name</th>
<th>Grade / Class</th>
</tr>
</thead>
<tbody>
{students.map((s) => (
<tr key={s.id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={selectedIds.has(s.id)}
onChange={() => toggleStudent(s.id)}
/>
</td>
<td>{s.lastname}</td>
<td>{s.firstname}</td>
<td>{s.grade}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer d-flex justify-content-between align-items-center">
<span className="text-muted small">
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<button
type="submit"
className="btn btn-success"
disabled={selectedIds.size === 0 || generating}
>
{generating ? (
<>
<span className="spinner-border spinner-border-sm me-1" role="status" />
Generating
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate &amp; Print Certificates
</>
)}
</button>
</div>
</div>
</form>
)}
{!loadingStudents && classId && !hasStudents && (
<div className="alert alert-warning">
No active students found for the selected class and school year.
</div>
)}
{!loadingStudents && !classId && (
<div className="alert alert-info">
Select a class above to load its student roster.
</div>
)}
</div>
)
}
+65 -8
View File
@@ -194,6 +194,40 @@ function buildChangedPayload(
return changed return changed
} }
type SortCol = 'id' | 'firstname' | 'lastname' | 'email' | 'cellphone' | 'user_type' | 'roles'
function sortValue(entry: UserListRow, col: SortCol): string | number {
const u = entry.user ?? {}
switch (col) {
case 'id':
return Number(u.school_id ?? u.id ?? 0)
case 'roles':
return roleLabels(entry).toLowerCase()
default:
return String(u[col] ?? '').toLowerCase()
}
}
function SortTh({
col, label, sortCol, sortDir, onSort, style,
}: {
col: SortCol; label: string; sortCol: SortCol; sortDir: 'asc' | 'desc'
onSort: (c: SortCol) => void; style?: React.CSSProperties
}) {
const active = sortCol === col
return (
<th
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
onClick={() => onSort(col)}
>
{label}{' '}
<span style={{ opacity: active ? 1 : 0.3 }}>
{active && sortDir === 'desc' ? '▼' : '▲'}
</span>
</th>
)
}
/** CI `user/user_list.php` — DataTable-style list + full edit modal (`saveUser`). */ /** CI `user/user_list.php` — DataTable-style list + full edit modal (`saveUser`). */
export function UserListPage() { export function UserListPage() {
const [rows, setRows] = useState<UserListRow[]>([]) const [rows, setRows] = useState<UserListRow[]>([])
@@ -204,6 +238,8 @@ export function UserListPage() {
const [originalForm, setOriginalForm] = useState<EditFormState | null>(null) const [originalForm, setOriginalForm] = useState<EditFormState | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const modalRef = useRef<HTMLDivElement | null>(null) const modalRef = useRef<HTMLDivElement | null>(null)
const [sortCol, setSortCol] = useState<SortCol>('lastname')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
async function load() { async function load() {
setLoading(true) setLoading(true)
@@ -222,6 +258,27 @@ export function UserListPage() {
void load() void load()
}, []) }, [])
function handleSort(col: SortCol) {
if (col === sortCol) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
}
const sortedRows = useMemo(() => {
const copy = [...rows]
copy.sort((a, b) => {
const av = sortValue(a, sortCol)
const bv = sortValue(b, sortCol)
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return copy
}, [rows, sortCol, sortDir])
const modalTitle = useMemo(() => { const modalTitle = useMemo(() => {
if (!form) return 'Edit User' if (!form) return 'Edit User'
const name = [form.firstname, form.lastname].filter(Boolean).join(' ').trim() const name = [form.firstname, form.lastname].filter(Boolean).join(' ').trim()
@@ -306,13 +363,13 @@ export function UserListPage() {
<table className="display table table-striped table-bordered align-middle" style={{ width: '100%' }}> <table className="display table table-striped table-bordered align-middle" style={{ width: '100%' }}>
<thead className="table-dark"> <thead className="table-dark">
<tr> <tr>
<th>ID</th> <SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>First Name</th> <SortTh col="firstname" label="First Name" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Last Name</th> <SortTh col="lastname" label="Last Name" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Email</th> <SortTh col="email" label="Email" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Phone</th> <SortTh col="cellphone" label="Phone" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>User Type</th> <SortTh col="user_type" label="User Type" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Roles</th> <SortTh col="roles" label="Roles" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th style={{ width: 140 }}>Actions</th> <th style={{ width: 140 }}>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -330,7 +387,7 @@ export function UserListPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
rows.map((entry) => { sortedRows.map((entry) => {
const user = entry.user ?? {} const user = entry.user ?? {}
const uid = Number(user.id ?? 0) const uid = Number(user.id ?? 0)
const idDisplay = const idDisplay =
+1 -1
View File
@@ -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://192.168.3.100:8000' const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8000'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({