add admin pages

This commit is contained in:
root
2026-04-23 15:28:56 -04:00
parent 94700d4f0f
commit 7fe34dde0d
9 changed files with 6513 additions and 99 deletions
+625
View File
@@ -0,0 +1,625 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
deleteAdministratorEmergencyContact,
fetchAdministratorEmergencyContact,
fetchAdministratorEmergencyContacts,
fetchClassProgressDetail,
fetchClassProgressGroups,
fetchClassProgressMeta,
fetchClassSections,
fetchStudentScoreCard,
searchAdministratorDashboard,
updateAdministratorEmergencyContact,
} from '../api/session'
import type {
AdministratorDashboardSearchResponse,
ClassProgressGroupRow,
ClassProgressReportRow,
EmergencyContactGroupRow,
StudentScoreCardRow,
} from '../api/types'
function PageShell({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="container-fluid py-3">
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
{children}
</div>
)
}
function formatDate(value?: string | null, withTime = false) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return withTime ? date.toLocaleString() : date.toLocaleDateString()
}
function splitUnitTitle(unitTitle?: string | null) {
const raw = String(unitTitle ?? '').trim()
if (!raw) return { curriculum: [] as string[], custom: [] as string[] }
const segments = raw
.split(/\s*[;|]\s*/)
.map((part) => part.trim())
.filter(Boolean)
const curriculum: string[] = []
const custom: string[] = []
for (const segment of segments) {
if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment)
else curriculum.push(segment)
}
if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw)
return { curriculum, custom }
}
function ClassProgressUnitDisplay({
unitTitle,
isQuran,
compact,
}: {
unitTitle?: string | null
isQuran?: boolean
compact?: boolean
}) {
const { curriculum, custom } = splitUnitTitle(unitTitle)
const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter'
const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)'
if (!String(unitTitle ?? '').trim()) return <span className="text-muted">-</span>
if (compact) {
return (
<>
{custom.length > 0 ? (
<div className="small">
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
{custom.join(', ')}
</div>
) : null}
{curriculum.length > 0 ? (
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
) : null}
</>
)
}
return (
<>
{curriculum.length > 0 ? <div className="mb-2"><strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}</div> : null}
{custom.length > 0 ? <div className="mb-2"><strong>{labelCustom}:</strong> {custom.join(' ; ')}</div> : null}
</>
)
}
export function AdminClassProgressListPage() {
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
const [classSections, setClassSections] = useState<Array<{ class_section_id?: number | null; class_section_name?: string | null }>>([])
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
async function load() {
try {
const [meta, sections, groups] = await Promise.all([
fetchClassProgressMeta(),
fetchClassSections({ perPage: 200 }),
fetchClassProgressGroups({
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
status: filters.status || null,
weekStart: filters.weekStart || null,
weekEnd: filters.weekEnd || null,
perPage: 200,
}),
])
setSubjectSections(meta.data?.subject_sections ?? {})
setStatusOptions(meta.data?.status_options ?? {})
setClassSections(sections.data?.sections ?? [])
setItems(groups.data?.items ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load class progress.')
}
}
useEffect(() => {
void load()
}, [])
const itemsBySection = useMemo(() => {
const map = new Map<string, ClassProgressGroupRow[]>()
for (const item of items) {
const key = String(item.class_section_id ?? '0')
const bucket = map.get(key) ?? []
bucket.push(item)
map.set(key, bucket)
}
return Array.from(map.entries())
}, [items])
return (
<PageShell title="Class Progress Reports">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="text-muted">Filter by week, class, and status</div>
<Link className="btn btn-sm btn-outline-warning" to="/app/administrator/teacher_submissions">
Teachers &lt; 50%
</Link>
</div>
<form className="card shadow-sm mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
<div className="card-body">
<div className="row g-3">
<div className="col-md-3"><label className="form-label">From</label><input type="date" className="form-control" value={filters.weekStart} onChange={(e) => setFilters((current) => ({ ...current, weekStart: e.target.value }))} /></div>
<div className="col-md-3"><label className="form-label">To</label><input type="date" className="form-control" value={filters.weekEnd} onChange={(e) => setFilters((current) => ({ ...current, weekEnd: e.target.value }))} /></div>
<div className="col-md-3">
<label className="form-label">Class/Section</label>
<select className="form-select" value={filters.classSectionId} onChange={(e) => setFilters((current) => ({ ...current, classSectionId: e.target.value }))}>
<option value="">All</option>
{classSections.map((section) => <option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>{section.class_section_name}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Status</label>
<select className="form-select" value={filters.status} onChange={(e) => setFilters((current) => ({ ...current, status: e.target.value }))}>
<option value="">All</option>
{Object.entries(statusOptions).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
</div>
<div className="d-flex gap-2 mt-3">
<button className="btn btn-primary" type="submit">Apply</button>
<button className="btn btn-outline-secondary" type="button" onClick={() => { setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' }); void setTimeout(load, 0) }}>Reset</button>
</div>
</div>
</form>
<div className="card shadow-sm mt-4">
<div className="card-body pt-4">
{itemsBySection.length === 0 ? (
<div className="text-center text-muted py-4">No reports found.</div>
) : (
<div className="accordion" id="adminProgressAccordion">
{itemsBySection.map(([sectionId, groups]) => (
<div className="accordion-item mb-2" key={sectionId}>
<h2 className="accordion-header" id={`progress-heading-${sectionId}`}>
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target={`#progress-section-${sectionId}`}>
{groups[0]?.class_section_name ?? `Section ${sectionId}`}
<span className="badge bg-secondary ms-2">{groups.length} weeks</span>
</button>
</h2>
<div id={`progress-section-${sectionId}`} className="accordion-collapse collapse" data-bs-parent="#adminProgressAccordion">
<div className="accordion-body">
<div className="table-responsive">
<table className="table table-hover align-middle mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr><th>Week</th><th>Subjects</th><th>Teacher</th><th className="text-end">Action</th></tr>
</thead>
<tbody>
{groups.map((group) => {
const reports = group.reports ?? {}
const teacherSet = Array.from(new Set(Object.values(reports).map((report) => report.teacher_name).filter(Boolean)))
const firstReport = Object.values(reports)[0]
return (
<tr key={`${sectionId}-${group.week_start}`}>
<td>
<div className="fw-semibold">{formatDate(group.week_start)} - {formatDate(group.week_end)}</div>
</td>
<td>
<div className="d-flex flex-column gap-2">
{Object.entries(subjectSections).map(([slug, section]) => {
const subjectName = section.db_subject ?? section.label ?? slug
const report = reports[subjectName]
return (
<div key={slug} className="border rounded-3 p-2">
<div className="d-flex justify-content-between align-items-center">
<strong className="small mb-0">{section.label ?? subjectName}</strong>
<span className={`badge ${report ? 'bg-secondary' : 'bg-light text-muted'}`}>{report?.status_label ?? 'No entry'}</span>
</div>
<div className="small">
{report ? (
<ClassProgressUnitDisplay unitTitle={report.unit_title} isQuran={subjectName === 'Quran/Arabic'} compact />
) : (
<span className="text-muted">No submission</span>
)}
</div>
</div>
)
})}
</div>
</td>
<td>{teacherSet.join(', ') || '—'}</td>
<td className="text-end">
{firstReport?.id ? (
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => navigate(`/app/admin/progress/view/${firstReport.id}`)}>
View
</button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</PageShell>
)
}
export function AdminClassProgressViewPage() {
const { id } = useParams()
const reportId = Number(id)
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!reportId) return
;(async () => {
try {
const [detail, meta] = await Promise.all([fetchClassProgressDetail(reportId), fetchClassProgressMeta()])
setReport(detail.data?.report ?? null)
setWeeklyReports(detail.data?.weekly_reports ?? [])
setSubjectSections(meta.data?.subject_sections ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load progress report.')
}
})()
}, [reportId])
const reportsBySubject = useMemo(
() => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])),
[weeklyReports],
)
const attachmentList = weeklyReports.flatMap((entry) => (entry.attachments ?? []).map((attachment) => ({
subject: entry.subject ?? 'Attachment',
attachment,
})))
return (
<PageShell title="Progress Report">
{error ? <div className="alert alert-danger">{error}</div> : null}
{report ? (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<div className="text-muted">{report.class_section_name ?? '-'} {formatDate(report.week_start)} {formatDate(report.week_end)}</div>
<Link to="/app/admin/progress" className="btn btn-outline-secondary">Back</Link>
</div>
<div className="row g-3">
<div className="col-lg-8">
{Object.entries(subjectSections).map(([slug, section]) => {
const subjectName = section.db_subject ?? section.label ?? slug
const entry = reportsBySubject[subjectName]
return (
<div className="card shadow-sm mb-3" key={slug}>
<div className="card-header bg-white"><strong>{section.label ?? subjectName}</strong></div>
<div className="card-body">
{!entry ? (
<div className="text-muted">No entry submitted for this subject this week.</div>
) : (
<>
<div className="mb-2">
<ClassProgressUnitDisplay unitTitle={entry.unit_title} isQuran={subjectName === 'Quran/Arabic'} />
</div>
<div className="mb-3"><strong>Activities</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.covered || '-'}</div></div>
<div className="mb-3"><strong>{subjectName === 'Quran/Arabic' ? 'Arabic Practice / Homework' : 'Assigned Homework'}:</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.homework || '-'}</div></div>
{(entry.attachments ?? []).length > 0 ? (
<div className="mt-3">
<strong>Attachments:</strong>
<div className="d-flex flex-column gap-2 mt-2">
{(entry.attachments ?? []).map((attachment) => (
<a key={attachment.id ?? attachment.download_url} className="btn btn-sm btn-outline-secondary text-start" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
{attachment.name ?? 'Attachment'}
</a>
))}
</div>
</div>
) : null}
</>
)}
</div>
</div>
)
})}
</div>
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-header bg-white"><strong>Summary</strong></div>
<div className="card-body">
<div className="mb-2"><strong>Teacher:</strong> {report.teacher_name ?? '-'}</div>
<div className="mb-2"><strong>Submitted:</strong> {formatDate(report.created_at, true)}</div>
<div><strong>Status:</strong> {report.status_label ?? 'Unknown'}</div>
</div>
</div>
{(report.flags ?? []).length > 0 ? (
<div className="card shadow-sm mb-3">
<div className="card-header bg-white"><strong>Checklist</strong></div>
<div className="card-body d-flex flex-wrap gap-2">
{(report.flags ?? []).map((flag) => <span key={flag} className="badge bg-light text-dark border">{flag.replace(/_/g, ' ')}</span>)}
</div>
</div>
) : null}
{attachmentList.length > 0 ? (
<div className="card shadow-sm">
<div className="card-header bg-white"><strong>Attachments</strong></div>
<div className="card-body">
{attachmentList.map(({ subject, attachment }, index) => (
<a key={`${attachment.id ?? index}`} className="btn btn-outline-primary w-100 text-start mb-2" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
{`${subject}${attachment.name ?? 'Attachment'}`}
</a>
))}
</div>
</div>
) : null}
</div>
</div>
</>
) : null}
</PageShell>
)
}
export function AdminStudentScoreCardPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
const [selectedStudent, setSelectedStudent] = useState<{ firstname?: string | null; lastname?: string | null; school_id?: string | null } | null>(null)
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
const [error, setError] = useState<string | null>(null)
async function runSearch(e?: FormEvent) {
e?.preventDefault()
if (!query.trim()) return
try {
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(query.trim())
setResults(data.results?.students ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to search students.')
}
}
async function openScoreCard(studentId: number) {
try {
const data = await fetchStudentScoreCard(studentId)
setSelectedStudentId(studentId)
setSelectedStudent(data.student)
setRows(data.rows ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load student score card.')
}
}
return (
<PageShell title="Student Score Card (Admin)">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-3">
<div className="card-body">
<form onSubmit={(e) => void runSearch(e)}>
<div className="row g-2 justify-content-center">
<div className="col-12 col-md-8 col-lg-6">
<div className="input-group">
<span className="input-group-text">Search Student</span>
<input className="form-control" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type School ID or Student Name" />
<button className="btn btn-outline-secondary" type="button" onClick={() => setQuery('')}>Clear</button>
</div>
<small className="text-muted">Searches all grades.</small>
</div>
</div>
<button type="submit" className="d-none">Search</button>
</form>
</div>
</div>
{query.trim() && results.length === 0 ? <div className="alert alert-warning">No students found.</div> : null}
{results.length > 0 ? (
<div className="card shadow-sm mb-4">
<div className="card-header d-flex justify-content-between align-items-center"><strong>Results</strong><span className="text-muted small">Showing matching students</span></div>
<div className="card-body table-responsive pt-2">
<table className="table table-striped table-hover align-middle">
<thead className="table-light"><tr><th>School ID</th><th>Student</th><th></th></tr></thead>
<tbody>
{results.map((row) => (
<tr key={String(row.id ?? '')}>
<td>{String(row.school_id ?? '')}</td>
<td>{`${String(row.firstname ?? '')} ${String(row.lastname ?? '')}`.trim()}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void openScoreCard(Number(row.id))}>Open Score Card</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{selectedStudentId && selectedStudent ? (
<div className="card shadow-sm">
<div className="card-header">
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
</div>
<div className="card-body table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>School Year</th>
<th>Semester</th>
<th>Class</th>
<th>Homework</th>
<th>Project</th>
<th>Participation</th>
<th>Quiz</th>
<th>Test</th>
<th>Attendance</th>
<th>PTAP</th>
<th>Midterm</th>
<th>Semester</th>
<th>Year Avg</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={13} className="text-muted">No score rows found.</td></tr> : rows.map((row, index) => (
<tr key={`${row.school_year}-${row.semester}-${index}`}>
<td>{row.school_year ?? '—'}</td>
<td>{row.semester ?? '—'}</td>
<td>{row.class_section_name ?? '—'}</td>
<td>{row.homework_avg ?? '—'}</td>
<td>{row.project_avg ?? '—'}</td>
<td>{row.participation_score ?? '—'}</td>
<td>{row.quiz_avg ?? '—'}</td>
<td>{row.test_avg ?? '—'}</td>
<td>{row.attendance_score ?? '—'}</td>
<td>{row.ptap_score ?? '—'}</td>
<td>{row.midterm_exam_score ?? '—'}</td>
<td>{row.semester_score ?? '—'}</td>
<td>{row.year_score ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</PageShell>
)
}
export function AdminEmergencyContactIndexPage() {
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
async function load() {
try {
const data = await fetchAdministratorEmergencyContacts()
setGroups(data.groups ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load emergency contacts.')
}
}
useEffect(() => {
void load()
}, [])
async function removeContact(contactId: number, parentId: number) {
await deleteAdministratorEmergencyContact(contactId, parentId)
await load()
}
return (
<PageShell title="Emergency Contact Information">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-dark">
<tr><th>Parent Name</th><th>Students</th><th>Contact Name</th><th>Relation</th><th>Phone</th><th>Actions</th></tr>
</thead>
<tbody>
{groups.flatMap((group) =>
(group.contacts ?? []).map((contact) => (
<tr key={contact.id}>
<td>{group.parent_name ?? '—'}</td>
<td>{(group.students ?? []).map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()).join(', ') || '—'}</td>
<td>{contact.name ?? '—'}</td>
<td>{contact.relation ?? '—'}</td>
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
<td className="d-flex gap-2">
<Link className="btn btn-sm btn-primary" to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}>Edit</Link>
<button className="btn btn-sm btn-danger" type="button" onClick={() => void removeContact(contact.id, group.parent_id)}>Delete</button>
</td>
</tr>
)),
)}
{groups.length === 0 ? <tr><td colSpan={6} className="text-muted">No emergency contacts found.</td></tr> : null}
</tbody>
</table>
</div>
</PageShell>
)
}
export function AdminEmergencyContactEditPage() {
const { contactId } = useParams()
const [searchParams] = useSearchParams()
const parentId = Number(searchParams.get('parent_id') ?? 0)
const [form, setForm] = useState({ name: '', cellphone: '', email: '', relation: '' })
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
if (!contactId || !parentId) return
;(async () => {
try {
const data = await fetchAdministratorEmergencyContact(Number(contactId), parentId)
setForm({
name: data.contact.name ?? '',
cellphone: data.contact.cellphone ?? '',
email: data.contact.email ?? '',
relation: data.contact.relation ?? '',
})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load emergency contact.')
}
})()
}, [contactId, parentId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!contactId || !parentId) return
try {
await updateAdministratorEmergencyContact(Number(contactId), {
parent_id: parentId,
name: form.name,
cellphone: form.cellphone,
email: form.email || null,
relation: form.relation || null,
})
setMessage('Emergency contact updated.')
setError(null)
navigate('/app/administrator/emergency-contacts')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to update emergency contact.')
}
}
return (
<PageShell title="Edit Emergency Contact">
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="card shadow-sm" style={{ maxWidth: 720 }}>
<div className="card-body">
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3"><label className="form-label">Name</label><input className="form-control" value={form.name} onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))} required /></div>
<div className="mb-3"><label className="form-label">Phone</label><input className="form-control" value={form.cellphone} onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))} required /></div>
<div className="mb-3"><label className="form-label">Email</label><input type="email" className="form-control" value={form.email} onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))} /></div>
<div className="mb-3"><label className="form-label">Relation</label><input className="form-control" value={form.relation} onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))} /></div>
<div className="d-flex gap-2">
<button className="btn btn-primary" type="submit">Update</button>
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">Cancel</Link>
</div>
</form>
</div>
</div>
</PageShell>
)
}
File diff suppressed because it is too large Load Diff
+188 -69
View File
@@ -1,29 +1,39 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session'
import { useEffect, useState, type FormEvent } from 'react'
import {
fetchAdministratorDashboardMetrics,
searchAdministratorDashboard,
} from '../api/session'
import { useAuth } from '../auth/AuthProvider'
import type {
AdministratorDashboardMetricsResponse,
AdministratorDashboardSearchResponse,
} from '../api/types'
export function AppHome() {
const { user } = useAuth()
const [route, setRoute] = useState<string | null>(null)
const [roles, setRoles] = useState<string[]>([])
const [metrics, setMetrics] = useState<AdministratorDashboardMetricsResponse | null>(null)
const [query, setQuery] = useState('')
const [search, setSearch] = useState<AdministratorDashboardSearchResponse | null>(null)
const [loadingMetrics, setLoadingMetrics] = useState(true)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoadingMetrics(true)
try {
const res = await fetchDashboardRoute()
const d = res.data?.dashboard
if (!cancelled && d) {
setRoute(d.route)
setRoles(d.roles ?? [])
const data = await fetchAdministratorDashboardMetrics()
if (!cancelled) {
setMetrics(data)
setError(null)
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
}
} finally {
if (!cancelled) setLoadingMetrics(false)
}
})()
return () => {
@@ -31,74 +41,183 @@ export function AppHome() {
}
}, [])
const roleKeys = user?.roles ? Object.keys(user.roles).filter((k) => user.roles[k]) : []
async function onSearch(event: FormEvent) {
event.preventDefault()
setSearching(true)
setError(null)
try {
const results = await searchAdministratorDashboard(query)
setSearch(results)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to search dashboard records.')
} finally {
setSearching(false)
}
}
const counts = metrics?.counts ?? {}
const statCards = [
{ key: 'students', title: 'Students', icon: 'bi bi-mortarboard', value: counts.students ?? 0, tone: 'primary' },
{ key: 'teachers', title: 'Teachers', icon: 'bi bi-person-video3', value: counts.teachers ?? 0, tone: 'success' },
{ key: 'teacherAssistants', title: 'Teacher Assistants', icon: 'bi bi-people', value: counts.teacherAssistants ?? 0, tone: 'warning' },
{ key: 'admins', title: 'Admins', icon: 'bi bi-person-badge', value: counts.admins ?? 0, tone: 'info' },
{ key: 'parents', title: 'Parents', icon: 'bi bi-people-fill', value: counts.parents ?? 0, tone: 'light' },
] as const
return (
<div>
<h2 className="h4 mb-3">Welcome</h2>
<p className="lead mb-4">
Signed in as <strong>{user?.name}</strong>.
</p>
<div className="container-fluid admin-dashboard">
<section className="mb-4">
<h2>Welcome, {user?.name || 'Administrator'}</h2>
<p>Here is an overview of your site's activity.</p>
</section>
<div className="row g-3 mb-4">
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Roles (from login)</h3>
<section className="mb-5">
<h2 className="mb-3">Search</h2>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="mb-3" onSubmit={onSearch}>
<div className="input-group">
<input
type="text"
className="form-control"
placeholder="Search name, email, phone, school ID, badge code..."
value={query}
onChange={(event) => setQuery(event.target.value)}
required
/>
<button className="btn btn-outline-secondary" type="submit" disabled={searching}>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
<div className="form-text">Supports Users, Parents, Staff, Students, and Emergency Contacts.</div>
</form>
{search?.query ? (
<div className="mb-2 small text-muted">
Showing results for: <strong>{search.query}</strong>
</div>
) : null}
{search && (
<div className="row g-3">
<SearchTable title="Users" icon="bi bi-people" columns={['Name', 'Email', 'Phone', 'School ID']} rows={search.results.users ?? []} renderRow={(row) => [
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.email),
stringValue(row.cellphone),
stringValue(row.school_id),
]} />
<SearchTable title="Students" icon="bi bi-mortarboard" columns={['School ID', 'Name', 'Gender', 'DOB']} rows={search.results.students ?? []} renderRow={(row) => [
stringValue(row.school_id),
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.gender),
stringValue(row.dob),
]} />
<SearchTable title="Parents" icon="bi bi-people-fill" columns={['First Parent ID', 'Second Parent', 'Email', 'Phone']} rows={search.results.parents ?? []} renderRow={(row) => [
stringValue(row.firstparent_id),
`${stringValue(row.secondparent_firstname)} ${stringValue(row.secondparent_lastname)}`.trim() || '',
stringValue(row.secondparent_email),
stringValue(row.secondparent_phone),
]} />
<SearchTable title="Staff" icon="bi bi-person-badge" columns={['Role', 'Name', 'Email', 'Phone', 'Status']} rows={search.results.staff ?? []} renderRow={(row) => [
stringValue(row.role_name),
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.email),
stringValue(row.phone),
stringValue(row.active_role),
]} />
<SearchTable title="Emergency Contacts" icon="bi bi-telephone-outbound" columns={['Name', 'Relation', 'Phone', 'Email']} rows={search.results.emergency_contacts ?? []} renderRow={(row) => [
stringValue(row.emergency_contact_name),
stringValue(row.relation),
stringValue(row.cellphone),
stringValue(row.email),
]} fullWidth />
</div>
)}
</section>
<section className="mb-5">
<h2 className="mb-4 text-center">Statistics</h2>
{loadingMetrics ? (
<p className="text-muted text-center">Loading...</p>
) : (
<div className="stats-row">
{statCards.map((item) => (
<div key={item.key} className={`stat-circle circle-${item.tone}`}>
<div className="stat-icon"><i className={item.icon} aria-hidden /></div>
<div className="stat-value">{item.value}</div>
<div className="stat-title">{item.title}</div>
</div>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3">Recent Activity</h2>
<div className="card border-0 shadow-sm">
<div className="card-body">
{(metrics?.recentActivities ?? []).length === 0 ? (
<p className="text-muted mb-0">No recent login activity.</p>
) : (
<ul className="mb-0">
{roleKeys.length ? (
roleKeys.map((r) => <li key={r}>{r}</li>)
) : (
<li className="text-muted">None reported</li>
)}
{(metrics?.recentActivities ?? []).map((item, index) => (
<li key={`${item.email ?? 'activity'}-${index}`}>
{item.email ?? 'Unknown user'} <span className="text-muted">· {item.login_time ?? ''}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Dashboard route (API)</h3>
{error ? (
<p className="text-danger small mb-0">{error}</p>
) : (
<>
<p className="mb-1">
<code>{route ?? '—'}</code>
</p>
<p className="small text-muted mb-0">
Resolved roles: {roles.length ? roles.join(', ') : '—'}
</p>
</>
)}
</div>
</div>
</div>
</div>
</section>
</div>
)
}
<div className="border rounded p-3 bg-white">
<h3 className="h6">Shortcuts</h3>
<ul className="mb-0">
<li>
<Link to="/app/parent/home">Parent Dashboard</Link> (parity with
CodeIgniter <code>parent/parent_dashboard</code>)
</li>
<li>
<Link to="/app/parent/attendance">Parent Attendance</Link> (parity
with CodeIgniter <code>Views/parent/attendance.php</code>)
</li>
<li>
<Link to="/app/parent/home">Parent All pages</Link> (navigation for
every view under <code>Views/parent/</code>)
</li>
<li>
<Link to="/docs">Public API docs (Swagger)</Link>
</li>
<li>
<code>app/Views</code> screen as an <code>/app/</code> parity route
</li>
</ul>
function SearchTable({
title,
icon,
columns,
rows,
renderRow,
fullWidth = false,
}: {
title: string
icon: string
columns: string[]
rows: Record<string, unknown>[]
renderRow: (row: Record<string, unknown>) => string[]
fullWidth?: boolean
}) {
return (
<div className={`col-12 ${fullWidth ? '' : 'col-lg-6'}`}>
<div className="card">
<div className="card-header"><i className={`${icon} me-1`} aria-hidden />{title}</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-sm mb-0">
<thead>
<tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={columns.length} className="text-muted">No {title.toLowerCase()} found.</td></tr>
) : (
rows.map((row, index) => (
<tr key={String(row.id ?? `${title}-${index}`)}>
{renderRow(row).map((cell, cellIndex) => <td key={`${index}-${cellIndex}`}>{cell || ''}</td>)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
function stringValue(value: unknown) {
return value == null || value === '' ? '' : String(value)
}
+445
View File
@@ -0,0 +1,445 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import {
fetchCompetitionScoresDetail,
fetchCompetitionScoresIndex,
fetchPublishedCompetition,
fetchPublishedCompetitions,
saveCompetitionScores,
} from '../api/session'
import type {
CompetitionScoreStudentRow,
CompetitionScoresEditResponse,
CompetitionScoresIndexCompetitionRow,
PublicCompetitionRow,
PublicCompetitionWinnerRow,
} from '../api/types'
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="container-fluid py-3">
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
{children}
</div>
)
}
function formatDate(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
function competitionLabel(row?: PublicCompetitionRow | null) {
return row?.title ?? `Competition #${row?.id ?? ''}`
}
function noteCard(children: React.ReactNode) {
return <div className="alert alert-warning">{children}</div>
}
export function CompetitionWinnersIndexPage() {
const [rows, setRows] = useState<PublicCompetitionRow[]>([])
const [teacherRows, setTeacherRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [activeClassName, setActiveClassName] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
try {
const [published, teacher] = await Promise.allSettled([
fetchPublishedCompetitions(),
fetchCompetitionScoresIndex(),
])
if (published.status === 'fulfilled') {
setRows(published.value.data?.competitions ?? [])
}
if (teacher.status === 'fulfilled') {
setTeacherRows(teacher.value.competitions ?? [])
setActiveClassName(teacher.value.activeClassName ?? null)
}
if (published.status === 'rejected' && teacher.status === 'rejected') {
setError('Unable to load competition winners data.')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
}
})()
}, [])
return (
<PageShell title="Competition Winners">
{noteCard(
<>
The current API exposes published winners data and teacher score entry. I could not find routed admin APIs for
create, settings, publish, lock, unlock, export quiz, or recognition PDF.
</>,
)}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-4">
<div className="card-header">Published Competitions</div>
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>ID</th>
<th>Title</th>
<th>Semester</th>
<th>School Year</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={6} className="text-muted">No published competitions found.</td></tr>
) : rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{competitionLabel(row)}</td>
<td>{row.semester ?? '—'}</td>
<td>{row.school_year ?? '—'}</td>
<td>{formatDate(row.published_at)}</td>
<td className="d-flex gap-2 flex-wrap">
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/preview`}>Preview</Link>
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/winners`}>Winners</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header">Teacher Score Entry Competitions{activeClassName ? ` - ${activeClassName}` : ''}</div>
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>ID</th>
<th>Title</th>
<th>Published</th>
<th>Locked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{teacherRows.length === 0 ? (
<tr><td colSpan={5} className="text-muted">No teacher competition score entries available for this account.</td></tr>
) : teacherRows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.title ?? `Competition #${row.id}`}</td>
<td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{row.is_locked ? 'Yes' : 'No'}</td>
<td>
<Link className="btn btn-sm btn-outline-primary" to={`/app/admin/competition-winners/${row.id}`}>Enter Scores</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</PageShell>
)
}
export function CompetitionWinnerFormPage() {
const { id } = useParams()
return (
<PageShell title={id ? 'Edit Competition' : 'Create Competition'}>
{noteCard(
<>
This screen depends on admin competition management endpoints that are not currently routed in the Laravel API.
The SPA can show published winners and teacher score entry, but it cannot safely create or update competition settings
without those endpoints.
</>,
)}
<div className="card shadow-sm">
<div className="card-body">
<div className="row g-3">
<div className="col-md-8"><label className="form-label">Title</label><input className="form-control" disabled value="" /></div>
<div className="col-md-4"><label className="form-label">Class Section</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">Semester</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">School Year</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">Start Date</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">End Date</label><input className="form-control" disabled value="" /></div>
</div>
</div>
</div>
</PageShell>
)
}
export function CompetitionWinnerScoresPage() {
const { id } = useParams()
const competitionId = Number(id)
const [searchParams, setSearchParams] = useSearchParams()
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null)
const [scores, setScores] = useState<Record<string, number | string>>({})
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function load(classSectionId = selectedClassId) {
try {
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId)
setData(response)
setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition scores.')
}
}
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
void load()
}, [competitionId, selectedClassId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!competitionId) return
try {
const result = await saveCompetitionScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId,
scores,
})
setMessage(result.message ?? 'Scores saved.')
if (result.ok) {
await load()
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to save scores.')
}
}
return (
<PageShell title="Enter Scores">
{noteCard(
<>
This page uses the teacher-facing `competition-scores` API. It works only when the logged-in account is assigned to the
target class section.
</>,
)}
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
{data?.competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(data.competition)}</div>
<div><strong>Class Section:</strong> {data.classSectionName ?? '—'}</div>
<div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong> {data.questionCount ?? '—'}
</div>
</div>
<form onSubmit={(e) => void onSubmit(e)}>
{data.classSelectionLocked === false ? (
<div className="mb-3" style={{ maxWidth: 360 }}>
<label className="form-label">Class Section ID</label>
<input
className="form-control"
value={String(selectedClassId ?? data.classSectionId ?? '')}
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})}
/>
</div>
) : null}
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle">
<thead className="table-light">
<tr>
<th>School ID</th>
<th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th>
</tr>
</thead>
<tbody>
{(data.students ?? []).length === 0 ? (
<tr><td colSpan={3} className="text-muted">No students loaded for this competition.</td></tr>
) : (data.students ?? []).map((student: CompetitionScoreStudentRow) => {
const studentId = Number(student.id ?? student.student_id ?? 0)
const name = `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() || `Student #${studentId}`
return (
<tr key={studentId}>
<td>{student.school_id ?? studentId}</td>
<td>{name}</td>
<td>
<input
className="form-control form-control-sm"
type="number"
step="1"
min="0"
max={data.questionCount ?? undefined}
disabled={Boolean(data.isLocked)}
value={String(scores[String(studentId)] ?? '')}
onChange={(e) => setScores((current) => ({ ...current, [String(studentId)]: e.target.value }))}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="d-flex gap-2">
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}>Save Scores</button>
<Link className="btn btn-outline-secondary" to={`/app/admin/competition-winners/${competitionId}/preview`}>Preview</Link>
</div>
</form>
</>
) : (
<p className="text-muted mb-0">No competition data loaded.</p>
)}
</PageShell>
)
}
function usePublishedCompetition(id: number) {
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [questionCounts, setQuestionCounts] = useState<Record<string, number>>({})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
;(async () => {
try {
const response = await fetchPublishedCompetition(id)
if (!response.status || !response.data) {
setError(response.message ?? 'Competition not found.')
return
}
setCompetition(response.data.competition)
setWinnersByClass(response.data.winners_by_class ?? {})
setSectionMap(response.data.section_map ?? {})
setQuestionCounts(response.data.question_counts ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [id])
return { competition, winnersByClass, sectionMap, questionCounts, error }
}
export function CompetitionWinnerPreviewPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId)
return (
<PageShell title="Preview Winners">
{noteCard('This preview is backed by the public published-winners API. Unpublished preview data is not exposed by the current backend.')}
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(competition)}</div>
<div><strong>School Year:</strong> {competition.school_year ?? '—'}</div>
</div>
{Object.keys(winnersByClass).length === 0 ? (
<div className="alert alert-warning">No published winners found for this competition.</div>
) : Object.entries(winnersByClass).map(([classId, rows]) => (
<div key={classId} className="mb-4">
<h5 className="mt-4">Top Winners - {sectionMap[classId] ?? `Class ${classId}`}</h5>
<table className="table table-bordered table-sm">
<thead className="table-light">
<tr><th>Rank</th><th>Student</th><th>Score</th><th>Prize</th></tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${classId}-${index}`}>
<td>{row.rank ?? index + 1}</td>
<td>{row.name ?? (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `Student #${row.student_id ?? ''}`)}</td>
<td>{row.score ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</>
) : null}
</PageShell>
)
}
export function CompetitionWinnerWinnersPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } = usePublishedCompetition(competitionId)
const flatRows = useMemo(
() =>
Object.entries(winnersByClass).flatMap(([classId, rows]) =>
rows.map((row) => ({ ...row, _classId: classId })),
),
[winnersByClass],
)
const totalPrize = flatRows.reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0)
return (
<PageShell title="School Winners">
{noteCard('This page uses the public published-winners API. The admin-only recognition PDF endpoint is not currently exposed in the Laravel API.')}
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(competition)}</div>
<div><strong>School Year:</strong> {competition.school_year ?? '—'}</div>
<div><strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'}</div>
</div>
{flatRows.length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div>
) : (
<div className="table-responsive">
<table className="table table-bordered table-sm">
<thead className="table-light">
<tr>
<th>Class</th>
<th>Student</th>
<th>Rank</th>
<th>Score</th>
<th>Question Count</th>
<th>Prize</th>
</tr>
</thead>
<tbody>
{flatRows.map((row, index) => (
<tr key={`${row._classId}-${index}`}>
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td>
<td>{row.name ?? (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `Student #${row.student_id ?? ''}`)}</td>
<td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={5} className="text-end">Total Prizes</th>
<th>{totalPrize.toFixed(2)}</th>
</tr>
</tfoot>
</table>
</div>
)}
</>
) : null}
</PageShell>
)
}
+25 -26
View File
@@ -8,22 +8,19 @@ export function RoleSelectPage() {
const { user, roles, selectedRole, setSelectedRole } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
const [busyRole, setBusyRole] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
async function continueLogin() {
if (!pickedRole) {
setError('Please select a role to continue.')
return
}
async function continueLogin(nextRole: string) {
setBusy(true)
setBusyRole(nextRole)
setError(null)
setSelectedRole(pickedRole)
setSelectedRole(nextRole)
let target = from.startsWith('/app') ? from : '/app/home'
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
if (nextRole.toLowerCase() === 'parent' && target === '/app/home') {
target = '/app/parent/home'
}
try {
@@ -39,41 +36,43 @@ export function RoleSelectPage() {
}
return (
<div className="container py-4">
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
<div className="card-body p-4">
<h2 className="h4 mb-2">Select role</h2>
<p className="text-muted mb-3">
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
to continue.
<div className="container mt-5">
<div className="card shadow-sm border-0 mx-auto" style={{ maxWidth: 720 }}>
<div className="card-header bg-primary text-white">
<h4 className="mb-0">Select Account Type</h4>
</div>
<div className="card-body">
<p className="mb-4">
{user?.name ? `${user.name}, y` : 'Y'}ou have multiple roles. Please select the account
you&apos;d like to log into:
</p>
<div className="list-group mb-3">
<div className="d-flex flex-wrap gap-3">
{roles.map((role) => (
<button
key={role}
type="button"
className={`list-group-item list-group-item-action text-start ${
pickedRole === role ? 'active' : ''
className={`btn ${
selectedRole === role ? 'btn-primary' : 'btn-outline-primary'
}`}
onClick={() => setPickedRole(role)}
onClick={() => {
void continueLogin(role)
}}
disabled={busy}
>
{role}
{busy && busyRole === role
? 'Opening...'
: role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())}
</button>
))}
</div>
{error ? (
<div className="alert alert-danger py-2" role="alert">
<div className="alert alert-danger py-2 mt-3" role="alert">
{error}
</div>
) : null}
<div className="d-grid">
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
{busy ? 'Continuing…' : 'Continue'}
</button>
</div>
</div>
</div>
</div>