page fixes

This commit is contained in:
root
2026-06-11 03:22:22 -04:00
parent fe1c50878b
commit e8d1b8c1c7
7 changed files with 798 additions and 235 deletions
@@ -1,62 +1,109 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchCompetitionScoresIndex, fetchPublishedCompetitions } from '../../api/session'
import type { CompetitionScoresIndexCompetitionRow, PublicCompetitionRow } from '../../api/types'
import {
exportAdminCompetitionWinnerQuiz,
fetchAdminCompetitionWinnersIndex,
lockAdminCompetitionWinner,
unlockAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminCompetitionRow } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
/** CI `admin/competition_winners/index.php` */
function toFiniteNumber(value: unknown): number | null {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
function competitionId(row: CompetitionWinnerAdminCompetitionRow): number | null {
return toFiniteNumber(row.id ?? row.competition_id)
}
export function CompetitionWinnersIndexPage() {
const [publishedRows, setPublishedRows] = useState<PublicCompetitionRow[]>([])
const [adminRows, setAdminRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [rows, setRows] = useState<CompetitionWinnerAdminCompetitionRow[]>([])
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [activeClassName, setActiveClassName] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
async function load() {
try {
const response = await fetchAdminCompetitionWinnersIndex()
if (!response.ok) {
setError(response.message ?? 'Unable to load competition winners data.')
return
}
setRows(response.competitions ?? [])
setSectionMap(response.sectionMap ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
}
}
useEffect(() => {
;(async () => {
try {
const [published, teacher] = await Promise.allSettled([
fetchPublishedCompetitions(),
fetchCompetitionScoresIndex(),
])
if (published.status === 'fulfilled') {
setPublishedRows(published.value.data?.competitions ?? [])
}
if (teacher.status === 'fulfilled') {
setAdminRows(teacher.value.competitions ?? [])
setSectionMap(teacher.value.sectionMap ?? {})
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.')
}
})()
void load()
}, [])
const tableRows = useMemo(() => {
if (adminRows.length > 0) return adminRows
return publishedRows.map((r) => ({
id: r.id,
title: r.title,
class_section_id: r.class_section_id,
is_locked: r.is_locked,
is_published: r.is_published,
})) as CompetitionScoresIndexCompetitionRow[]
}, [adminRows, publishedRows])
async function handleExport(row: CompetitionWinnerAdminCompetitionRow) {
const id = competitionId(row)
if (id === null) {
setError('This competition does not have a valid id yet.')
return
}
try {
setBusyAction(`export:${id}`)
setMessage(null)
const result = await exportAdminCompetitionWinnerQuiz(id, {
class_section_id: toFiniteNumber(row.class_section_id),
})
if (!result.ok) {
setError(result.message ?? 'Unable to export competition scores to quiz.')
return
}
setMessage(result.message ?? 'Competition scores exported to quiz.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to export competition scores to quiz.')
} finally {
setBusyAction(null)
}
}
async function handleLockToggle(row: CompetitionWinnerAdminCompetitionRow) {
const id = competitionId(row)
if (id === null) {
setError('This competition does not have a valid id yet.')
return
}
try {
setBusyAction(`lock:${id}`)
setMessage(null)
const result = row.is_locked
? await unlockAdminCompetitionWinner(id)
: await lockAdminCompetitionWinner(id)
if (!result.ok) {
setError(result.message ?? 'Unable to update competition lock state.')
return
}
setMessage(result.message ?? (row.is_locked ? 'Competition unlocked.' : 'Competition locked.'))
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to update competition lock state.')
} finally {
setBusyAction(null)
}
}
return (
<CompetitionPageShell title="Competition Winners">
<ApiScopeNote>
Lock/unlock, export quiz, and competition CRUD match CI once Laravel exposes those admin routes. Score entry and
published winners work with the current API.
Admin competition management now uses dedicated Laravel endpoints. Draft competitions keep working here, and preview
is no longer limited to published rows.
</ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div />
@@ -79,63 +126,75 @@ export function CompetitionWinnersIndexPage() {
</tr>
</thead>
<tbody>
{tableRows.length === 0 ? (
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No competitions found.
</td>
</tr>
) : (
tableRows.map((c) => {
const sectionId = Number(c.class_section_id ?? 0)
rows.map((row) => {
const id = competitionId(row)
const sectionId = toFiniteNumber(row.class_section_id) ?? 0
const sectionName =
sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All'
const isLocked = Boolean(c.is_locked)
const isPublished = Boolean(c.is_published)
const id = Number(c.id)
const title = String(row.title ?? (id !== null ? `Competition #${id}` : 'Competition'))
const exportBusy = busyAction === `export:${id}`
const lockBusy = busyAction === `lock:${id}`
return (
<tr key={id}>
<td>{id}</td>
<td>{String(c.title ?? `Competition #${id}`)}</td>
<tr key={id !== null ? String(id) : `${title}-${sectionId}`}>
<td>{id ?? '—'}</td>
<td>{title}</td>
<td>{sectionName}</td>
<td>Tiered per class</td>
<td>{isPublished ? 'Yes' : 'No'}</td>
<td>{isLocked ? 'Yes' : 'No'}</td>
<td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{row.is_locked ? 'Yes' : 'No'}</td>
<td>
<div className="d-flex flex-wrap gap-1">
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
>
Settings
</Link>
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
Enter Scores
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
>
Preview
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
>
Winners
</Link>
<button type="button" className="btn btn-sm btn-outline-success" disabled title="Requires API">
Export Scores to Quiz
</button>
{isLocked ? (
<button type="button" className="btn btn-sm btn-outline-warning" disabled title="Requires API">
Unlock
</button>
{id !== null ? (
<>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
>
Settings
</Link>
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
Enter Scores
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
>
Preview
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
>
Winners
</Link>
</>
) : (
<button type="button" className="btn btn-sm btn-outline-danger" disabled title="Requires API">
Lock
</button>
<span className="text-muted small me-2">Invalid competition id</span>
)}
<button
type="button"
className="btn btn-sm btn-outline-success"
disabled={id === null || exportBusy || lockBusy}
onClick={() => void handleExport(row)}
>
{exportBusy ? 'Exporting...' : 'Export Scores to Quiz'}
</button>
<button
type="button"
className={`btn btn-sm ${row.is_locked ? 'btn-outline-warning' : 'btn-outline-danger'}`}
disabled={id === null || exportBusy || lockBusy}
onClick={() => void handleLockToggle(row)}
>
{lockBusy ? 'Saving...' : row.is_locked ? 'Unlock' : 'Lock'}
</button>
</div>
</td>
</tr>
@@ -145,10 +204,6 @@ export function CompetitionWinnersIndexPage() {
</tbody>
</table>
</div>
{activeClassName ? (
<p className="text-muted small mt-3 mb-0">Active class context: {activeClassName}</p>
) : null}
</CompetitionPageShell>
)
}