page fixes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user