add certificate
This commit is contained in:
@@ -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 (
|
||||
<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) {
|
||||
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 [loading, setLoading] = useState(true)
|
||||
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) {
|
||||
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<Record<string, unknown>>) ?? [])
|
||||
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 (
|
||||
<AdminParityShell title="IP Bans">
|
||||
{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">
|
||||
<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="active">Active bans only</option>
|
||||
</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>
|
||||
<button className="btn btn-danger btn-sm" type="button" onClick={() => void onUnban(undefined, true)}>
|
||||
Unban All
|
||||
@@ -3090,23 +3141,23 @@ export function AdminIpBansPage() {
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP Address</th>
|
||||
<th>Attempts</th>
|
||||
<th>Last Attempt</th>
|
||||
<th>Blocked Until</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="ip_address" label="IP Address" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="attempts" label="Attempts" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="last_attempt_at" label="Last Attempt" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="blocked_until" label="Blocked Until" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="is_active" label="Status" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="created_at" label="Created" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="updated_at" label="Updated" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<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>
|
||||
) : rows.map((row) => {
|
||||
) : pagedRows.map((row) => {
|
||||
const id = Number(row.id ?? 0)
|
||||
const active = Boolean(row.is_active)
|
||||
return (
|
||||
@@ -3121,17 +3172,11 @@ export function AdminIpBansPage() {
|
||||
<td>{formatDate(String(row.updated_at ?? ''))}</td>
|
||||
<td className="d-flex gap-2">
|
||||
{active ? (
|
||||
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>
|
||||
Unban
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>Unban</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>
|
||||
Ban 24h
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>
|
||||
Clear Attempts
|
||||
</button>
|
||||
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>Ban 24h</button>
|
||||
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>Clear Attempts</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -3141,6 +3186,45 @@ export function AdminIpBansPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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`). */
|
||||
export function UserListPage() {
|
||||
const [rows, setRows] = useState<UserListRow[]>([])
|
||||
@@ -204,6 +238,8 @@ export function UserListPage() {
|
||||
const [originalForm, setOriginalForm] = useState<EditFormState | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const modalRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sortCol, setSortCol] = useState<SortCol>('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() {
|
||||
<table className="display table table-striped table-bordered align-middle" style={{ width: '100%' }}>
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>User Type</th>
|
||||
<th>Roles</th>
|
||||
<SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="firstname" label="First Name" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="lastname" label="Last Name" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="email" label="Email" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="cellphone" label="Phone" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="user_type" label="User Type" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortTh col="roles" label="Roles" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
<th style={{ width: 140 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -330,7 +387,7 @@ export function UserListPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((entry) => {
|
||||
sortedRows.map((entry) => {
|
||||
const user = entry.user ?? {}
|
||||
const uid = Number(user.id ?? 0)
|
||||
const idDisplay =
|
||||
|
||||
Reference in New Issue
Block a user