210 lines
7.9 KiB
TypeScript
210 lines
7.9 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
exportAdminCompetitionWinnerQuiz,
|
|
fetchAdminCompetitionWinnersIndex,
|
|
lockAdminCompetitionWinner,
|
|
unlockAdminCompetitionWinner,
|
|
} from '../../api/session'
|
|
import type { CompetitionWinnerAdminCompetitionRow } from '../../api/types'
|
|
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
|
|
|
|
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 [rows, setRows] = useState<CompetitionWinnerAdminCompetitionRow[]>([])
|
|
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
|
|
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(() => {
|
|
void load()
|
|
}, [])
|
|
|
|
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>
|
|
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 />
|
|
<Link className="btn btn-primary" to={`${COMPETITION_WINNERS_BASE}/create`}>
|
|
+ New Competition
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered table-sm align-middle mb-0" data-no-mgmt-sticky>
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Title</th>
|
|
<th>Class Section</th>
|
|
<th>Winners Rule</th>
|
|
<th>Published</th>
|
|
<th>Locked</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="text-muted">
|
|
No competitions found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
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 title = String(row.title ?? (id !== null ? `Competition #${id}` : 'Competition'))
|
|
const exportBusy = busyAction === `export:${id}`
|
|
const lockBusy = busyAction === `lock:${id}`
|
|
|
|
return (
|
|
<tr key={id !== null ? String(id) : `${title}-${sectionId}`}>
|
|
<td>{id ?? '—'}</td>
|
|
<td>{title}</td>
|
|
<td>{sectionName}</td>
|
|
<td>Tiered per class</td>
|
|
<td>{row.is_published ? 'Yes' : 'No'}</td>
|
|
<td>{row.is_locked ? 'Yes' : 'No'}</td>
|
|
<td>
|
|
<div className="d-flex flex-wrap gap-1">
|
|
{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>
|
|
</>
|
|
) : (
|
|
<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>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CompetitionPageShell>
|
|
)
|
|
}
|