add badge logic

This commit is contained in:
root
2026-04-25 01:23:20 -04:00
parent 3e77fc92c7
commit 93b75b9b3f
10 changed files with 717 additions and 108 deletions
+32 -5
View File
@@ -60,7 +60,7 @@ export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' }) const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken() const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`) if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl('/api/v1/administrator/printables/badges/generate'), { const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
method: 'POST', method: 'POST',
headers, headers,
body: formData, body: formData,
@@ -98,18 +98,44 @@ export async function fetchBadgeFormOptions(params?: {
school_year?: string school_year?: string
semester?: string semester?: string
}): Promise<{ }): Promise<{
active_role?: string
rolesTabs?: Record<string, string>
schoolYears?: string[]
selectedYear?: string selectedYear?: string
selectedStudentIds?: number[]
selectedUserIds?: number[]
users?: BadgeUserRow[] users?: BadgeUserRow[]
}> { }> {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year) if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester) if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/printables/badges/form-options${suffix}`) const body = await apiFetch<{
ok?: boolean
data?: {
active_role?: string
rolesTabs?: Record<string, string>
schoolYears?: string[]
selectedYear?: string
selectedStudentIds?: number[]
selectedUserIds?: number[]
users?: BadgeUserRow[]
}
} & {
active_role?: string
rolesTabs?: Record<string, string>
schoolYears?: string[]
selectedYear?: string
selectedStudentIds?: number[]
selectedUserIds?: number[]
users?: BadgeUserRow[]
}>(`/api/v1/administrator/printables/badges/form-options${suffix}`)
return body.data ?? body
} }
export async function fetchBadgePrintStatus(params: { export async function fetchBadgePrintStatus(params: {
user_ids: (number | string)[] user_ids?: (number | string)[]
student_ids?: (number | string)[]
school_year?: string school_year?: string
}): Promise<{ }): Promise<{
ok?: boolean ok?: boolean
@@ -118,9 +144,10 @@ export async function fetchBadgePrintStatus(params: {
csrf_hash?: string csrf_hash?: string
}> { }> {
const qs = new URLSearchParams() const qs = new URLSearchParams()
for (const id of params.user_ids) qs.append('user_ids[]', String(id)) for (const id of params.user_ids ?? []) qs.append('user_ids[]', String(id))
for (const id of params.student_ids ?? []) qs.append('student_ids[]', String(id))
if (params.school_year) qs.set('school_year', params.school_year) if (params.school_year) qs.set('school_year', params.school_year)
return apiFetch(`/api/v1/administrator/printables/badges/status?${qs}`) return apiFetch(`/api/v1/badges/print-status?${qs}`)
} }
export type ReportCardMeta = { export type ReportCardMeta = {
+89 -11
View File
@@ -2,7 +2,7 @@
* Roles & permissions admin (legacy CI rolepermission/*). * Roles & permissions admin (legacy CI rolepermission/*).
* Expected under `/api/v1/role-permission`. * Expected under `/api/v1/role-permission`.
*/ */
import { apiFetch } from './http' import { ApiHttpError, apiFetch } from './http'
const BASE = '/api/v1/role-permission' const BASE = '/api/v1/role-permission'
@@ -31,11 +31,64 @@ export type PermissionRow = {
updated_at?: string updated_at?: string
} }
export async function fetchRolesList(): Promise<{ roles?: RoleRow[] }> { export async function fetchRolesList(params?: {
const body = await apiFetch<{ roles?: RoleRow[] } | { data?: { roles?: RoleRow[] } }>(`${BASE}/roles`) page?: number
per_page?: number
sort_by?: string
sort_dir?: 'asc' | 'desc'
}): Promise<{
roles?: RoleRow[]
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
}> {
const qs = new URLSearchParams()
qs.set('per_page', String(params?.per_page ?? 200))
if (params?.page) qs.set('page', String(params.page))
if (params?.sort_by) qs.set('sort_by', params.sort_by)
if (params?.sort_dir) qs.set('sort_dir', params.sort_dir)
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<
| {
roles?: RoleRow[]
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
}
| {
data?: {
roles?: RoleRow[]
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
}
}
>(`${BASE}/roles${suffix}`)
return unwrapData(body) return unwrapData(body)
} }
export async function fetchAllRoles(): Promise<{ roles: RoleRow[] }> {
const rolesById = new Map<string, RoleRow>()
let page = 1
let lastPage = 1
do {
const response = await fetchRolesList({
page,
per_page: 200,
sort_by: 'name',
sort_dir: 'asc',
})
for (const role of response.roles ?? []) {
const key = String(role.id ?? '').trim()
if (key === '') {
continue
}
if (!rolesById.has(key)) {
rolesById.set(key, role)
}
}
lastPage = Number(response.meta?.last_page ?? page)
page += 1
} while (page <= lastPage)
return { roles: [...rolesById.values()] }
}
export async function fetchPermissionsList(): Promise<{ permissions?: PermissionRow[] }> { export async function fetchPermissionsList(): Promise<{ permissions?: PermissionRow[] }> {
const body = await apiFetch< const body = await apiFetch<
{ permissions?: PermissionRow[] } | { data?: { permissions?: PermissionRow[] } } { permissions?: PermissionRow[] } | { data?: { permissions?: PermissionRow[] } }
@@ -146,15 +199,34 @@ export async function updateRole(
roleId: number | string, roleId: number | string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
): Promise<{ ok?: boolean; message?: string }> { ): Promise<{ ok?: boolean; message?: string }> {
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>( try {
`${BASE}/roles/${encodeURIComponent(String(roleId))}`, const body = await apiFetch<
{ { ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }
method: 'PATCH', >(`${BASE}/roles/${encodeURIComponent(String(roleId))}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, })
) return unwrapData(body)
return unwrapData(body) } catch (error) {
if (error instanceof ApiHttpError && error.status === 422) {
const body = error.body
if (body && typeof body === 'object') {
const errors = (body as { errors?: unknown }).errors
if (errors && typeof errors === 'object') {
for (const value of Object.values(errors as Record<string, unknown>)) {
if (Array.isArray(value) && value.length > 0) {
throw new Error(String(value[0]))
}
if (typeof value === 'string' && value.trim() !== '') {
throw new Error(value)
}
}
}
}
}
throw error
}
} }
export async function deleteRole(roleId: number | string): Promise<{ ok?: boolean }> { export async function deleteRole(roleId: number | string): Promise<{ ok?: boolean }> {
@@ -213,7 +285,13 @@ export async function updatePermission(
export type PermissionMatrixRow = { export type PermissionMatrixRow = {
id?: number | string id?: number | string
permission_id?: number | string
name?: string name?: string
description?: string
can_create?: boolean | number
can_read?: boolean | number
can_update?: boolean | number
can_delete?: boolean | number
} }
export type ExistingPermissionFlags = { export type ExistingPermissionFlags = {
+1 -1
View File
@@ -269,7 +269,7 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: {
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<StaffMonthlyOverviewPayload>(`/api/v1/attendance/staff/month-overview${qs}`) return apiFetch<StaffMonthlyOverviewPayload>(`/api/v1/attendance/staff/month${qs}`)
} }
/** Matches CI `teacher_attendance_month` save cell (form body). */ /** Matches CI `teacher_attendance_month` save cell (form body). */
+3
View File
@@ -257,9 +257,12 @@ export type StaffMonthlyOverviewPayload = {
school_year?: string school_year?: string
range_label?: string range_label?: string
month_label?: string month_label?: string
range_start?: string
range_end?: string
} }
sundays?: string[] sundays?: string[]
noSchoolDays?: string[] noSchoolDays?: string[]
no_school_days?: string[]
sections?: StaffMonthlySection[] sections?: StaffMonthlySection[]
admins?: StaffMonthlyAdminRow[] admins?: StaffMonthlyAdminRow[]
missingYear?: boolean missingYear?: boolean
@@ -81,8 +81,8 @@ export function TeacherAttendanceMonthPage() {
const filters = payload?.filters ?? {} const filters = payload?.filters ?? {}
const sundays = payload?.sundays ?? [] const sundays = payload?.sundays ?? []
const noSchoolSet = useMemo( const noSchoolSet = useMemo(
() => new Set(payload?.noSchoolDays ?? []), () => new Set(payload?.noSchoolDays ?? payload?.no_school_days ?? []),
[payload?.noSchoolDays], [payload?.noSchoolDays, payload?.no_school_days],
) )
const isCurrentYear = payload?.isCurrentYear !== false const isCurrentYear = payload?.isCurrentYear !== false
const schoolYears = useMemo(() => { const schoolYears = useMemo(() => {
@@ -97,6 +97,9 @@ export function TeacherAttendanceMonthPage() {
return ys return ys
}, [payload?.schoolYears, schoolYearParam]) }, [payload?.schoolYears, schoolYearParam])
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
const hasAdmins = (payload?.admins?.length ?? 0) > 0
async function onCellChange( async function onCellChange(
kind: 'teacher' | 'admin', kind: 'teacher' | 'admin',
patch: { patch: {
@@ -217,7 +220,7 @@ export function TeacherAttendanceMonthPage() {
</Link> </Link>
<a <a
className="btn btn-outline-primary" className="btn btn-outline-primary"
href={apiUrl(`/api/v1/attendance/staff/month-overview.csv${queryString}`)} href={apiUrl(`/api/v1/attendance/staff/month-csv${queryString}`)}
> >
Export CSV Export CSV
</a> </a>
@@ -241,6 +244,13 @@ export function TeacherAttendanceMonthPage() {
<small className="text-muted">Use the dropdowns to set attendance inline</small> <small className="text-muted">Use the dropdowns to set attendance inline</small>
</div> </div>
<div className="card-body month-grid-wrap"> <div className="card-body month-grid-wrap">
{!hasTeacherSections ? (
<div className="alert alert-info mb-3">
No teacher assignment rows were returned for this term. Attendance entries in
<code className="ms-1">staff_attendance</code> only display here for users who are also assigned in
<code className="ms-1">teacher_class</code>.
</div>
) : null}
<TeachersGrid <TeachersGrid
sections={payload?.sections ?? []} sections={payload?.sections ?? []}
sundays={sundays} sundays={sundays}
@@ -262,6 +272,11 @@ export function TeacherAttendanceMonthPage() {
</div> </div>
</div> </div>
<div className="card-body month-grid-wrap"> <div className="card-body month-grid-wrap">
{!hasAdmins ? (
<div className="alert alert-info mb-3">
No admin attendance rows were returned for this term.
</div>
) : null}
<AdminsGrid <AdminsGrid
admins={payload?.admins ?? []} admins={payload?.admins ?? []}
sundays={sundays} sundays={sundays}
+131 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { import {
createConfigurationEntry, createConfigurationEntry,
@@ -13,6 +13,10 @@ export function ConfigurationViewPage() {
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 [flash, setFlash] = useState<string | null>(null) const [flash, setFlash] = useState<string | null>(null)
const [sortKey, setSortKey] = useState<'id' | 'config_key' | 'config_value'>('config_key')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [page, setPage] = useState(1)
const pageSize = 25
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [newKey, setNewKey] = useState('') const [newKey, setNewKey] = useState('')
@@ -103,6 +107,77 @@ export function ConfigurationViewPage() {
} }
} }
const sortedRows = useMemo(() => {
const items = [...rows]
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const valueFor = (row: ConfigurationRow): string => {
switch (sortKey) {
case 'id':
return String(row.id ?? '')
case 'config_value':
return String(row.config_value ?? '')
case 'config_key':
default:
return String(row.config_key ?? '')
}
}
items.sort((a, b) => {
const result = collator.compare(valueFor(a), valueFor(b))
return sortDir === 'asc' ? result : -result
})
return items
}, [rows, sortDir, sortKey])
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * pageSize
return sortedRows.slice(start, start + pageSize)
}, [currentPage, sortedRows])
function toggleSort(nextKey: 'id' | 'config_key' | 'config_value') {
if (sortKey === nextKey) {
setSortDir((current) => (current === 'asc' ? 'desc' : 'asc'))
setPage(1)
return
}
setSortKey(nextKey)
setSortDir('asc')
setPage(1)
}
function sortIndicator(key: 'id' | 'config_key' | 'config_value') {
if (sortKey !== key) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
function renderSortableHeader(
key: 'id' | 'config_key' | 'config_value',
label: string,
) {
return (
<button
type="button"
onClick={() => toggleSort(key)}
style={{
background: 'transparent',
border: 0,
color: 'inherit',
font: 'inherit',
fontWeight: 600,
padding: 0,
textAlign: 'left',
}}
>
{label}
{sortIndicator(key)}
</button>
)
}
return ( return (
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<div className="text-center mb-4"> <div className="text-center mb-4">
@@ -158,13 +233,19 @@ export function ConfigurationViewPage() {
</form> </form>
) : null} ) : null}
<div className="d-flex justify-content-end mb-3">
<button type="button" className="btn btn-success" onClick={() => setShowAdd((v) => !v)}>
{showAdd ? 'Hide form' : 'Add New Config'}
</button>
</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{renderSortableHeader('id', 'ID')}</th>
<th>Config Key</th> <th>{renderSortableHeader('config_key', 'Config Key')}</th>
<th>Config Value</th> <th>{renderSortableHeader('config_value', 'Config Value')}</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -176,7 +257,7 @@ export function ConfigurationViewPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
rows.map((row) => ( pagedRows.map((row) => (
<tr key={String(row.id)}> <tr key={String(row.id)}>
<td>{String(row.id)}</td> <td>{String(row.id)}</td>
<td>{row.config_key}</td> <td>{row.config_key}</td>
@@ -206,11 +287,51 @@ export function ConfigurationViewPage() {
</table> </table>
</div> </div>
<div className="mb-4"> {!loading && rows.length > 0 ? (
<button type="button" className="btn btn-success" onClick={() => setShowAdd((v) => !v)}> <div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
{showAdd ? 'Hide form' : 'Add New Config'} <div className="text-muted small">
</button> Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRows.length)} of{' '}
</div> {sortedRows.length} configurations
</div>
<div className="btn-group" role="group" aria-label="Pagination">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage <= 1}
onClick={() => setPage(1)}
>
First
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" disabled>
Page {currentPage} of {totalPages}
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage >= totalPages}
onClick={() => setPage(totalPages)}
>
Last
</button>
</div>
</div>
) : null}
{editOpen ? ( {editOpen ? (
<div className="modal fade show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}> <div className="modal fade show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
+212 -53
View File
@@ -9,7 +9,20 @@ import {
} from '../../api/printables' } from '../../api/printables'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
type RowMeta = { userId: string; roleLabel: string; className: string } type BadgeRoleGroup = 'all' | 'teacher' | 'ta' | 'admin' | 'staff' | 'student'
type SortKey = 'firstname' | 'lastname' | 'role' | 'className' | 'prints'
type SortDir = 'asc' | 'desc'
type RowMeta = {
rowKey: string
entityType: 'user' | 'student'
numericId: string
firstname: string
lastname: string
roleLabel: string
className: string
roleGroup: BadgeRoleGroup
}
function pickRoleLabel(u: BadgeUserRow): string { function pickRoleLabel(u: BadgeUserRow): string {
const raw = (u.role_name ?? u.active_role ?? u.roles ?? '') as string const raw = (u.role_name ?? u.active_role ?? u.roles ?? '') as string
@@ -20,73 +33,147 @@ function pickRoleLabel(u: BadgeUserRow): string {
return String(raw || '') return String(raw || '')
} }
function pickUserId(u: BadgeUserRow): string | null { function pickNumericId(u: BadgeUserRow): string | null {
const id = u.user_id ?? u.id ?? (u as { 'users.id'?: unknown })['users.id'] const id =
u.entity_type === 'student'
? u.student_id
: u.user_id ?? u.id ?? (u as { 'users.id'?: unknown })['users.id']
if (id == null || id === '') return null if (id == null || id === '') return null
return String(id) return String(id)
} }
function pickEntityType(u: BadgeUserRow): 'user' | 'student' {
return u.entity_type === 'student' || u.student_id != null ? 'student' : 'user'
}
function pickRoleGroup(u: BadgeUserRow): BadgeRoleGroup {
const raw = String(u.role_group ?? '').toLowerCase()
if (raw === 'teacher' || raw === 'ta' || raw === 'admin' || raw === 'staff' || raw === 'student') {
return raw
}
return pickEntityType(u) === 'student' ? 'student' : 'staff'
}
export function BadgeFormPage() { export function BadgeFormPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? undefined const schoolYear = searchParams.get('school_year') ?? undefined
const semester = searchParams.get('semester') ?? undefined const semester = searchParams.get('semester') ?? undefined
const [users, setUsers] = useState<BadgeUserRow[]>([]) const [users, setUsers] = useState<BadgeUserRow[]>([])
const [rolesTabs, setRolesTabs] = useState<Record<string, string>>({})
const [activeRole, setActiveRole] = useState<BadgeRoleGroup>('all')
const [selectedYear, setSelectedYear] = useState('') const [selectedYear, setSelectedYear] = useState('')
const [filterText, setFilterText] = useState('') const [filterText, setFilterText] = useState('')
const [selected, setSelected] = useState<Set<string>>(() => new Set()) const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [printCounts, setPrintCounts] = useState<Record<string, number>>({}) const [printCounts, setPrintCounts] = useState<Record<string, number>>({})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [sortKey, setSortKey] = useState<SortKey>('lastname')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const pageSize = 100 const pageSize = 100
const rows = useMemo((): RowMeta[] => { const rows = useMemo((): RowMeta[] => {
const out: RowMeta[] = [] const out: RowMeta[] = []
for (const u of users) { for (const u of users) {
const userId = pickUserId(u) const numericId = pickNumericId(u)
if (!userId) continue if (!numericId) continue
const roleLabel = pickRoleLabel(u) const roleLabel = pickRoleLabel(u)
const className = String(u.class_section_name ?? '') const entityType = pickEntityType(u)
out.push({ userId, roleLabel, className }) out.push({
rowKey: `${entityType}:${numericId}`,
entityType,
numericId,
firstname: String(u.firstname ?? ''),
lastname: String(u.lastname ?? ''),
roleLabel,
className: String(u.class_section_name ?? u.registration_grade ?? ''),
roleGroup: pickRoleGroup(u),
})
} }
return out return out
}, [users]) }, [users])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = filterText.trim().toLowerCase() const q = filterText.trim().toLowerCase()
if (!q) return rows const visible = activeRole === 'all' ? rows : rows.filter((r) => r.roleGroup === activeRole)
return rows.filter((r) => { if (!q) return visible
const u = users.find((x) => pickUserId(x) === r.userId) return visible.filter((r) => {
const fn = String(u?.firstname ?? '').toLowerCase() return (
const ln = String(u?.lastname ?? '').toLowerCase() r.firstname.toLowerCase().includes(q) ||
return fn.includes(q) || ln.includes(q) || r.roleLabel.toLowerCase().includes(q) r.lastname.toLowerCase().includes(q) ||
r.roleLabel.toLowerCase().includes(q) ||
r.className.toLowerCase().includes(q)
)
}) })
}, [rows, filterText, users]) }, [activeRole, filterText, rows])
const sorted = useMemo(() => {
const direction = sortDir === 'asc' ? 1 : -1
return [...filtered].sort((a, b) => {
const aPrints = printCounts[a.numericId] ?? 0
const bPrints = printCounts[b.numericId] ?? 0
const valueFor = (row: RowMeta, prints: number): string | number => {
switch (sortKey) {
case 'firstname':
return row.firstname.toLowerCase()
case 'lastname':
return row.lastname.toLowerCase()
case 'role':
return row.roleLabel.toLowerCase()
case 'className':
return row.className.toLowerCase()
case 'prints':
return prints
}
}
const av = valueFor(a, aPrints)
const bv = valueFor(b, bPrints)
if (typeof av === 'number' && typeof bv === 'number') {
if (av === bv) {
return a.lastname.localeCompare(b.lastname) * direction
}
return (av - bv) * direction
}
const cmp = String(av).localeCompare(String(bv))
if (cmp !== 0) return cmp * direction
return a.lastname.localeCompare(b.lastname) * direction
})
}, [filtered, printCounts, sortDir, sortKey])
const pageRows = useMemo(() => { const pageRows = useMemo(() => {
const start = page * pageSize const start = page * pageSize
return filtered.slice(start, start + pageSize) return sorted.slice(start, start + pageSize)
}, [filtered, page]) }, [page, sorted])
useEffect(() => { useEffect(() => {
setPage(0) setPage(0)
}, [filterText]) }, [activeRole, filterText, sortDir, sortKey])
const rowMetaMap = useMemo(() => { const rowMetaMap = useMemo(() => {
const m: Record<string, { role: string; className: string }> = {} const m: Record<string, RowMeta> = {}
for (const r of rows) { for (const r of rows) {
m[r.userId] = { role: r.roleLabel, className: r.className } m[r.rowKey] = r
} }
return m return m
}, [rows]) }, [rows])
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
const ids = rows.map((r) => r.userId) const userIds = rows.filter((r) => r.entityType === 'user').map((r) => r.numericId)
if (ids.length === 0) return const studentIds = rows.filter((r) => r.entityType === 'student').map((r) => r.numericId)
if (userIds.length === 0 && studentIds.length === 0) return
try { try {
const res = await fetchBadgePrintStatus({ user_ids: ids, school_year: selectedYear || schoolYear }) const res = await fetchBadgePrintStatus({
user_ids: userIds,
student_ids: studentIds,
school_year: selectedYear || schoolYear,
})
const data = res.data ?? {} const data = res.data ?? {}
const next: Record<string, number> = {} const next: Record<string, number> = {}
for (const id of ids) { for (const id of [...userIds, ...studentIds]) {
const info = data[id] as { count?: number } | undefined const info = data[id] as { count?: number } | undefined
next[id] = Number(info?.count ?? 0) next[id] = Number(info?.count ?? 0)
} }
@@ -100,6 +187,8 @@ export function BadgeFormPage() {
fetchBadgeFormOptions({ school_year: schoolYear, semester }) fetchBadgeFormOptions({ school_year: schoolYear, semester })
.then((d) => { .then((d) => {
setUsers(d.users ?? []) setUsers(d.users ?? [])
setRolesTabs(d.rolesTabs ?? {})
setActiveRole((d.active_role as BadgeRoleGroup | undefined) ?? 'all')
setSelectedYear(d.selectedYear ?? schoolYear ?? '') setSelectedYear(d.selectedYear ?? schoolYear ?? '')
}) })
.catch(() => {}) .catch(() => {})
@@ -130,30 +219,35 @@ export function BadgeFormPage() {
setSelected((prev) => { setSelected((prev) => {
const n = new Set(prev) const n = new Set(prev)
for (const r of pageRows) { for (const r of pageRows) {
if (checked) n.add(r.userId) if (checked) n.add(r.rowKey)
else n.delete(r.userId) else n.delete(r.rowKey)
} }
return n return n
}) })
} }
const pageAllSelected = const pageAllSelected =
pageRows.length > 0 && pageRows.every((r) => selected.has(r.userId)) pageRows.length > 0 && pageRows.every((r) => selected.has(r.rowKey))
const pageSome = pageRows.some((r) => selected.has(r.userId)) const pageSome = pageRows.some((r) => selected.has(r.rowKey))
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
if (selected.size === 0) { if (selected.size === 0) {
alert('Please select at least one staff member to generate badges.') alert('Please select at least one person to generate badges.')
return return
} }
const fd = new FormData() const fd = new FormData()
fd.set('school_year', selectedYear || schoolYear || '') fd.set('school_year', selectedYear || schoolYear || '')
for (const id of selected) { for (const rowKey of selected) {
fd.append('user_ids[]', id) const meta = rowMetaMap[rowKey]
const meta = rowMetaMap[id] ?? { role: '', className: '' } if (!meta) continue
fd.set(`roles[${id}]`, meta.role) if (meta.entityType === 'student') {
fd.set(`classes[${id}]`, meta.className) fd.append('student_ids[]', meta.numericId)
continue
}
fd.append('user_ids[]', meta.numericId)
fd.set(`roles[${meta.numericId}]`, meta.roleLabel)
fd.set(`classes[${meta.numericId}]`, meta.className)
} }
try { try {
const blob = await postBadgeGenerate(fd) const blob = await postBadgeGenerate(fd)
@@ -167,16 +261,63 @@ export function BadgeFormPage() {
} }
} }
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)) const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize))
function changeSort(nextKey: SortKey) {
if (sortKey === nextKey) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
return
}
setSortKey(nextKey)
setSortDir(nextKey === 'prints' ? 'desc' : 'asc')
}
function sortIndicator(key: SortKey): string {
if (sortKey !== key) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
function renderSortButton(label: string, key: SortKey) {
return (
<button
type="button"
className="bg-transparent border-0 p-0 m-0 fw-semibold"
style={{ cursor: 'pointer', color: '#212529' }}
onClick={() => changeSort(key)}
>
{label}
{sortIndicator(key)}
</button>
)
}
return ( return (
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<div className="text-center mx-auto mb-4" style={{ maxWidth: 600 }}> <div className="text-center mx-auto mb-4" style={{ maxWidth: 600 }}>
<h2 className="text-dark mb-2">Generate Staff Badges</h2> <h2 className="text-dark mb-2">Generate School Badges</h2>
</div> </div>
<AcademicFilterBar /> <AcademicFilterBar />
{Object.keys(rolesTabs).length > 0 ? (
<div className="d-flex flex-wrap gap-2 mb-3">
{Object.entries(rolesTabs).map(([key, label]) => {
const normalized = (key as BadgeRoleGroup) || 'all'
return (
<button
key={key}
type="button"
className={`btn btn-sm ${activeRole === normalized ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveRole(normalized)}
>
{label}
</button>
)
})}
</div>
) : null}
<div className="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2 mb-3"> <div className="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2 mb-3">
<button type="submit" className="btn btn-success" form="badgeForm"> <button type="submit" className="btn btn-success" form="badgeForm">
Generate Badges Generate Badges
@@ -186,7 +327,7 @@ export function BadgeFormPage() {
<div className="row g-2 mb-3"> <div className="row g-2 mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label className="form-label small text-muted" htmlFor="badgeFilter"> <label className="form-label small text-muted" htmlFor="badgeFilter">
Filter by name or role Filter by name, role, or class
</label> </label>
<input <input
id="badgeFilter" id="badgeFilter"
@@ -199,9 +340,20 @@ export function BadgeFormPage() {
</div> </div>
<div className="col-md-6 d-flex align-items-end justify-content-md-end gap-2 small text-muted"> <div className="col-md-6 d-flex align-items-end justify-content-md-end gap-2 small text-muted">
<span> <span>
Showing {filtered.length === 0 ? 0 : page * pageSize + 1} Showing {sorted.length === 0 ? 0 : page * pageSize + 1}
{Math.min((page + 1) * pageSize, filtered.length)} of {filtered.length} {Math.min((page + 1) * pageSize, sorted.length)} of {sorted.length}
</span> </span>
<span>
Page {totalPages === 0 ? 0 : page + 1} of {totalPages}
</span>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page <= 0}
onClick={() => setPage(0)}
>
First
</button>
<button <button
type="button" type="button"
className="btn btn-outline-secondary btn-sm" className="btn btn-outline-secondary btn-sm"
@@ -218,6 +370,14 @@ export function BadgeFormPage() {
> >
Next Next
</button> </button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages - 1}
onClick={() => setPage(Math.max(0, totalPages - 1))}
>
Last
</button>
</div> </div>
</div> </div>
@@ -226,11 +386,11 @@ export function BadgeFormPage() {
<thead className="table-dark"> <thead className="table-dark">
<tr> <tr>
<th style={{ width: 70 }}>#</th> <th style={{ width: 70 }}>#</th>
<th>Firstname</th> <th>{renderSortButton('Firstname', 'firstname')}</th>
<th>Lastname</th> <th>{renderSortButton('Lastname', 'lastname')}</th>
<th>Role</th> <th>{renderSortButton('Role', 'role')}</th>
<th>Teacher Class Section</th> <th>{renderSortButton('Class / Section', 'className')}</th>
<th style={{ width: 140 }}>Badge Prints</th> <th style={{ width: 140 }}>{renderSortButton('Badge Prints', 'prints')}</th>
<th style={{ width: 160 }}> <th style={{ width: 160 }}>
<div className="form-check m-0"> <div className="form-check m-0">
<input <input
@@ -254,22 +414,21 @@ export function BadgeFormPage() {
{pageRows.length === 0 ? ( {pageRows.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="text-center text-muted"> <td colSpan={7} className="text-center text-muted">
No staff found for the selected year. No badge records found for the selected year.
</td> </td>
</tr> </tr>
) : ( ) : (
pageRows.map((r, i) => { pageRows.map((r, i) => {
const order = page * pageSize + i + 1 const order = page * pageSize + i + 1
const u = users.find((x) => pickUserId(x) === r.userId) const count = printCounts[r.numericId] ?? 0
const count = printCounts[r.userId] ?? 0
return ( return (
<tr <tr
key={r.userId} key={r.rowKey}
className={count > 0 ? 'table-success' : undefined} className={count > 0 ? 'table-success' : undefined}
> >
<td className="text-center">{order}</td> <td className="text-center">{order}</td>
<td>{String(u?.firstname ?? '')}</td> <td>{r.firstname}</td>
<td>{String(u?.lastname ?? '')}</td> <td>{r.lastname}</td>
<td>{r.roleLabel || '—'}</td> <td>{r.roleLabel || '—'}</td>
<td>{r.className || '—'}</td> <td>{r.className || '—'}</td>
<td> <td>
@@ -282,11 +441,11 @@ export function BadgeFormPage() {
<input <input
type="checkbox" type="checkbox"
className="form-check-input" className="form-check-input"
checked={selected.has(r.userId)} checked={selected.has(r.rowKey)}
onChange={(e) => toggle(r.userId, e.target.checked)} onChange={(e) => toggle(r.rowKey, e.target.checked)}
id={`chk-${r.userId}`} id={`chk-${r.rowKey}`}
/> />
<label className="form-check-label" htmlFor={`chk-${r.userId}`}> <label className="form-check-label" htmlFor={`chk-${r.rowKey}`}>
Select Select
</label> </label>
</div> </div>
+1 -1
View File
@@ -57,7 +57,7 @@ export function EditRolePage() {
}) })
navigate(`${ROLE_PERMISSION_BASE}/roles`) navigate(`${ROLE_PERMISSION_BASE}/roles`)
} catch (err: unknown) { } catch (err: unknown) {
setMsg(err instanceof ApiHttpError ? err.message : 'Update failed.') setMsg(err instanceof Error ? err.message : 'Update failed.')
} }
} }
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { import {
fetchPermissionsList,
fetchRolePermissionMatrix, fetchRolePermissionMatrix,
fetchRolesList, fetchRolesList,
saveRolePermissionMatrix, saveRolePermissionMatrix,
@@ -11,12 +12,34 @@ import { ROLE_PERMISSION_BASE } from './rolePermissionPaths'
type Flags = { create: boolean; read: boolean; update: boolean; delete: boolean } type Flags = { create: boolean; read: boolean; update: boolean; delete: boolean }
function draftStorageKey(roleId: string): string {
return `role-permission-draft:${roleId}`
}
function permissionKey(
permission: PermissionMatrixRow,
index: number,
): string {
const rawId = String(permission.permission_id ?? permission.id ?? '').trim()
if (rawId !== '' && rawId !== '0') {
return rawId
}
const name = String(permission.name ?? '').trim().toLowerCase()
if (name !== '') {
return `name:${name}:${index}`
}
return `permission:${index}`
}
export function EditRolePermissionsPage() { export function EditRolePermissionsPage() {
const { roleId: routeRoleId } = useParams<{ roleId: string }>() const { roleId: routeRoleId } = useParams<{ roleId: string }>()
const [roles, setRoles] = useState<Array<{ id?: number | string; name?: string }>>([]) const [roles, setRoles] = useState<Array<{ id?: number | string; name?: string }>>([])
const [selectedRoleId, setSelectedRoleId] = useState(routeRoleId ?? '') const [selectedRoleId, setSelectedRoleId] = useState(routeRoleId ?? '')
const [permissions, setPermissions] = useState<PermissionMatrixRow[]>([]) const [permissions, setPermissions] = useState<PermissionMatrixRow[]>([])
const [matrix, setMatrix] = useState<Record<string, Flags>>({}) const [matrix, setMatrix] = useState<Record<string, Flags>>({})
const [matrixRoleId, setMatrixRoleId] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
@@ -34,18 +57,42 @@ export function EditRolePermissionsPage() {
if (!rid) { if (!rid) {
setPermissions([]) setPermissions([])
setMatrix({}) setMatrix({})
setMatrixRoleId('')
return return
} }
setLoading(true) setLoading(true)
setMsg(null) setMsg(null)
fetchRolePermissionMatrix(rid) setPermissions([])
.then((d) => { setMatrix({})
setPermissions(d.permissions ?? []) setMatrixRoleId('')
const ex = d.existing ?? {} Promise.all([fetchPermissionsList(), fetchRolePermissionMatrix(rid)])
.then(([allPermissions, roleMatrix]) => {
const permissionRows: PermissionMatrixRow[] = (allPermissions.permissions ?? []).map((p) => ({
id: p.id,
permission_id: p.id,
name: p.name,
description: p.description,
}))
setPermissions(permissionRows)
const ex = roleMatrix.existing ?? {}
const flagsByPermissionId = new Map<string, PermissionMatrixRow>()
for (const row of roleMatrix.permissions ?? []) {
const rawId = String(row.permission_id ?? row.id ?? '').trim()
if (rawId !== '') {
flagsByPermissionId.set(rawId, row)
}
}
const next: Record<string, Flags> = {} const next: Record<string, Flags> = {}
for (const p of d.permissions ?? []) { for (const [index, p] of permissionRows.entries()) {
const pid = String(p.id ?? '') const pid = permissionKey(p, index)
const e = ex[pid] ?? ex[p.id as keyof typeof ex] const direct = flagsByPermissionId.get(String(p.permission_id ?? p.id ?? '').trim())
const e =
direct ??
ex[pid] ??
ex[p.permission_id as keyof typeof ex] ??
ex[p.id as keyof typeof ex]
next[pid] = { next[pid] = {
create: Boolean(e?.can_create), create: Boolean(e?.can_create),
read: Boolean(e?.can_read), read: Boolean(e?.can_read),
@@ -53,7 +100,24 @@ export function EditRolePermissionsPage() {
delete: Boolean(e?.can_delete), delete: Boolean(e?.can_delete),
} }
} }
try {
const rawDraft = sessionStorage.getItem(draftStorageKey(rid))
if (rawDraft) {
const parsed = JSON.parse(rawDraft) as Record<string, Partial<Flags>>
for (const [key, value] of Object.entries(parsed)) {
next[key] = {
create: Boolean(value?.create),
read: Boolean(value?.read),
update: Boolean(value?.update),
delete: Boolean(value?.delete),
}
}
}
} catch {
// Ignore malformed local drafts and prefer server state.
}
setMatrix(next) setMatrix(next)
setMatrixRoleId(rid)
}) })
.catch(() => setMsg('Failed to load permissions matrix.')) .catch(() => setMsg('Failed to load permissions matrix.'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -63,6 +127,15 @@ export function EditRolePermissionsPage() {
loadMatrix(selectedRoleId) loadMatrix(selectedRoleId)
}, [selectedRoleId]) }, [selectedRoleId])
useEffect(() => {
if (!selectedRoleId || matrixRoleId !== selectedRoleId) return
try {
sessionStorage.setItem(draftStorageKey(selectedRoleId), JSON.stringify(matrix))
} catch {
// Ignore storage write failures.
}
}, [matrix, matrixRoleId, selectedRoleId])
function setFlag(pid: string, key: keyof Flags, val: boolean) { function setFlag(pid: string, key: keyof Flags, val: boolean) {
setMatrix((m) => ({ setMatrix((m) => ({
...m, ...m,
@@ -73,8 +146,8 @@ export function EditRolePermissionsPage() {
function selectAll() { function selectAll() {
setMatrix((m) => { setMatrix((m) => {
const next = { ...m } const next = { ...m }
for (const p of permissions) { for (const [index, p] of permissions.entries()) {
const pid = String(p.id ?? '') const pid = permissionKey(p, index)
next[pid] = { create: true, read: true, update: true, delete: true } next[pid] = { create: true, read: true, update: true, delete: true }
} }
return next return next
@@ -84,8 +157,8 @@ export function EditRolePermissionsPage() {
function deselectAll() { function deselectAll() {
setMatrix((m) => { setMatrix((m) => {
const next = { ...m } const next = { ...m }
for (const p of permissions) { for (const [index, p] of permissions.entries()) {
const pid = String(p.id ?? '') const pid = permissionKey(p, index)
next[pid] = { create: false, read: false, update: false, delete: false } next[pid] = { create: false, read: false, update: false, delete: false }
} }
return next return next
@@ -98,8 +171,8 @@ export function EditRolePermissionsPage() {
setMsg(null) setMsg(null)
const payload: Record<string, { create?: boolean; read?: boolean; update?: boolean; delete?: boolean }> = const payload: Record<string, { create?: boolean; read?: boolean; update?: boolean; delete?: boolean }> =
{} {}
for (const p of permissions) { for (const [index, p] of permissions.entries()) {
const pid = String(p.id ?? '') const pid = permissionKey(p, index)
const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false } const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false }
payload[pid] = { payload[pid] = {
create: f.create, create: f.create,
@@ -110,6 +183,7 @@ export function EditRolePermissionsPage() {
} }
try { try {
await saveRolePermissionMatrix(selectedRoleId, payload) await saveRolePermissionMatrix(selectedRoleId, payload)
sessionStorage.removeItem(draftStorageKey(selectedRoleId))
setMsg('Saved.') setMsg('Saved.')
} catch (err: unknown) { } catch (err: unknown) {
setMsg(err instanceof ApiHttpError ? err.message : 'Save failed.') setMsg(err instanceof ApiHttpError ? err.message : 'Save failed.')
@@ -179,8 +253,8 @@ export function EditRolePermissionsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
permissions.map((p) => { permissions.map((p, index) => {
const pid = String(p.id ?? '') const pid = permissionKey(p, index)
const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false } const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false }
return ( return (
<tr key={pid}> <tr key={pid}>
+141 -9
View File
@@ -1,20 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { deleteRole, fetchRolesList, type RoleRow } from '../../api/rolePermission' import { deleteRole, fetchAllRoles, type RoleRow } from '../../api/rolePermission'
import { ROLE_PERMISSION_BASE } from './rolePermissionPaths' import { ROLE_PERMISSION_BASE } from './rolePermissionPaths'
export function RoleListPage() { export function RoleListPage() {
const [rows, setRows] = useState<RoleRow[]>([]) const [rows, setRows] = useState<RoleRow[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sortKey, setSortKey] = useState<'id' | 'name' | 'description' | 'user_count'>('name')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [page, setPage] = useState(1)
const pageSize = 25
function load() { function load() {
setError(null) setError(null)
fetchRolesList() setLoading(true)
fetchAllRoles()
.then((d) => setRows(d.roles ?? [])) .then((d) => setRows(d.roles ?? []))
.catch((e: unknown) => .catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Unable to load roles.'), setError(e instanceof ApiHttpError ? e.message : 'Unable to load roles.'),
) )
.finally(() => setLoading(false))
} }
useEffect(() => { useEffect(() => {
@@ -31,6 +38,79 @@ export function RoleListPage() {
} }
} }
const sortedRows = useMemo(() => {
const items = [...rows]
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const valueFor = (role: RoleRow): string => {
switch (sortKey) {
case 'id':
return String(role.id ?? '')
case 'description':
return String(role.description ?? '')
case 'user_count':
return String(role.user_count ?? 0)
case 'name':
default:
return String(role.name ?? '')
}
}
items.sort((a, b) => {
const result = collator.compare(valueFor(a), valueFor(b))
return sortDir === 'asc' ? result : -result
})
return items
}, [rows, sortDir, sortKey])
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * pageSize
return sortedRows.slice(start, start + pageSize)
}, [currentPage, sortedRows])
function toggleSort(nextKey: 'id' | 'name' | 'description' | 'user_count') {
if (sortKey === nextKey) {
setSortDir((current) => (current === 'asc' ? 'desc' : 'asc'))
setPage(1)
return
}
setSortKey(nextKey)
setSortDir('asc')
setPage(1)
}
function sortIndicator(key: 'id' | 'name' | 'description' | 'user_count') {
if (sortKey !== key) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
function renderSortableHeader(
key: 'id' | 'name' | 'description' | 'user_count',
label: string,
) {
return (
<button
type="button"
onClick={() => toggleSort(key)}
style={{
background: 'transparent',
border: 0,
color: 'inherit',
font: 'inherit',
fontWeight: 600,
padding: 0,
textAlign: 'left',
}}
>
{label}
{sortIndicator(key)}
</button>
)
}
return ( return (
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<h2 className="text-center mt-4 mb-3">List of Roles</h2> <h2 className="text-center mt-4 mb-3">List of Roles</h2>
@@ -50,22 +130,28 @@ export function RoleListPage() {
<table className="table table-bordered table-striped align-middle"> <table className="table table-bordered table-striped align-middle">
<thead className="table-dark"> <thead className="table-dark">
<tr> <tr>
<th>Role ID</th> <th>{renderSortableHeader('id', 'Role ID')}</th>
<th>Role Name</th> <th>{renderSortableHeader('name', 'Role Name')}</th>
<th>Description</th> <th>{renderSortableHeader('description', 'Description')}</th>
<th>Number of Users</th> <th>{renderSortableHeader('user_count', 'Number of Users')}</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 ? ( {loading ? (
<tr>
<td colSpan={5} className="text-center text-muted py-4">
Loading roles
</td>
</tr>
) : rows.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="text-center text-muted py-4"> <td colSpan={5} className="text-center text-muted py-4">
{error ? '—' : 'No roles found.'} {error ? '—' : 'No roles found.'}
</td> </td>
</tr> </tr>
) : ( ) : (
rows.map((role) => ( pagedRows.map((role) => (
<tr key={String(role.id)}> <tr key={String(role.id)}>
<td>{String(role.id ?? '')}</td> <td>{String(role.id ?? '')}</td>
<td>{String(role.name ?? '')}</td> <td>{String(role.name ?? '')}</td>
@@ -98,6 +184,52 @@ export function RoleListPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{!loading && rows.length > 0 ? (
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
<div className="text-muted small">
Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRows.length)} of{' '}
{sortedRows.length} roles
</div>
<div className="btn-group" role="group" aria-label="Pagination">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage <= 1}
onClick={() => setPage(1)}
>
First
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" disabled>
Page {currentPage} of {totalPages}
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={currentPage >= totalPages}
onClick={() => setPage(totalPages)}
>
Last
</button>
</div>
</div>
) : null}
</div> </div>
) )
} }