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 token = getStoredToken()
|
||||
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',
|
||||
headers,
|
||||
body: formData,
|
||||
@@ -98,18 +98,44 @@ export async function fetchBadgeFormOptions(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{
|
||||
active_role?: string
|
||||
rolesTabs?: Record<string, string>
|
||||
schoolYears?: string[]
|
||||
selectedYear?: string
|
||||
selectedStudentIds?: number[]
|
||||
selectedUserIds?: number[]
|
||||
users?: BadgeUserRow[]
|
||||
}> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
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: {
|
||||
user_ids: (number | string)[]
|
||||
user_ids?: (number | string)[]
|
||||
student_ids?: (number | string)[]
|
||||
school_year?: string
|
||||
}): Promise<{
|
||||
ok?: boolean
|
||||
@@ -118,9 +144,10 @@ export async function fetchBadgePrintStatus(params: {
|
||||
csrf_hash?: string
|
||||
}> {
|
||||
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)
|
||||
return apiFetch(`/api/v1/administrator/printables/badges/status?${qs}`)
|
||||
return apiFetch(`/api/v1/badges/print-status?${qs}`)
|
||||
}
|
||||
|
||||
export type ReportCardMeta = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Roles & permissions admin (legacy CI rolepermission/*).
|
||||
* Expected under `/api/v1/role-permission`.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
import { ApiHttpError, apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/role-permission'
|
||||
|
||||
@@ -31,11 +31,64 @@ export type PermissionRow = {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export async function fetchRolesList(): Promise<{ roles?: RoleRow[] }> {
|
||||
const body = await apiFetch<{ roles?: RoleRow[] } | { data?: { roles?: RoleRow[] } }>(`${BASE}/roles`)
|
||||
export async function fetchRolesList(params?: {
|
||||
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)
|
||||
}
|
||||
|
||||
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[] }> {
|
||||
const body = await apiFetch<
|
||||
{ permissions?: PermissionRow[] } | { data?: { permissions?: PermissionRow[] } }
|
||||
@@ -146,15 +199,34 @@ export async function updateRole(
|
||||
roleId: number | string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||
{
|
||||
try {
|
||||
const body = await apiFetch<
|
||||
{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }
|
||||
>(`${BASE}/roles/${encodeURIComponent(String(roleId))}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
})
|
||||
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 }> {
|
||||
@@ -213,7 +285,13 @@ export async function updatePermission(
|
||||
|
||||
export type PermissionMatrixRow = {
|
||||
id?: number | string
|
||||
permission_id?: number | string
|
||||
name?: string
|
||||
description?: string
|
||||
can_create?: boolean | number
|
||||
can_read?: boolean | number
|
||||
can_update?: boolean | number
|
||||
can_delete?: boolean | number
|
||||
}
|
||||
|
||||
export type ExistingPermissionFlags = {
|
||||
|
||||
+1
-1
@@ -269,7 +269,7 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: {
|
||||
if (params?.semester) query.set('semester', params.semester)
|
||||
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||
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). */
|
||||
|
||||
@@ -257,9 +257,12 @@ export type StaffMonthlyOverviewPayload = {
|
||||
school_year?: string
|
||||
range_label?: string
|
||||
month_label?: string
|
||||
range_start?: string
|
||||
range_end?: string
|
||||
}
|
||||
sundays?: string[]
|
||||
noSchoolDays?: string[]
|
||||
no_school_days?: string[]
|
||||
sections?: StaffMonthlySection[]
|
||||
admins?: StaffMonthlyAdminRow[]
|
||||
missingYear?: boolean
|
||||
|
||||
@@ -81,8 +81,8 @@ export function TeacherAttendanceMonthPage() {
|
||||
const filters = payload?.filters ?? {}
|
||||
const sundays = payload?.sundays ?? []
|
||||
const noSchoolSet = useMemo(
|
||||
() => new Set(payload?.noSchoolDays ?? []),
|
||||
[payload?.noSchoolDays],
|
||||
() => new Set(payload?.noSchoolDays ?? payload?.no_school_days ?? []),
|
||||
[payload?.noSchoolDays, payload?.no_school_days],
|
||||
)
|
||||
const isCurrentYear = payload?.isCurrentYear !== false
|
||||
const schoolYears = useMemo(() => {
|
||||
@@ -97,6 +97,9 @@ export function TeacherAttendanceMonthPage() {
|
||||
return ys
|
||||
}, [payload?.schoolYears, schoolYearParam])
|
||||
|
||||
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
|
||||
const hasAdmins = (payload?.admins?.length ?? 0) > 0
|
||||
|
||||
async function onCellChange(
|
||||
kind: 'teacher' | 'admin',
|
||||
patch: {
|
||||
@@ -217,7 +220,7 @@ export function TeacherAttendanceMonthPage() {
|
||||
</Link>
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
@@ -241,6 +244,13 @@ export function TeacherAttendanceMonthPage() {
|
||||
<small className="text-muted">Use the dropdowns to set attendance inline</small>
|
||||
</div>
|
||||
<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
|
||||
sections={payload?.sections ?? []}
|
||||
sundays={sundays}
|
||||
@@ -262,6 +272,11 @@ export function TeacherAttendanceMonthPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
admins={payload?.admins ?? []}
|
||||
sundays={sundays}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
createConfigurationEntry,
|
||||
@@ -13,6 +13,10 @@ export function ConfigurationViewPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = 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 [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 (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="text-center mb-4">
|
||||
@@ -158,13 +233,19 @@ export function ConfigurationViewPage() {
|
||||
</form>
|
||||
) : 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">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Config Key</th>
|
||||
<th>Config Value</th>
|
||||
<th>{renderSortableHeader('id', 'ID')}</th>
|
||||
<th>{renderSortableHeader('config_key', 'Config Key')}</th>
|
||||
<th>{renderSortableHeader('config_value', 'Config Value')}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -176,7 +257,7 @@ export function ConfigurationViewPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
pagedRows.map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{String(row.id)}</td>
|
||||
<td>{row.config_key}</td>
|
||||
@@ -206,11 +287,51 @@ export function ConfigurationViewPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<button type="button" className="btn btn-success" onClick={() => setShowAdd((v) => !v)}>
|
||||
{showAdd ? 'Hide form' : 'Add New Config'}
|
||||
{!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} 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 ? (
|
||||
<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'
|
||||
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 {
|
||||
const raw = (u.role_name ?? u.active_role ?? u.roles ?? '') as string
|
||||
@@ -20,73 +33,147 @@ function pickRoleLabel(u: BadgeUserRow): string {
|
||||
return String(raw || '')
|
||||
}
|
||||
|
||||
function pickUserId(u: BadgeUserRow): string | null {
|
||||
const id = u.user_id ?? u.id ?? (u as { 'users.id'?: unknown })['users.id']
|
||||
function pickNumericId(u: BadgeUserRow): string | null {
|
||||
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
|
||||
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() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [users, setUsers] = useState<BadgeUserRow[]>([])
|
||||
const [rolesTabs, setRolesTabs] = useState<Record<string, string>>({})
|
||||
const [activeRole, setActiveRole] = useState<BadgeRoleGroup>('all')
|
||||
const [selectedYear, setSelectedYear] = useState('')
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
const [printCounts, setPrintCounts] = useState<Record<string, number>>({})
|
||||
const [page, setPage] = useState(0)
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastname')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const pageSize = 100
|
||||
|
||||
const rows = useMemo((): RowMeta[] => {
|
||||
const out: RowMeta[] = []
|
||||
for (const u of users) {
|
||||
const userId = pickUserId(u)
|
||||
if (!userId) continue
|
||||
const numericId = pickNumericId(u)
|
||||
if (!numericId) continue
|
||||
const roleLabel = pickRoleLabel(u)
|
||||
const className = String(u.class_section_name ?? '')
|
||||
out.push({ userId, roleLabel, className })
|
||||
const entityType = pickEntityType(u)
|
||||
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
|
||||
}, [users])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filterText.trim().toLowerCase()
|
||||
if (!q) return rows
|
||||
return rows.filter((r) => {
|
||||
const u = users.find((x) => pickUserId(x) === r.userId)
|
||||
const fn = String(u?.firstname ?? '').toLowerCase()
|
||||
const ln = String(u?.lastname ?? '').toLowerCase()
|
||||
return fn.includes(q) || ln.includes(q) || r.roleLabel.toLowerCase().includes(q)
|
||||
const visible = activeRole === 'all' ? rows : rows.filter((r) => r.roleGroup === activeRole)
|
||||
if (!q) return visible
|
||||
return visible.filter((r) => {
|
||||
return (
|
||||
r.firstname.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 start = page * pageSize
|
||||
return filtered.slice(start, start + pageSize)
|
||||
}, [filtered, page])
|
||||
return sorted.slice(start, start + pageSize)
|
||||
}, [page, sorted])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [filterText])
|
||||
}, [activeRole, filterText, sortDir, sortKey])
|
||||
|
||||
const rowMetaMap = useMemo(() => {
|
||||
const m: Record<string, { role: string; className: string }> = {}
|
||||
const m: Record<string, RowMeta> = {}
|
||||
for (const r of rows) {
|
||||
m[r.userId] = { role: r.roleLabel, className: r.className }
|
||||
m[r.rowKey] = r
|
||||
}
|
||||
return m
|
||||
}, [rows])
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const ids = rows.map((r) => r.userId)
|
||||
if (ids.length === 0) return
|
||||
const userIds = rows.filter((r) => r.entityType === 'user').map((r) => r.numericId)
|
||||
const studentIds = rows.filter((r) => r.entityType === 'student').map((r) => r.numericId)
|
||||
if (userIds.length === 0 && studentIds.length === 0) return
|
||||
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 next: Record<string, number> = {}
|
||||
for (const id of ids) {
|
||||
for (const id of [...userIds, ...studentIds]) {
|
||||
const info = data[id] as { count?: number } | undefined
|
||||
next[id] = Number(info?.count ?? 0)
|
||||
}
|
||||
@@ -100,6 +187,8 @@ export function BadgeFormPage() {
|
||||
fetchBadgeFormOptions({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
setUsers(d.users ?? [])
|
||||
setRolesTabs(d.rolesTabs ?? {})
|
||||
setActiveRole((d.active_role as BadgeRoleGroup | undefined) ?? 'all')
|
||||
setSelectedYear(d.selectedYear ?? schoolYear ?? '')
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -130,30 +219,35 @@ export function BadgeFormPage() {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev)
|
||||
for (const r of pageRows) {
|
||||
if (checked) n.add(r.userId)
|
||||
else n.delete(r.userId)
|
||||
if (checked) n.add(r.rowKey)
|
||||
else n.delete(r.rowKey)
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const pageAllSelected =
|
||||
pageRows.length > 0 && pageRows.every((r) => selected.has(r.userId))
|
||||
const pageSome = pageRows.some((r) => selected.has(r.userId))
|
||||
pageRows.length > 0 && pageRows.every((r) => selected.has(r.rowKey))
|
||||
const pageSome = pageRows.some((r) => selected.has(r.rowKey))
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
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
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.set('school_year', selectedYear || schoolYear || '')
|
||||
for (const id of selected) {
|
||||
fd.append('user_ids[]', id)
|
||||
const meta = rowMetaMap[id] ?? { role: '', className: '' }
|
||||
fd.set(`roles[${id}]`, meta.role)
|
||||
fd.set(`classes[${id}]`, meta.className)
|
||||
for (const rowKey of selected) {
|
||||
const meta = rowMetaMap[rowKey]
|
||||
if (!meta) continue
|
||||
if (meta.entityType === 'student') {
|
||||
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 {
|
||||
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 (
|
||||
<div className="container-fluid py-3">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button type="submit" className="btn btn-success" form="badgeForm">
|
||||
Generate Badges
|
||||
@@ -186,7 +327,7 @@ export function BadgeFormPage() {
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label small text-muted" htmlFor="badgeFilter">
|
||||
Filter by name or role
|
||||
Filter by name, role, or class
|
||||
</label>
|
||||
<input
|
||||
id="badgeFilter"
|
||||
@@ -199,9 +340,20 @@ export function BadgeFormPage() {
|
||||
</div>
|
||||
<div className="col-md-6 d-flex align-items-end justify-content-md-end gap-2 small text-muted">
|
||||
<span>
|
||||
Showing {filtered.length === 0 ? 0 : page * pageSize + 1}–
|
||||
{Math.min((page + 1) * pageSize, filtered.length)} of {filtered.length}
|
||||
Showing {sorted.length === 0 ? 0 : page * pageSize + 1}–
|
||||
{Math.min((page + 1) * pageSize, sorted.length)} of {sorted.length}
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
@@ -218,6 +370,14 @@ export function BadgeFormPage() {
|
||||
>
|
||||
Next
|
||||
</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>
|
||||
|
||||
@@ -226,11 +386,11 @@ export function BadgeFormPage() {
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th style={{ width: 70 }}>#</th>
|
||||
<th>Firstname</th>
|
||||
<th>Lastname</th>
|
||||
<th>Role</th>
|
||||
<th>Teacher Class Section</th>
|
||||
<th style={{ width: 140 }}>Badge Prints</th>
|
||||
<th>{renderSortButton('Firstname', 'firstname')}</th>
|
||||
<th>{renderSortButton('Lastname', 'lastname')}</th>
|
||||
<th>{renderSortButton('Role', 'role')}</th>
|
||||
<th>{renderSortButton('Class / Section', 'className')}</th>
|
||||
<th style={{ width: 140 }}>{renderSortButton('Badge Prints', 'prints')}</th>
|
||||
<th style={{ width: 160 }}>
|
||||
<div className="form-check m-0">
|
||||
<input
|
||||
@@ -254,22 +414,21 @@ export function BadgeFormPage() {
|
||||
{pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-muted">
|
||||
No staff found for the selected year.
|
||||
No badge records found for the selected year.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageRows.map((r, i) => {
|
||||
const order = page * pageSize + i + 1
|
||||
const u = users.find((x) => pickUserId(x) === r.userId)
|
||||
const count = printCounts[r.userId] ?? 0
|
||||
const count = printCounts[r.numericId] ?? 0
|
||||
return (
|
||||
<tr
|
||||
key={r.userId}
|
||||
key={r.rowKey}
|
||||
className={count > 0 ? 'table-success' : undefined}
|
||||
>
|
||||
<td className="text-center">{order}</td>
|
||||
<td>{String(u?.firstname ?? '')}</td>
|
||||
<td>{String(u?.lastname ?? '')}</td>
|
||||
<td>{r.firstname}</td>
|
||||
<td>{r.lastname}</td>
|
||||
<td>{r.roleLabel || '—'}</td>
|
||||
<td>{r.className || '—'}</td>
|
||||
<td>
|
||||
@@ -282,11 +441,11 @@ export function BadgeFormPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selected.has(r.userId)}
|
||||
onChange={(e) => toggle(r.userId, e.target.checked)}
|
||||
id={`chk-${r.userId}`}
|
||||
checked={selected.has(r.rowKey)}
|
||||
onChange={(e) => toggle(r.rowKey, e.target.checked)}
|
||||
id={`chk-${r.rowKey}`}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor={`chk-${r.userId}`}>
|
||||
<label className="form-check-label" htmlFor={`chk-${r.rowKey}`}>
|
||||
Select
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function EditRolePage() {
|
||||
})
|
||||
navigate(`${ROLE_PERMISSION_BASE}/roles`)
|
||||
} 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 { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchPermissionsList,
|
||||
fetchRolePermissionMatrix,
|
||||
fetchRolesList,
|
||||
saveRolePermissionMatrix,
|
||||
@@ -11,12 +12,34 @@ import { ROLE_PERMISSION_BASE } from './rolePermissionPaths'
|
||||
|
||||
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() {
|
||||
const { roleId: routeRoleId } = useParams<{ roleId: string }>()
|
||||
const [roles, setRoles] = useState<Array<{ id?: number | string; name?: string }>>([])
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(routeRoleId ?? '')
|
||||
const [permissions, setPermissions] = useState<PermissionMatrixRow[]>([])
|
||||
const [matrix, setMatrix] = useState<Record<string, Flags>>({})
|
||||
const [matrixRoleId, setMatrixRoleId] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
|
||||
@@ -34,18 +57,42 @@ export function EditRolePermissionsPage() {
|
||||
if (!rid) {
|
||||
setPermissions([])
|
||||
setMatrix({})
|
||||
setMatrixRoleId('')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setMsg(null)
|
||||
fetchRolePermissionMatrix(rid)
|
||||
.then((d) => {
|
||||
setPermissions(d.permissions ?? [])
|
||||
const ex = d.existing ?? {}
|
||||
setPermissions([])
|
||||
setMatrix({})
|
||||
setMatrixRoleId('')
|
||||
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> = {}
|
||||
for (const p of d.permissions ?? []) {
|
||||
const pid = String(p.id ?? '')
|
||||
const e = ex[pid] ?? ex[p.id as keyof typeof ex]
|
||||
for (const [index, p] of permissionRows.entries()) {
|
||||
const pid = permissionKey(p, index)
|
||||
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] = {
|
||||
create: Boolean(e?.can_create),
|
||||
read: Boolean(e?.can_read),
|
||||
@@ -53,7 +100,24 @@ export function EditRolePermissionsPage() {
|
||||
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)
|
||||
setMatrixRoleId(rid)
|
||||
})
|
||||
.catch(() => setMsg('Failed to load permissions matrix.'))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -63,6 +127,15 @@ export function EditRolePermissionsPage() {
|
||||
loadMatrix(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) {
|
||||
setMatrix((m) => ({
|
||||
...m,
|
||||
@@ -73,8 +146,8 @@ export function EditRolePermissionsPage() {
|
||||
function selectAll() {
|
||||
setMatrix((m) => {
|
||||
const next = { ...m }
|
||||
for (const p of permissions) {
|
||||
const pid = String(p.id ?? '')
|
||||
for (const [index, p] of permissions.entries()) {
|
||||
const pid = permissionKey(p, index)
|
||||
next[pid] = { create: true, read: true, update: true, delete: true }
|
||||
}
|
||||
return next
|
||||
@@ -84,8 +157,8 @@ export function EditRolePermissionsPage() {
|
||||
function deselectAll() {
|
||||
setMatrix((m) => {
|
||||
const next = { ...m }
|
||||
for (const p of permissions) {
|
||||
const pid = String(p.id ?? '')
|
||||
for (const [index, p] of permissions.entries()) {
|
||||
const pid = permissionKey(p, index)
|
||||
next[pid] = { create: false, read: false, update: false, delete: false }
|
||||
}
|
||||
return next
|
||||
@@ -98,8 +171,8 @@ export function EditRolePermissionsPage() {
|
||||
setMsg(null)
|
||||
const payload: Record<string, { create?: boolean; read?: boolean; update?: boolean; delete?: boolean }> =
|
||||
{}
|
||||
for (const p of permissions) {
|
||||
const pid = String(p.id ?? '')
|
||||
for (const [index, p] of permissions.entries()) {
|
||||
const pid = permissionKey(p, index)
|
||||
const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false }
|
||||
payload[pid] = {
|
||||
create: f.create,
|
||||
@@ -110,6 +183,7 @@ export function EditRolePermissionsPage() {
|
||||
}
|
||||
try {
|
||||
await saveRolePermissionMatrix(selectedRoleId, payload)
|
||||
sessionStorage.removeItem(draftStorageKey(selectedRoleId))
|
||||
setMsg('Saved.')
|
||||
} catch (err: unknown) {
|
||||
setMsg(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
@@ -179,8 +253,8 @@ export function EditRolePermissionsPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
permissions.map((p) => {
|
||||
const pid = String(p.id ?? '')
|
||||
permissions.map((p, index) => {
|
||||
const pid = permissionKey(p, index)
|
||||
const f = matrix[pid] ?? { create: false, read: false, update: false, delete: false }
|
||||
return (
|
||||
<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 { 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'
|
||||
|
||||
export function RoleListPage() {
|
||||
const [rows, setRows] = useState<RoleRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
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() {
|
||||
setError(null)
|
||||
fetchRolesList()
|
||||
setLoading(true)
|
||||
fetchAllRoles()
|
||||
.then((d) => setRows(d.roles ?? []))
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load roles.'),
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container-fluid py-3">
|
||||
<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">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Role ID</th>
|
||||
<th>Role Name</th>
|
||||
<th>Description</th>
|
||||
<th>Number of Users</th>
|
||||
<th>{renderSortableHeader('id', 'Role ID')}</th>
|
||||
<th>{renderSortableHeader('name', 'Role Name')}</th>
|
||||
<th>{renderSortableHeader('description', 'Description')}</th>
|
||||
<th>{renderSortableHeader('user_count', 'Number of Users')}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-4">
|
||||
Loading roles…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-4">
|
||||
{error ? '—' : 'No roles found.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((role) => (
|
||||
pagedRows.map((role) => (
|
||||
<tr key={String(role.id)}>
|
||||
<td>{String(role.id ?? '')}</td>
|
||||
<td>{String(role.name ?? '')}</td>
|
||||
@@ -98,6 +184,52 @@ export function RoleListPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user