add certificate
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: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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 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],
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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)}>«</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setPage((p) => p - 1)}>‹</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)}>›</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setPage(totalPages)}>»</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</AdminParityShell>
|
</AdminParityShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user