fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+719
View File
@@ -0,0 +1,719 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchUsers, saveUser } from '../../api/session'
import type { UserListRow } from '../../api/types'
function dash(v: unknown): string {
const s = (v ?? '').toString().trim()
return s === '' ? '—' : s
}
function formatDateTimeLocal(value: unknown): string {
if (value == null || value === '') return ''
const normalized = String(value).trim().replace(' ', 'T')
const date = new Date(normalized)
if (Number.isNaN(date.valueOf())) {
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/)
return match ? `${match[1]}T${match[2]}` : ''
}
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function roleLabels(row: UserListRow): string {
const roles = row.roles
if (!roles?.length) return 'No Role Assigned'
const parts = roles
.map((r) => {
if (typeof r === 'string') return r.trim()
return String(r.name ?? r.slug ?? '').trim()
})
.filter(Boolean)
return parts.length ? parts.join(', ') : 'No Role Assigned'
}
function isParentRole(row: UserListRow): boolean {
return (row.roles ?? []).some((r) => {
const s =
typeof r === 'string' ? r.toLowerCase() : `${r.name ?? ''} ${r.slug ?? ''}`.toLowerCase()
return s.includes('parent')
})
}
type EditFormState = {
id: number
account_id: string
firstname: string
lastname: string
gender: string
cellphone: string
email: string
address_street: string
apt: string
city: string
state: string
zip: string
accept_school_policy: boolean
user_type: string
rfid_tag: string
school_id: string
failed_attempts: string
last_failed_at: string
semester: string
school_year: string
status: string
is_suspended: boolean
is_verified: boolean
token: string
updated_at: string
created_at: string
}
function userToForm(user: Record<string, unknown>, id: number): EditFormState {
const g = (user.gender ?? '').toString().toLowerCase()
return {
id,
account_id: String(user.account_id ?? ''),
firstname: String(user.firstname ?? ''),
lastname: String(user.lastname ?? ''),
gender: g === 'male' || g === 'female' ? g : '',
cellphone: String(user.cellphone ?? ''),
email: String(user.email ?? ''),
address_street: String(user.address_street ?? ''),
apt: String(user.apt ?? ''),
city: String(user.city ?? ''),
state: String(user.state ?? ''),
zip: String(user.zip ?? ''),
accept_school_policy: Boolean(
user.accept_school_policy === true ||
user.accept_school_policy === 1 ||
user.accept_school_policy === '1',
),
user_type: String(user.user_type ?? ''),
rfid_tag: String(user.rfid_tag ?? ''),
school_id: String(user.school_id ?? ''),
failed_attempts: String(user.failed_attempts ?? ''),
last_failed_at: formatDateTimeLocal(user.last_failed_at),
semester: String(user.semester ?? ''),
school_year: String(user.school_year ?? ''),
status: String(user.status ?? ''),
is_suspended: Boolean(
user.is_suspended === true || user.is_suspended === 1 || user.is_suspended === '1',
),
is_verified: Boolean(
user.is_verified === true || user.is_verified === 1 || user.is_verified === '1',
),
token: String(user.token ?? ''),
updated_at: formatDateTimeLocal(user.updated_at),
created_at: formatDateTimeLocal(user.created_at),
}
}
function formToPayload(f: EditFormState): Record<string, unknown> {
const payload: Record<string, unknown> = {
firstname: f.firstname.trim(),
lastname: f.lastname.trim(),
accept_school_policy: f.accept_school_policy ? 1 : 0,
is_suspended: f.is_suspended ? 1 : 0,
is_verified: f.is_verified ? 1 : 0,
}
const normalizedGender = f.gender.trim().toLowerCase()
const normalizedUserType = f.user_type.trim().toLowerCase()
const normalizedStatus = f.status.trim().toLowerCase()
const assignIfPresent = (key: string, value: string, opts?: { nullable?: boolean }) => {
const trimmed = value.trim()
if (trimmed !== '') {
payload[key] = trimmed
return
}
if (opts?.nullable) {
payload[key] = null
}
}
assignIfPresent('account_id', f.account_id, { nullable: true })
assignIfPresent('cellphone', f.cellphone)
assignIfPresent('email', f.email)
assignIfPresent('address_street', f.address_street)
assignIfPresent('apt', f.apt, { nullable: true })
assignIfPresent('city', f.city)
assignIfPresent('state', f.state)
assignIfPresent('zip', f.zip)
assignIfPresent('rfid_tag', f.rfid_tag, { nullable: true })
assignIfPresent('last_failed_at', f.last_failed_at, { nullable: true })
assignIfPresent('semester', f.semester)
assignIfPresent('school_year', f.school_year, { nullable: true })
assignIfPresent('token', f.token, { nullable: true })
if (normalizedGender === 'male' || normalizedGender === 'female') {
payload.gender = normalizedGender
} else if (normalizedGender === '') {
payload.gender = null
}
if (normalizedUserType === 'primary' || normalizedUserType === 'secondary' || normalizedUserType === 'tertiary') {
payload.user_type = normalizedUserType
}
if (normalizedStatus === 'active' || normalizedStatus === 'inactive') {
payload.status = normalizedStatus === 'active' ? 'Active' : 'Inactive'
}
const schoolId = f.school_id.trim()
if (schoolId !== '') {
if (/^\d+$/.test(schoolId)) {
payload.school_id = Number(schoolId)
}
} else {
payload.school_id = null
}
const failedAttempts = f.failed_attempts.trim()
payload.failed_attempts = failedAttempts === '' ? null : Number(failedAttempts)
return payload
}
function buildChangedPayload(
current: EditFormState,
original: EditFormState | null,
): Record<string, unknown> {
const currentPayload = formToPayload(current)
if (!original) return currentPayload
const originalPayload = formToPayload(original)
const changed: Record<string, unknown> = {}
for (const [key, value] of Object.entries(currentPayload)) {
if (!Object.is(value, originalPayload[key])) {
changed[key] = value
}
}
return changed
}
/** CI `user/user_list.php` — DataTable-style list + full edit modal (`saveUser`). */
export function UserListPage() {
const [rows, setRows] = useState<UserListRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [form, setForm] = useState<EditFormState | null>(null)
const [originalForm, setOriginalForm] = useState<EditFormState | null>(null)
const [saving, setSaving] = useState(false)
const modalRef = useRef<HTMLDivElement | null>(null)
async function load() {
setLoading(true)
try {
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
setRows(data.users ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load users.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const modalTitle = useMemo(() => {
if (!form) return 'Edit User'
const name = [form.firstname, form.lastname].filter(Boolean).join(' ').trim()
return name ? `Edit User — ${name}` : 'Edit User'
}, [form])
useEffect(() => {
if (!form) return
const el = modalRef.current
const B = (
window as unknown as {
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
}
).bootstrap
if (el && B?.Modal?.getOrCreateInstance) {
B.Modal.getOrCreateInstance(el).show()
}
}, [form])
function openEdit(entry: UserListRow) {
const user = entry.user ?? {}
const id = Number(user.id ?? 0)
if (!id) return
const nextForm = userToForm(user as Record<string, unknown>, id)
setSaveError(null)
setOriginalForm(nextForm)
setForm(nextForm)
}
function closeModal() {
const el = modalRef.current
const Inst = (
window as unknown as {
bootstrap?: { Modal: { getInstance: (n: HTMLElement) => { hide: () => void } | null } }
}
).bootstrap?.Modal
if (el && Inst?.getInstance) {
Inst.getInstance(el)?.hide()
}
setForm(null)
setOriginalForm(null)
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!form) return
const payload = buildChangedPayload(form, originalForm)
if (Object.keys(payload).length === 0) {
setSaveError(null)
closeModal()
return
}
setSaving(true)
setSaveError(null)
try {
await saveUser(form.id, payload)
await load()
closeModal()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container-fluid py-3">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h2 className="text-center mt-2 mb-0 flex-grow-1">User List</h2>
<Link className="btn btn-success btn-sm" to="/app/user/create">
Create user
</Link>
</div>
{error ? (
<div id="userListAlert" className="alert alert-danger" role="alert">
{error}
</div>
) : null}
<div className="table-responsive">
<table className="display table table-striped table-bordered align-middle" style={{ width: '100%' }}>
<thead className="table-dark">
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Phone</th>
<th>User Type</th>
<th>Roles</th>
<th style={{ width: 140 }}>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="text-center text-muted">
Loading users
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted">
No users found.
</td>
</tr>
) : (
rows.map((entry) => {
const user = entry.user ?? {}
const uid = Number(user.id ?? 0)
const idDisplay =
user.school_id != null && String(user.school_id).trim() !== ''
? String(user.school_id)
: String(user.id ?? '')
const parent = isParentRole(entry) && uid > 0
const familyHref = `/app/administrator/family?guardian_id=${encodeURIComponent(String(uid))}`
return (
<tr key={uid || idDisplay}>
<td>{dash(idDisplay)}</td>
<td>
{parent ? (
<Link className="text-decoration-none" to={familyHref}>
{dash(user.firstname)}
</Link>
) : (
dash(user.firstname)
)}
</td>
<td>
{parent ? (
<Link className="text-decoration-none" to={familyHref}>
{dash(user.lastname)}
</Link>
) : (
dash(user.lastname)
)}
</td>
<td>
{user.email ? (
<a className="text-decoration-none" href={`mailto:${String(user.email)}`}>
{String(user.email)}
</a>
) : (
'—'
)}
</td>
<td>
{user.cellphone ? (
<a className="text-decoration-none" href={`tel:${String(user.cellphone)}`}>
{String(user.cellphone)}
</a>
) : (
'—'
)}
</td>
<td>{dash(user.user_type)}</td>
<td>{roleLabels(entry)}</td>
<td>
<button
type="button"
className="btn btn-sm btn-primary"
disabled={!uid}
onClick={() => openEdit(entry)}
>
<i className="fas fa-edit" aria-hidden /> Edit
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{form ? (
<div
className="modal fade"
id="editUserModal"
tabIndex={-1}
ref={modalRef}
aria-labelledby="editUserModalLabel"
aria-hidden="true"
>
<div className="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<form className="modal-content" onSubmit={(e) => void onSubmit(e)}>
<div className="modal-header bg-primary text-white">
<h5 className="modal-title" id="editUserModalLabel">
{modalTitle}
</h5>
<button
type="button"
className="btn-close btn-close-white"
aria-label="Close"
onClick={() => closeModal()}
/>
</div>
<div className="modal-body">
{saveError ? <div className="alert alert-danger">{saveError}</div> : null}
<div className="row g-3">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Account ID</label>
<input
type="text"
name="account_id"
className="form-control"
value={form.account_id}
onChange={(e) => setForm((c) => (c ? { ...c, account_id: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">First Name</label>
<input
type="text"
name="firstname"
className="form-control"
required
value={form.firstname}
onChange={(e) => setForm((c) => (c ? { ...c, firstname: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Last Name</label>
<input
type="text"
name="lastname"
className="form-control"
required
value={form.lastname}
onChange={(e) => setForm((c) => (c ? { ...c, lastname: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Gender</label>
<select
name="gender"
className="form-select"
value={form.gender}
onChange={(e) => setForm((c) => (c ? { ...c, gender: e.target.value } : c))}
>
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Phone</label>
<input
type="text"
name="cellphone"
className="form-control"
value={form.cellphone}
onChange={(e) => setForm((c) => (c ? { ...c, cellphone: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input
type="email"
name="email"
className="form-control"
required
value={form.email}
onChange={(e) => setForm((c) => (c ? { ...c, email: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Address Street</label>
<input
type="text"
name="address_street"
className="form-control"
value={form.address_street}
onChange={(e) => setForm((c) => (c ? { ...c, address_street: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Apt</label>
<input
type="text"
name="apt"
className="form-control"
value={form.apt}
onChange={(e) => setForm((c) => (c ? { ...c, apt: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">City</label>
<input
type="text"
name="city"
className="form-control"
value={form.city}
onChange={(e) => setForm((c) => (c ? { ...c, city: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">State</label>
<input
type="text"
name="state"
maxLength={2}
className="form-control"
value={form.state}
onChange={(e) => setForm((c) => (c ? { ...c, state: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">ZIP</label>
<input
type="text"
name="zip"
className="form-control"
value={form.zip}
onChange={(e) => setForm((c) => (c ? { ...c, zip: e.target.value } : c))}
/>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label d-block">Accept School Policy</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.accept_school_policy}
onChange={(e) =>
setForm((c) => (c ? { ...c, accept_school_policy: e.target.checked } : c))
}
/>
<label className="form-check-label">Accepted</label>
</div>
</div>
<div className="mb-3">
<label className="form-label">User Type</label>
<input
type="text"
name="user_type"
className="form-control"
value={form.user_type}
onChange={(e) => setForm((c) => (c ? { ...c, user_type: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">RFID Tag</label>
<input
type="text"
name="rfid_tag"
className="form-control"
value={form.rfid_tag}
onChange={(e) => setForm((c) => (c ? { ...c, rfid_tag: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">School ID</label>
<input
type="text"
name="school_id"
className="form-control"
value={form.school_id}
onChange={(e) => setForm((c) => (c ? { ...c, school_id: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Failed Attempts</label>
<input
type="number"
name="failed_attempts"
className="form-control"
value={form.failed_attempts}
onChange={(e) => setForm((c) => (c ? { ...c, failed_attempts: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Last Failed At</label>
<input
type="datetime-local"
name="last_failed_at"
className="form-control"
value={form.last_failed_at}
onChange={(e) => setForm((c) => (c ? { ...c, last_failed_at: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Semester</label>
<input
type="text"
name="semester"
className="form-control"
value={form.semester}
onChange={(e) => setForm((c) => (c ? { ...c, semester: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
value={form.school_year}
onChange={(e) => setForm((c) => (c ? { ...c, school_year: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<input
type="text"
name="status"
className="form-control"
value={form.status}
onChange={(e) => setForm((c) => (c ? { ...c, status: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label d-block">Is Suspended</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.is_suspended}
onChange={(e) =>
setForm((c) => (c ? { ...c, is_suspended: e.target.checked } : c))
}
/>
<label className="form-check-label">Suspended</label>
</div>
</div>
<div className="mb-3">
<label className="form-label d-block">Is Verified</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.is_verified}
onChange={(e) =>
setForm((c) => (c ? { ...c, is_verified: e.target.checked } : c))
}
/>
<label className="form-check-label">Verified</label>
</div>
</div>
<div className="mb-3">
<label className="form-label">Token</label>
<input
type="text"
name="token"
className="form-control"
value={form.token}
onChange={(e) => setForm((c) => (c ? { ...c, token: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Updated At</label>
<input
type="datetime-local"
name="updated_at"
className="form-control"
value={form.updated_at}
onChange={(e) => setForm((c) => (c ? { ...c, updated_at: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Created At</label>
<input
type="datetime-local"
name="created_at"
className="form-control"
value={form.created_at}
onChange={(e) => setForm((c) => (c ? { ...c, created_at: e.target.value } : c))}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => closeModal()}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}