From 93b75b9b3ffdb55941be0d222fc7a8fee60649f4 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Apr 2026 01:23:20 -0400 Subject: [PATCH] add badge logic --- src/api/printables.ts | 37 ++- src/api/rolePermission.ts | 100 ++++++- src/api/session.ts | 2 +- src/api/types.ts | 3 + .../attendance/TeacherAttendanceMonthPage.tsx | 21 +- .../configuration/ConfigurationViewPage.tsx | 141 +++++++++- src/pages/printables/BadgeFormPage.tsx | 265 ++++++++++++++---- src/pages/rolePermission/EditRolePage.tsx | 2 +- .../EditRolePermissionsPage.tsx | 104 ++++++- src/pages/rolePermission/RoleListPage.tsx | 150 +++++++++- 10 files changed, 717 insertions(+), 108 deletions(-) diff --git a/src/api/printables.ts b/src/api/printables.ts index e45d429..be182d6 100644 --- a/src/api/printables.ts +++ b/src/api/printables.ts @@ -60,7 +60,7 @@ export async function postBadgeGenerate(formData: FormData): Promise { 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 + 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 + schoolYears?: string[] + selectedYear?: string + selectedStudentIds?: number[] + selectedUserIds?: number[] + users?: BadgeUserRow[] + } + } & { + active_role?: string + rolesTabs?: Record + 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 = { diff --git a/src/api/rolePermission.ts b/src/api/rolePermission.ts index 28050cf..562f0c2 100644 --- a/src/api/rolePermission.ts +++ b/src/api/rolePermission.ts @@ -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() + 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, ): Promise<{ ok?: boolean; message?: string }> { - 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' }, + 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) + }) + 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)) { + 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 = { diff --git a/src/api/session.ts b/src/api/session.ts index 3b41dd7..d046113 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -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(`/api/v1/attendance/staff/month-overview${qs}`) + return apiFetch(`/api/v1/attendance/staff/month${qs}`) } /** Matches CI `teacher_attendance_month` save cell (form body). */ diff --git a/src/api/types.ts b/src/api/types.ts index 332508a..4331875 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 diff --git a/src/pages/attendance/TeacherAttendanceMonthPage.tsx b/src/pages/attendance/TeacherAttendanceMonthPage.tsx index 23e1456..7a4c752 100644 --- a/src/pages/attendance/TeacherAttendanceMonthPage.tsx +++ b/src/pages/attendance/TeacherAttendanceMonthPage.tsx @@ -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() { Export CSV @@ -241,6 +244,13 @@ export function TeacherAttendanceMonthPage() { Use the dropdowns to set attendance inline
+ {!hasTeacherSections ? ( +
+ No teacher assignment rows were returned for this term. Attendance entries in + staff_attendance only display here for users who are also assigned in + teacher_class. +
+ ) : null}
+ {!hasAdmins ? ( +
+ No admin attendance rows were returned for this term. +
+ ) : null} (null) const [flash, setFlash] = useState(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 ( + + ) + } + return (
@@ -158,13 +233,19 @@ export function ConfigurationViewPage() { ) : null} +
+ +
+
- - - + + + @@ -176,7 +257,7 @@ export function ConfigurationViewPage() { ) : ( - rows.map((row) => ( + pagedRows.map((row) => ( @@ -206,11 +287,51 @@ export function ConfigurationViewPage() {
IDConfig KeyConfig Value{renderSortableHeader('id', 'ID')}{renderSortableHeader('config_key', 'Config Key')}{renderSortableHeader('config_value', 'Config Value')} Actions
{String(row.id)} {row.config_key}
-
- -
+ {!loading && rows.length > 0 ? ( +
+
+ Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRows.length)} of{' '} + {sortedRows.length} configurations +
+
+ + + + + +
+
+ ) : null} {editOpen ? (
diff --git a/src/pages/printables/BadgeFormPage.tsx b/src/pages/printables/BadgeFormPage.tsx index 3d89875..d8a4fc0 100644 --- a/src/pages/printables/BadgeFormPage.tsx +++ b/src/pages/printables/BadgeFormPage.tsx @@ -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([]) + const [rolesTabs, setRolesTabs] = useState>({}) + const [activeRole, setActiveRole] = useState('all') const [selectedYear, setSelectedYear] = useState('') const [filterText, setFilterText] = useState('') const [selected, setSelected] = useState>(() => new Set()) const [printCounts, setPrintCounts] = useState>({}) const [page, setPage] = useState(0) + const [sortKey, setSortKey] = useState('lastname') + const [sortDir, setSortDir] = useState('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 = {} + const m: Record = {} 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 = {} - 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) { 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 ( + + ) + } return (
-

Generate Staff Badges

+

Generate School Badges

+ {Object.keys(rolesTabs).length > 0 ? ( +
+ {Object.entries(rolesTabs).map(([key, label]) => { + const normalized = (key as BadgeRoleGroup) || 'all' + return ( + + ) + })} +
+ ) : null} +
+
@@ -226,11 +386,11 @@ export function BadgeFormPage() { # - Firstname - Lastname - Role - Teacher Class Section - Badge Prints + {renderSortButton('Firstname', 'firstname')} + {renderSortButton('Lastname', 'lastname')} + {renderSortButton('Role', 'role')} + {renderSortButton('Class / Section', 'className')} + {renderSortButton('Badge Prints', 'prints')}
- No staff found for the selected year. + No badge records found for the selected year. ) : ( 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 ( 0 ? 'table-success' : undefined} > {order} - {String(u?.firstname ?? '')} - {String(u?.lastname ?? '')} + {r.firstname} + {r.lastname} {r.roleLabel || '—'} {r.className || '—'} @@ -282,11 +441,11 @@ export function BadgeFormPage() { 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}`} /> -
diff --git a/src/pages/rolePermission/EditRolePage.tsx b/src/pages/rolePermission/EditRolePage.tsx index 1f3649b..1dc719e 100644 --- a/src/pages/rolePermission/EditRolePage.tsx +++ b/src/pages/rolePermission/EditRolePage.tsx @@ -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.') } } diff --git a/src/pages/rolePermission/EditRolePermissionsPage.tsx b/src/pages/rolePermission/EditRolePermissionsPage.tsx index bc6ba2c..f0908f0 100644 --- a/src/pages/rolePermission/EditRolePermissionsPage.tsx +++ b/src/pages/rolePermission/EditRolePermissionsPage.tsx @@ -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>([]) const [selectedRoleId, setSelectedRoleId] = useState(routeRoleId ?? '') const [permissions, setPermissions] = useState([]) const [matrix, setMatrix] = useState>({}) + const [matrixRoleId, setMatrixRoleId] = useState('') const [loading, setLoading] = useState(false) const [msg, setMsg] = useState(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() + for (const row of roleMatrix.permissions ?? []) { + const rawId = String(row.permission_id ?? row.id ?? '').trim() + if (rawId !== '') { + flagsByPermissionId.set(rawId, row) + } + } + const next: Record = {} - 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> + 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 = {} - 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() { ) : ( - 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 ( diff --git a/src/pages/rolePermission/RoleListPage.tsx b/src/pages/rolePermission/RoleListPage.tsx index 26b0ee0..88e51ae 100644 --- a/src/pages/rolePermission/RoleListPage.tsx +++ b/src/pages/rolePermission/RoleListPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) const [error, setError] = useState(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 ( + + ) + } + return (

List of Roles

@@ -50,22 +130,28 @@ export function RoleListPage() { - - - - + + + + - {rows.length === 0 ? ( + {loading ? ( + + + + ) : rows.length === 0 ? ( ) : ( - rows.map((role) => ( + pagedRows.map((role) => ( @@ -98,6 +184,52 @@ export function RoleListPage() {
Role IDRole NameDescriptionNumber of Users{renderSortableHeader('id', 'Role ID')}{renderSortableHeader('name', 'Role Name')}{renderSortableHeader('description', 'Description')}{renderSortableHeader('user_count', 'Number of Users')} Actions
+ Loading roles… +
{error ? '—' : 'No roles found.'}
{String(role.id ?? '')} {String(role.name ?? '')}
+ + {!loading && rows.length > 0 ? ( +
+
+ Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRows.length)} of{' '} + {sortedRows.length} roles +
+
+ + + + + +
+
+ ) : null}
) }