diff --git a/docker-compose.yml b/docker-compose.yml index 7a0ab0f..9f1ff7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - client_node_modules:/app/node_modules environment: # Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below. - VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8000} + VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8080} extra_hosts: - 'host.docker.internal:host-gateway' diff --git a/scripts/docker-up-wsl.sh b/scripts/docker-up-wsl.sh index a4219d5..b9c1142 100644 --- a/scripts/docker-up-wsl.sh +++ b/scripts/docker-up-wsl.sh @@ -10,8 +10,8 @@ # # 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: -# export VITE_PROXY_API=http://172.17.0.1:8000 +# Laravel API proxy: defaults to host port 8080. Override if requests fail: +# export VITE_PROXY_API=http://172.17.0.1:8080 # ./scripts/docker-up-wsl.sh # 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 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 "App → http://localhost:5173 (ensure Laravel is reachable from the container at the proxy target)" >&2 diff --git a/scripts/test-api.mjs b/scripts/test-api.mjs index 111b7a9..27eefad 100644 --- a/scripts/test-api.mjs +++ b/scripts/test-api.mjs @@ -1,9 +1,9 @@ /** * Smoke-test the same API routes the Vite client uses. - * Usage: node scripts/test-api.mjs - * $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(/\/$/, '') + * Usage: node scripts/test-api.mjs + * $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:8080').replace(/\/$/, '') const tests = [ ['GET', '/up', null], diff --git a/src/App.tsx b/src/App.tsx index b4b16de..403764d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Suspense, lazy } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './auth/AuthProvider' import { RequireAuth } from './auth/RequireAuth' +import { GlobalTableSorting } from './components/GlobalTableSorting' import './App.css' const PublicLayout = lazy(() => @@ -487,6 +488,9 @@ const TeacherPrintRequestsPage = lazy(() => default: m.TeacherPrintRequestsPage, })), ) +const CertificatesPage = lazy(() => + import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })), +) const StickerFormPage = lazy(() => import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })), ) @@ -946,6 +950,7 @@ export default function App() { return ( + }> }> @@ -1216,6 +1221,7 @@ export default function App() { path="administrator/printables/report-cards" element={} /> + } /> } diff --git a/src/api/certificates.ts b/src/api/certificates.ts new file mode 100644 index 0000000..1794c24 --- /dev/null +++ b/src/api/certificates.ts @@ -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> { + 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>(`/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 { + 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' }) +} diff --git a/src/api/http.ts b/src/api/http.ts index c58d075..09ed19d 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -43,7 +43,7 @@ export async function apiFetch( 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://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' throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null) diff --git a/src/components/GlobalTableSorting.tsx b/src/components/GlobalTableSorting.tsx new file mode 100644 index 0000000..fcb6509 --- /dev/null +++ b/src/components/GlobalTableSorting.tsx @@ -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() + const tableSortStates = new WeakMap() + const cleanupCallbacks = new WeakMap 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 +} diff --git a/src/index.css b/src/index.css index 04bcbe3..7c8c69b 100644 --- a/src/index.css +++ b/src/index.css @@ -1070,6 +1070,34 @@ body { 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 { width: 100%; position: relative; diff --git a/src/lib/apiOrigin.ts b/src/lib/apiOrigin.ts index 45b2658..f4fb861 100644 --- a/src/lib/apiOrigin.ts +++ b/src/lib/apiOrigin.ts @@ -3,7 +3,7 @@ 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://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). * * **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`. diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index f0d6dfb..929abe4 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -114,6 +114,21 @@ function formatDateInput(value?: string | null) { 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 ( + onSort(col)}> + {label}{' '} + {active && sortDir === 'desc' ? '▼' : '▲'} + + ) +} + function normalizeAttendanceSearch(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() } @@ -3043,12 +3058,17 @@ export function AdminIpBansPage() { const [rows, setRows] = useState>>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(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) { setLoading(true) try { - const data = await fetchIpBans({ status: currentStatus, perPage: 100 }) + const data = await fetchIpBans({ status: currentStatus, perPage: 200 }) setRows((data.data?.bans as Array>) ?? []) + setPage(1) setError(null) } catch (e) { setError(e instanceof Error ? e.message : 'Unable to load IP bans.') @@ -3057,9 +3077,36 @@ export function AdminIpBansPage() { } } - useEffect(() => { - void load(status) - }, [status]) + useEffect(() => { 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) { await unbanIp(all ? { all: true } : { id }) @@ -3074,13 +3121,17 @@ export function AdminIpBansPage() { return ( {error ?
{error}
: null} -
+
- { setStatus(e.target.value as 'all' | 'active'); setPage(1) }}> + +
+ {totalPages > 1 && ( +
+ + Showing {Math.min((page - 1) * pageSize + 1, sortedRows.length)}–{Math.min(page * pageSize, sortedRows.length)} of {sortedRows.length} + + +
+ )} ) } diff --git a/src/pages/certificates/CertificatesPage.tsx b/src/pages/certificates/CertificatesPage.tsx new file mode 100644 index 0000000..398d214 --- /dev/null +++ b/src/pages/certificates/CertificatesPage.tsx @@ -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([]) + const [students, setStudents] = useState([]) + const [currentYear, setCurrentYear] = useState('') + const [loadingStudents, setLoadingStudents] = useState(false) + const [error, setError] = useState(null) + + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [certDate, setCertDate] = useState(() => { + const d = new Date() + return d.toISOString().slice(0, 10) + }) + const [generating, setGenerating] = useState(false) + + const selectAllRef = useRef(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 ( +
+
+
+

+ + Generate Certificates +

+
+ Select a class, choose students, then generate and print their certificates. +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Filter */} +
+
Filter Students
+
+
+
+ + +
+
+ + handleSchoolYearChange(e.target.value)} + /> +
+
+
+
+ + {loadingStudents && ( +
Loading students…
+ )} + + {!loadingStudents && hasStudents && ( +
+
+
+ + Students + {students.length} + +
+
+ + + + setCertDate(e.target.value)} + title="Certificate date" + /> +
+
+ toggleAll(e.target.checked)} + /> + +
+
+
+ +
+
+ + + + + + + + + + {students.map((s) => ( + + + + + + + ))} + +
+ Last NameFirst NameGrade / Class
+ toggleStudent(s.id)} + /> + {s.lastname}{s.firstname}{s.grade}
+
+
+ +
+ + {selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected + + +
+
+ + )} + + {!loadingStudents && classId && !hasStudents && ( +
+ No active students found for the selected class and school year. +
+ )} + + {!loadingStudents && !classId && ( +
+ Select a class above to load its student roster. +
+ )} +
+ ) +} diff --git a/src/pages/user/UserListPage.tsx b/src/pages/user/UserListPage.tsx index 560cebf..3dff5ce 100644 --- a/src/pages/user/UserListPage.tsx +++ b/src/pages/user/UserListPage.tsx @@ -194,6 +194,40 @@ function buildChangedPayload( 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 ( + onSort(col)} + > + {label}{' '} + + {active && sortDir === 'desc' ? '▼' : '▲'} + + + ) +} + /** CI `user/user_list.php` — DataTable-style list + full edit modal (`saveUser`). */ export function UserListPage() { const [rows, setRows] = useState([]) @@ -204,6 +238,8 @@ export function UserListPage() { const [originalForm, setOriginalForm] = useState(null) const [saving, setSaving] = useState(false) const modalRef = useRef(null) + const [sortCol, setSortCol] = useState('lastname') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') async function load() { setLoading(true) @@ -222,6 +258,27 @@ export function UserListPage() { 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(() => { if (!form) return 'Edit User' const name = [form.firstname, form.lastname].filter(Boolean).join(' ').trim() @@ -306,13 +363,13 @@ export function UserListPage() { - - - - - - - + + + + + + + @@ -330,7 +387,7 @@ export function UserListPage() { ) : ( - rows.map((entry) => { + sortedRows.map((entry) => { const user = entry.user ?? {} const uid = Number(user.id ?? 0) const idDisplay = diff --git a/vite.config.ts b/vite.config.ts index 4e059cd..05269c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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://192.168.3.100:8000' +const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8000' // https://vite.dev/config/ export default defineConfig({
IDFirst NameLast NameEmailPhoneUser TypeRolesActions