add badge logic
This commit is contained in:
+32
-5
@@ -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
@@ -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
@@ -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). */
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user