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
+99
View File
@@ -59,6 +59,13 @@ import type {
ParentProfileRecord, ParentProfileRecord,
ContactSubmitResponse, ContactSubmitResponse,
CompetitionScoresEditResponse, CompetitionScoresEditResponse,
CompetitionWinnerAdminActionResponse,
CompetitionWinnerAdminFormResponse,
CompetitionWinnerAdminIndexResponse,
CompetitionWinnerAdminPreviewResponse,
CompetitionWinnerAdminScoresResponse,
CompetitionWinnerAdminWinnersResponse,
CompetitionWinnerExportQuizResponse,
CompetitionScoresIndexResponse, CompetitionScoresIndexResponse,
ExamDraftAdminResponse, ExamDraftAdminResponse,
PublicCompetitionIndexResponse, PublicCompetitionIndexResponse,
@@ -992,6 +999,98 @@ export async function fetchCompetitionScoresDetail(
return apiFetch<CompetitionScoresEditResponse>(`/api/v1/competition-scores/${id}${query}`) return apiFetch<CompetitionScoresEditResponse>(`/api/v1/competition-scores/${id}${query}`)
} }
export async function fetchAdminCompetitionWinnersIndex(): Promise<CompetitionWinnerAdminIndexResponse> {
return apiFetch<CompetitionWinnerAdminIndexResponse>('/api/v1/competition-winners')
}
export async function fetchAdminCompetitionWinnerCreate(): Promise<CompetitionWinnerAdminFormResponse> {
return apiFetch<CompetitionWinnerAdminFormResponse>('/api/v1/competition-winners/create')
}
export async function fetchAdminCompetitionWinnerSettings(id: number): Promise<CompetitionWinnerAdminFormResponse> {
return apiFetch<CompetitionWinnerAdminFormResponse>(`/api/v1/competition-winners/${id}/settings`)
}
export async function saveAdminCompetitionWinner(
payload: {
title: string
class_section_id?: number | null
semester?: string | null
school_year?: string | null
start_date?: string | null
end_date?: string | null
winner_overrides?: Record<string, number | string | null>
question_counts?: Record<string, number | string | null>
prizes?: Record<string, Record<string, number | string | null>>
},
id?: number,
): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(id ? `/api/v1/competition-winners/${id}` : '/api/v1/competition-winners', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchAdminCompetitionWinnerScores(
id: number,
classSectionId?: number | null,
): Promise<CompetitionWinnerAdminScoresResponse> {
const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : ''
return apiFetch<CompetitionWinnerAdminScoresResponse>(`/api/v1/competition-winners/${id}/scores${query}`)
}
export async function saveAdminCompetitionWinnerScores(
id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> },
): Promise<{ ok: boolean; message?: string; savedCount?: number }> {
return apiFetch<{ ok: boolean; message?: string; savedCount?: number }>(
`/api/v1/competition-winners/${id}/scores`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
}
export async function fetchAdminCompetitionWinnerPreview(id: number): Promise<CompetitionWinnerAdminPreviewResponse> {
return apiFetch<CompetitionWinnerAdminPreviewResponse>(`/api/v1/competition-winners/${id}/preview`)
}
export async function fetchAdminCompetitionWinnerWinners(id: number): Promise<CompetitionWinnerAdminWinnersResponse> {
return apiFetch<CompetitionWinnerAdminWinnersResponse>(`/api/v1/competition-winners/${id}/winners`)
}
export async function publishAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/publish`, {
method: 'POST',
})
}
export async function lockAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/lock`, {
method: 'POST',
})
}
export async function unlockAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/unlock`, {
method: 'POST',
})
}
export async function exportAdminCompetitionWinnerQuiz(
id: number,
payload?: { class_section_id?: number | null; semester?: string | null; school_year?: string | null },
): Promise<CompetitionWinnerExportQuizResponse> {
return apiFetch<CompetitionWinnerExportQuizResponse>(`/api/v1/competition-winners/${id}/export-quiz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
})
}
export async function saveCompetitionScores( export async function saveCompetitionScores(
id: number, id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> }, payload: { class_section_id?: number | null; scores: Record<string, number | string> },
+113
View File
@@ -1058,6 +1058,119 @@ export type CompetitionScoresIndexResponse = {
hasClasses?: boolean hasClasses?: boolean
} }
export type CompetitionWinnerAdminCompetitionRow = CompetitionScoresIndexCompetitionRow & {
semester?: string | null
school_year?: string | null
start_date?: string | null
end_date?: string | null
published_at?: string | null
locked_at?: string | null
}
export type CompetitionWinnerAdminIndexResponse = {
ok: boolean
competitions?: CompetitionWinnerAdminCompetitionRow[]
sectionMap?: Record<string, string>
message?: string
}
export type CompetitionWinnerAdminClassSectionRow = {
class_section_id?: number
class_section_name?: string | null
}
export type CompetitionWinnerAdminClassRow = {
class_section_id: number
class_section_name: string
student_count: number
auto_winners: number
override_winners?: number | null
final_winners: number
question_count?: number | null
prize_1?: number | null
prize_2?: number | null
prize_3?: number | null
prize_4?: number | null
prize_5?: number | null
prize_6?: number | null
}
export type CompetitionWinnerAdminFormResponse = {
ok: boolean
mode?: 'create' | 'edit'
competition?: PublicCompetitionRow | null
classSections?: CompetitionWinnerAdminClassSectionRow[]
classRows?: CompetitionWinnerAdminClassRow[]
defaultSemester?: string | null
defaultSchoolYear?: string | null
message?: string
}
export type CompetitionWinnerAdminActionResponse = {
ok: boolean
id?: number
message?: string
}
export type CompetitionWinnerAdminScoresResponse = {
ok: boolean
competition?: PublicCompetitionRow
students?: CompetitionScoreStudentRow[]
scoreMap?: Record<string, number | string>
classSections?: CompetitionWinnerAdminClassSectionRow[]
activeClassSectionId?: number
classSectionName?: string | null
classSelectionLocked?: boolean
classStudentCount?: number
classAutoWinners?: number
classOverrideWinners?: number | null
classFinalWinners?: number
classQuestionCount?: number | null
classPrizeMap?: Record<string, number | null>
message?: string
}
export type CompetitionWinnerPreviewStudentRow = {
student_id?: number
name?: string | null
score?: number | string | null
rank?: number
prize_amount?: number | string | null
}
export type CompetitionWinnerAdminPreviewResponse = {
ok: boolean
competition?: PublicCompetitionRow
classMap?: Record<string, string>
classCounts?: Record<string, number>
overrideMap?: Record<string, number>
questionCounts?: Record<string, number>
prizeMap?: Record<string, Record<string, number | null>>
winnersCountByClass?: Record<string, number>
rankedByClass?: Record<string, CompetitionWinnerPreviewStudentRow[]>
topByClass?: Record<string, CompetitionWinnerPreviewStudentRow[]>
message?: string
}
export type CompetitionWinnerAdminWinnerRow = PublicCompetitionWinnerRow & {
school_id?: string | null
photo_consent?: number | boolean | null
}
export type CompetitionWinnerAdminWinnersResponse = {
ok: boolean
competition?: PublicCompetitionRow
rows?: CompetitionWinnerAdminWinnerRow[]
sectionMap?: Record<string, string>
message?: string
}
export type CompetitionWinnerExportQuizResponse = {
ok: boolean
message?: string
quizIndexes?: Record<string, number>
}
export type CompetitionScoreStudentRow = { export type CompetitionScoreStudentRow = {
id?: number id?: number
student_id?: number student_id?: number
@@ -1,47 +1,255 @@
import { useParams } from 'react-router-dom' import { useEffect, useMemo, useState } from 'react'
import { ApiScopeNote, CompetitionPageShell } from './competitionWinnersShared' import { useNavigate, useParams } from 'react-router-dom'
import {
fetchAdminCompetitionWinnerCreate,
fetchAdminCompetitionWinnerSettings,
saveAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminClassRow, CompetitionWinnerAdminFormResponse } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
/** CI `admin/competition_winners/form.php` — layout parity; persist requires admin competition APIs. */ type CompetitionFormState = {
title: string
class_section_id: string
semester: string
school_year: string
start_date: string
end_date: string
}
function numberOrNull(value: string): number | null {
const trimmed = value.trim()
if (trimmed === '') return null
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
function initialFormState(payload: CompetitionWinnerAdminFormResponse): CompetitionFormState {
const competition = payload.competition
return {
title: String(competition?.title ?? ''),
class_section_id:
competition?.class_section_id === null || competition?.class_section_id === undefined
? ''
: String(competition.class_section_id),
semester: String(competition?.semester ?? payload.defaultSemester ?? ''),
school_year: String(competition?.school_year ?? payload.defaultSchoolYear ?? ''),
start_date: String(competition?.start_date ?? ''),
end_date: String(competition?.end_date ?? ''),
}
}
function normalizeRowValue(value: number | string | null | undefined): string {
return value === null || value === undefined ? '' : String(value)
}
/** CI `admin/competition_winners/form.php` */
export function CompetitionWinnerFormPage() { export function CompetitionWinnerFormPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const competitionId = Number(id)
const [form, setForm] = useState<CompetitionFormState>({
title: '',
class_section_id: '',
semester: '',
school_year: '',
start_date: '',
end_date: '',
})
const [classRows, setClassRows] = useState<CompetitionWinnerAdminClassRow[]>([])
const [classSections, setClassSections] = useState<CompetitionWinnerAdminFormResponse['classSections']>([])
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
try {
const response =
isEdit && Number.isFinite(competitionId) && competitionId > 0
? await fetchAdminCompetitionWinnerSettings(competitionId)
: await fetchAdminCompetitionWinnerCreate()
if (!response.ok) {
setError(response.message ?? 'Unable to load competition settings.')
return
}
setForm(initialFormState(response))
setClassRows(response.classRows ?? [])
setClassSections(response.classSections ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition settings.')
}
})()
}, [competitionId, isEdit])
const visibleRows = useMemo(() => {
const selectedClassId = numberOrNull(form.class_section_id)
if (selectedClassId === null) return classRows
return classRows.filter((row) => row.class_section_id === selectedClassId)
}, [classRows, form.class_section_id])
function updateClassRow(
classSectionId: number,
key:
| 'question_count'
| 'override_winners'
| 'prize_1'
| 'prize_2'
| 'prize_3'
| 'prize_4'
| 'prize_5'
| 'prize_6',
value: string,
) {
setClassRows((current) =>
current.map((row) => {
if (row.class_section_id !== classSectionId) return row
return {
...row,
[key]: value.trim() === '' ? null : Number(value),
}
}),
)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
try {
setSaving(true)
setMessage(null)
setError(null)
const winnerOverrides = Object.fromEntries(
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.override_winners)]),
)
const questionCounts = Object.fromEntries(
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.question_count)]),
)
const prizes = Object.fromEntries(
classRows.map((row) => [
String(row.class_section_id),
{
1: normalizeRowValue(row.prize_1),
2: normalizeRowValue(row.prize_2),
3: normalizeRowValue(row.prize_3),
4: normalizeRowValue(row.prize_4),
5: normalizeRowValue(row.prize_5),
6: normalizeRowValue(row.prize_6),
},
]),
)
const result = await saveAdminCompetitionWinner(
{
title: form.title,
class_section_id: numberOrNull(form.class_section_id),
semester: form.semester || null,
school_year: form.school_year || null,
start_date: form.start_date || null,
end_date: form.end_date || null,
winner_overrides: winnerOverrides,
question_counts: questionCounts,
prizes,
},
isEdit && Number.isFinite(competitionId) && competitionId > 0 ? competitionId : undefined,
)
if (!result.ok) {
setError(result.message ?? 'Unable to save competition.')
return
}
const nextId = result.id ?? competitionId
setMessage(result.message ?? 'Competition saved.')
if (Number.isFinite(nextId) && nextId > 0) {
navigate(`${COMPETITION_WINNERS_BASE}/${nextId}/settings`, { replace: true })
return
}
navigate(COMPETITION_WINNERS_BASE)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to save competition.')
} finally {
setSaving(false)
}
}
return ( return (
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}> <CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
<ApiScopeNote> <ApiScopeNote>
Saving competitions and per-class prize grids requires POST endpoints that mirror CI ( This screen now saves directly through the Laravel admin competition endpoints, including per-class winner overrides
<code>store</code>, <code>update</code>). Connect the API to enable the fields below. and prize grids.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="card shadow-sm"> <form className="card shadow-sm" onSubmit={(e) => void onSubmit(e)}>
<div className="card-body row g-3"> <div className="card-body row g-3">
<div className="col-md-8"> <div className="col-md-8">
<label className="form-label">Title *</label> <label className="form-label">Title *</label>
<input className="form-control" disabled placeholder="Competition title" /> <input
className="form-control"
value={form.title}
onChange={(e) => setForm((current) => ({ ...current, title: e.target.value }))}
required
/>
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label className="form-label">Class Section (optional)</label> <label className="form-label">Class Section (optional)</label>
<select className="form-select" disabled> <select
className="form-select"
value={form.class_section_id}
onChange={(e) => setForm((current) => ({ ...current, class_section_id: e.target.value }))}
>
<option value="">All classes</option> <option value="">All classes</option>
{(classSections ?? []).map((section) => (
<option key={String(section?.class_section_id ?? '')} value={String(section?.class_section_id ?? '')}>
{section?.class_section_name ?? section?.class_section_id}
</option>
))}
</select> </select>
<div className="form-text">Leave empty to manage winners for all classes.</div> <div className="form-text">Leave empty to manage winners for all classes.</div>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
<input className="form-control" disabled /> <input
className="form-control"
value={form.semester}
onChange={(e) => setForm((current) => ({ ...current, semester: e.target.value }))}
/>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input className="form-control" disabled placeholder="2025-2026" /> <input
className="form-control"
value={form.school_year}
onChange={(e) => setForm((current) => ({ ...current, school_year: e.target.value }))}
placeholder="2025-2026"
/>
<div className="form-text">Counts use the school year above.</div> <div className="form-text">Counts use the school year above.</div>
</div> </div>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">Start Date</label> <label className="form-label">Start Date</label>
<input className="form-control" type="date" disabled /> <input
className="form-control"
type="date"
value={form.start_date}
onChange={(e) => setForm((current) => ({ ...current, start_date: e.target.value }))}
/>
</div> </div>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">End Date</label> <label className="form-label">End Date</label>
<input className="form-control" type="date" disabled /> <input
className="form-control"
type="date"
value={form.end_date}
onChange={(e) => setForm((current) => ({ ...current, end_date: e.target.value }))}
/>
</div> </div>
<div className="col-12"> <div className="col-12">
@@ -65,23 +273,91 @@ export function CompetitionWinnerFormPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleRows.length === 0 ? (
<tr> <tr>
<td colSpan={12} className="text-muted small"> <td colSpan={12} className="text-muted small">
Class rows load from the backend when competition settings APIs are available. No class rows found for the selected scope.
</td> </td>
</tr> </tr>
) : (
visibleRows.map((row) => {
const finalWinners =
row.override_winners === null || row.override_winners === undefined
? row.auto_winners
: row.override_winners
return (
<tr key={row.class_section_id}>
<td>{row.class_section_name}</td>
<td>{row.student_count}</td>
<td>{row.auto_winners}</td>
<td>
<input
className="form-control form-control-sm"
type="number"
min="0"
value={normalizeRowValue(row.question_count)}
onChange={(e) => updateClassRow(row.class_section_id, 'question_count', e.target.value)}
/>
</td>
{[1, 2, 3, 4, 5, 6].map((rank) => (
<td key={rank}>
<input
className="form-control form-control-sm"
type="number"
min="0"
step="0.01"
value={normalizeRowValue(row[`prize_${rank}` as keyof CompetitionWinnerAdminClassRow] as number | null | undefined)}
onChange={(e) =>
updateClassRow(
row.class_section_id,
`prize_${rank}` as
| 'prize_1'
| 'prize_2'
| 'prize_3'
| 'prize_4'
| 'prize_5'
| 'prize_6',
e.target.value,
)
}
/>
</td>
))}
<td>
<input
className="form-control form-control-sm"
type="number"
min="0"
value={normalizeRowValue(row.override_winners)}
onChange={(e) => updateClassRow(row.class_section_id, 'override_winners', e.target.value)}
/>
</td>
<td>{finalWinners}</td>
</tr>
)
})
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div className="col-12"> <div className="col-12 d-flex gap-2">
<button type="button" className="btn btn-primary" disabled> <button type="submit" className="btn btn-primary" disabled={saving}>
Save {saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(COMPETITION_WINNERS_BASE)}
disabled={saving}
>
Back
</button> </button>
</div> </div>
</div> </div>
</div> </form>
</CompetitionPageShell> </CompetitionPageShell>
) )
} }
@@ -1,85 +1,101 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session' import { fetchAdminCompetitionWinnerPreview, publishAdminCompetitionWinner } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types' import type { CompetitionWinnerAdminPreviewResponse, CompetitionWinnerPreviewStudentRow } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared' import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
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 [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 ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [id])
return { competition, winnersByClass, sectionMap, error }
}
/** CI `admin/competition_winners/preview.php` */ /** CI `admin/competition_winners/preview.php` */
export function CompetitionWinnerPreviewPage() { export function CompetitionWinnerPreviewPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId) const [data, setData] = useState<CompetitionWinnerAdminPreviewResponse | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [publishing, setPublishing] = useState(false)
async function load() {
try {
const response = await fetchAdminCompetitionWinnerPreview(competitionId)
setData(response)
setError(response.ok ? null : response.message ?? 'Competition not found.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners preview.')
}
}
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
void load()
}, [competitionId])
async function handlePublish() {
try {
setPublishing(true)
setMessage(null)
const result = await publishAdminCompetitionWinner(competitionId)
if (!result.ok) {
setError(result.message ?? 'Unable to publish competition winners.')
return
}
setMessage(result.message ?? 'Competition winners published.')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to publish competition winners.')
} finally {
setPublishing(false)
}
}
return ( return (
<CompetitionPageShell title="Preview Winners"> <CompetitionPageShell title="Preview Winners">
<ApiScopeNote> <ApiScopeNote>
Preview reflects published winners from the public API. Unpublished drafts are not shown until the backend exposes an Preview is generated from the admin scoring data, so it works before a competition is publicly published.
admin preview.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? ( {message ? <div className="alert alert-success">{message}</div> : null}
{data?.competition ? (
<> <>
<div className="mb-3"> <div className="mb-3 d-flex flex-wrap justify-content-between align-items-start gap-2">
<div> <div>
<strong>Competition:</strong> {competitionLabel(competition)} <div>
<strong>Competition:</strong> {competitionLabel(data.competition)}
</div> </div>
<div> <div>
<strong>School Year:</strong> {competition.school_year ?? '—'} <strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div> </div>
</div> </div>
{Object.keys(winnersByClass).length === 0 ? ( <button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
<div className="alert alert-warning">No published winners found for this competition.</div> {publishing ? 'Publishing...' : 'Publish Winners'}
</button>
</div>
{Object.keys(data.topByClass ?? {}).length === 0 ? (
<div className="alert alert-warning">No ranked winners found for this competition yet.</div>
) : ( ) : (
Object.entries(winnersByClass).map(([classId, rows]) => ( Object.entries(data.topByClass ?? {}).map(([classId, rows]) => (
<div key={classId} className="mb-4"> <div key={classId} className="mb-4">
<h5 className="mt-4">Top Winners - {sectionMap[classId] ?? `Class ${classId}`}</h5> <h5 className="mt-4">
Top Winners - {data.classMap?.[classId] ?? `Class ${classId}`}
</h5>
<table className="table table-bordered table-sm"> <table className="table table-bordered table-sm">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
<th>Rank</th> <th>Rank</th>
<th>Student</th> <th>Student</th>
<th>Score</th> <th>Score</th>
<th>Question Count</th>
<th>Prize</th> <th>Prize</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => ( {(rows as CompetitionWinnerPreviewStudentRow[]).map((row, index) => (
<tr key={`${classId}-${index}`}> <tr key={`${classId}-${index}`}>
<td>{row.rank ?? index + 1}</td> <td>{row.rank ?? index + 1}</td>
<td> <td>{row.name ?? `Student #${row.student_id ?? ''}`}</td>
{row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)}
</td>
<td>{row.score ?? '—'}</td> <td>{row.score ?? '—'}</td>
<td>{data.questionCounts?.[classId] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td> <td>{row.prize_amount ?? '—'}</td>
</tr> </tr>
))} ))}
@@ -1,10 +1,10 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { import {
fetchCompetitionScoresDetail, fetchAdminCompetitionWinnerScores,
saveCompetitionScores, saveAdminCompetitionWinnerScores,
} from '../../api/session' } from '../../api/session'
import type { CompetitionScoresEditResponse, CompetitionScoreStudentRow } from '../../api/types' import type { CompetitionScoreStudentRow, CompetitionWinnerAdminScoresResponse } from '../../api/types'
import { import {
ApiScopeNote, ApiScopeNote,
CompetitionPageShell, CompetitionPageShell,
@@ -12,20 +12,26 @@ import {
competitionLabel, competitionLabel,
} from './competitionWinnersShared' } from './competitionWinnersShared'
/** CI `admin/competition_winners/scores.php` — enter scores (teacher competition API). */ function numberOrNull(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
/** CI `admin/competition_winners/scores.php` */
export function CompetitionWinnerScoresPage() { export function CompetitionWinnerScoresPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null const selectedClassId = numberOrNull(searchParams.get('class_section_id'))
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null) const [data, setData] = useState<CompetitionWinnerAdminScoresResponse | null>(null)
const [scores, setScores] = useState<Record<string, number | string>>({}) const [scores, setScores] = useState<Record<string, number | string>>({})
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function load(classSectionId = selectedClassId) { async function load(classSectionId = selectedClassId) {
try { try {
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId) const response = await fetchAdminCompetitionWinnerScores(competitionId, classSectionId)
setData(response) setData(response)
setScores(response.scoreMap ?? {}) setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.') setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
@@ -39,12 +45,23 @@ export function CompetitionWinnerScoresPage() {
void load() void load()
}, [competitionId, selectedClassId]) }, [competitionId, selectedClassId])
const classOptions = useMemo(
() =>
(data?.classSections ?? [])
.map((section) => ({
id: numberOrNull(section.class_section_id),
label: String(section.class_section_name ?? section.class_section_id ?? ''),
}))
.filter((section): section is { id: number; label: string } => section.id !== null),
[data?.classSections],
)
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (!competitionId) return if (!competitionId) return
try { try {
const result = await saveCompetitionScores(competitionId, { const result = await saveAdminCompetitionWinnerScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId, class_section_id: selectedClassId ?? data?.activeClassSectionId ?? null,
scores, scores,
}) })
setMessage(result.message ?? 'Scores saved.') setMessage(result.message ?? 'Scores saved.')
@@ -59,7 +76,8 @@ export function CompetitionWinnerScoresPage() {
return ( return (
<CompetitionPageShell title="Enter Scores"> <CompetitionPageShell title="Enter Scores">
<ApiScopeNote> <ApiScopeNote>
Uses the teacher-facing competition-scores API; your account must be assigned to the class section being scored. This admin score-entry page uses the dedicated Laravel competition-winners endpoints, so it works for administrator
workflows without relying on teacher class assignments.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null} {message ? <div className="alert alert-success">{message}</div> : null}
@@ -74,18 +92,27 @@ export function CompetitionWinnerScoresPage() {
</div> </div>
<div> <div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '} <strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '}
{data.questionCount ?? '—'} {data.classQuestionCount ?? '—'} | <strong>Final Winners:</strong> {data.classFinalWinners ?? '—'}
</div> </div>
</div> </div>
<form onSubmit={(e) => void onSubmit(e)}> <form onSubmit={(e) => void onSubmit(e)}>
{data.classSelectionLocked === false ? ( {data.classSelectionLocked === false ? (
<div className="mb-3" style={{ maxWidth: 360 }}> <div className="mb-3" style={{ maxWidth: 360 }}>
<label className="form-label">Class Section ID</label> <label className="form-label">Class Section</label>
<input <select
className="form-control" className="form-select"
value={String(selectedClassId ?? data.classSectionId ?? '')} value={String(selectedClassId ?? data.activeClassSectionId ?? '')}
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})} onChange={(e) =>
/> setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})
}
>
<option value="">Select a class</option>
{classOptions.map((section) => (
<option key={section.id} value={section.id}>
{section.label}
</option>
))}
</select>
</div> </div>
) : null} ) : null}
<div className="table-responsive"> <div className="table-responsive">
@@ -94,7 +121,7 @@ export function CompetitionWinnerScoresPage() {
<tr> <tr>
<th>School ID</th> <th>School ID</th>
<th>Student</th> <th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th> <th>Score{data.classQuestionCount ? ` (max ${data.classQuestionCount})` : ''}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -120,8 +147,8 @@ export function CompetitionWinnerScoresPage() {
type="number" type="number"
step="1" step="1"
min="0" min="0"
max={data.questionCount ?? undefined} max={data.classQuestionCount ?? undefined}
disabled={Boolean(data.isLocked)} disabled={Boolean(data.competition?.is_locked)}
value={String(scores[String(studentId)] ?? '')} value={String(scores[String(studentId)] ?? '')}
onChange={(e) => onChange={(e) =>
setScores((current) => ({ setScores((current) => ({
@@ -139,7 +166,7 @@ export function CompetitionWinnerScoresPage() {
</table> </table>
</div> </div>
<div className="d-flex gap-2 mt-3"> <div className="d-flex gap-2 mt-3">
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}> <button className="btn btn-primary" disabled={Boolean(data.competition?.is_locked)}>
Save Scores Save Scores
</button> </button>
<Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}> <Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}>
@@ -1,77 +1,54 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session' import { fetchAdminCompetitionWinnerWinners } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types' import type { CompetitionWinnerAdminWinnerRow, CompetitionWinnerAdminWinnersResponse } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared' import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
function usePublishedCompetitionFull(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 }
}
/** CI `admin/competition_winners/winners.php` */ /** CI `admin/competition_winners/winners.php` */
export function CompetitionWinnerWinnersPage() { export function CompetitionWinnerWinnersPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } = const [data, setData] = useState<CompetitionWinnerAdminWinnersResponse | null>(null)
usePublishedCompetitionFull(competitionId) const [error, setError] = useState<string | null>(null)
const flatRows = useMemo( useEffect(() => {
() => if (!Number.isFinite(competitionId) || competitionId <= 0) return
Object.entries(winnersByClass).flatMap(([classId, rows]) => ;(async () => {
rows.map((row) => ({ ...row, _classId: classId })), try {
), const response = await fetchAdminCompetitionWinnerWinners(competitionId)
[winnersByClass], setData(response)
setError(response.ok ? null : response.message ?? 'Competition not found.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [competitionId])
const totalPrize = useMemo(
() => (data?.rows ?? []).reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0),
[data?.rows],
) )
const totalPrize = flatRows.reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0)
return ( return (
<CompetitionPageShell title="School Winners"> <CompetitionPageShell title="School Winners">
<ApiScopeNote> <ApiScopeNote>
Prize totals and rankings come from the published winners API. Export to recognition PDF requires an admin endpoint if This listing comes from the admin winners table and reflects the last published winner set for the competition.
not yet in Laravel.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? ( {data?.competition ? (
<> <>
<div className="mb-3"> <div className="mb-3">
<div> <div>
<strong>Competition:</strong> {competitionLabel(competition)} <strong>Competition:</strong> {competitionLabel(data.competition)}
</div> </div>
<div> <div>
<strong>School Year:</strong> {competition.school_year ?? '—'} <strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div> </div>
<div> <div>
<strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'} <strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div> </div>
</div> </div>
{flatRows.length === 0 ? ( {(data.rows ?? []).length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div> <div className="alert alert-warning">No published winners yet.</div>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
@@ -80,24 +57,24 @@ export function CompetitionWinnerWinnersPage() {
<tr> <tr>
<th>Class</th> <th>Class</th>
<th>Student</th> <th>Student</th>
<th>School ID</th>
<th>Rank</th> <th>Rank</th>
<th>Score</th> <th>Score</th>
<th>Question Count</th>
<th>Prize</th> <th>Prize</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{flatRows.map((row, index) => ( {(data.rows ?? []).map((row: CompetitionWinnerAdminWinnerRow, index) => (
<tr key={`${row._classId}-${index}`}> <tr key={`${row.class_section_id ?? 'class'}-${row.student_id ?? index}-${index}`}>
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td> <td>{data.sectionMap?.[String(row.class_section_id ?? '')] ?? `Class ${row.class_section_id ?? ''}`}</td>
<td> <td>
{row.name ?? {row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)} `Student #${row.student_id ?? ''}`)}
</td> </td>
<td>{row.school_id ?? '—'}</td>
<td>{row.rank ?? '—'}</td> <td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td> <td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td> <td>{row.prize_amount ?? '—'}</td>
</tr> </tr>
))} ))}
@@ -1,62 +1,109 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { fetchCompetitionScoresIndex, fetchPublishedCompetitions } from '../../api/session' import {
import type { CompetitionScoresIndexCompetitionRow, PublicCompetitionRow } from '../../api/types' exportAdminCompetitionWinnerQuiz,
fetchAdminCompetitionWinnersIndex,
lockAdminCompetitionWinner,
unlockAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminCompetitionRow } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared' 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() { export function CompetitionWinnersIndexPage() {
const [publishedRows, setPublishedRows] = useState<PublicCompetitionRow[]>([]) const [rows, setRows] = useState<CompetitionWinnerAdminCompetitionRow[]>([])
const [adminRows, setAdminRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [sectionMap, setSectionMap] = useState<Record<string, string>>({}) 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 [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
useEffect(() => { async function load() {
;(async () => {
try { try {
const [published, teacher] = await Promise.allSettled([ const response = await fetchAdminCompetitionWinnersIndex()
fetchPublishedCompetitions(), if (!response.ok) {
fetchCompetitionScoresIndex(), setError(response.message ?? 'Unable to load competition winners data.')
]) return
if (published.status === 'fulfilled') {
setPublishedRows(published.value.data?.competitions ?? [])
} }
if (teacher.status === 'fulfilled') { setRows(response.competitions ?? [])
setAdminRows(teacher.value.competitions ?? []) setSectionMap(response.sectionMap ?? {})
setSectionMap(teacher.value.sectionMap ?? {}) setError(null)
setActiveClassName(teacher.value.activeClassName ?? null)
}
if (published.status === 'rejected' && teacher.status === 'rejected') {
setError('Unable to load competition winners data.')
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.') setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
} }
})() }
useEffect(() => {
void load()
}, []) }, [])
const tableRows = useMemo(() => { async function handleExport(row: CompetitionWinnerAdminCompetitionRow) {
if (adminRows.length > 0) return adminRows const id = competitionId(row)
return publishedRows.map((r) => ({ if (id === null) {
id: r.id, setError('This competition does not have a valid id yet.')
title: r.title, return
class_section_id: r.class_section_id, }
is_locked: r.is_locked,
is_published: r.is_published, try {
})) as CompetitionScoresIndexCompetitionRow[] setBusyAction(`export:${id}`)
}, [adminRows, publishedRows]) 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 ( return (
<CompetitionPageShell title="Competition Winners"> <CompetitionPageShell title="Competition Winners">
<ApiScopeNote> <ApiScopeNote>
Lock/unlock, export quiz, and competition CRUD match CI once Laravel exposes those admin routes. Score entry and Admin competition management now uses dedicated Laravel endpoints. Draft competitions keep working here, and preview
published winners work with the current API. is no longer limited to published rows.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {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 className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div /> <div />
@@ -79,30 +126,34 @@ export function CompetitionWinnersIndexPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tableRows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="text-muted"> <td colSpan={7} className="text-muted">
No competitions found. No competitions found.
</td> </td>
</tr> </tr>
) : ( ) : (
tableRows.map((c) => { rows.map((row) => {
const sectionId = Number(c.class_section_id ?? 0) const id = competitionId(row)
const sectionId = toFiniteNumber(row.class_section_id) ?? 0
const sectionName = const sectionName =
sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All' sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All'
const isLocked = Boolean(c.is_locked) const title = String(row.title ?? (id !== null ? `Competition #${id}` : 'Competition'))
const isPublished = Boolean(c.is_published) const exportBusy = busyAction === `export:${id}`
const id = Number(c.id) const lockBusy = busyAction === `lock:${id}`
return ( return (
<tr key={id}> <tr key={id !== null ? String(id) : `${title}-${sectionId}`}>
<td>{id}</td> <td>{id ?? '—'}</td>
<td>{String(c.title ?? `Competition #${id}`)}</td> <td>{title}</td>
<td>{sectionName}</td> <td>{sectionName}</td>
<td>Tiered per class</td> <td>Tiered per class</td>
<td>{isPublished ? 'Yes' : 'No'}</td> <td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{isLocked ? 'Yes' : 'No'}</td> <td>{row.is_locked ? 'Yes' : 'No'}</td>
<td> <td>
<div className="d-flex flex-wrap gap-1"> <div className="d-flex flex-wrap gap-1">
{id !== null ? (
<>
<Link <Link
className="btn btn-sm btn-outline-secondary" className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`} to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
@@ -124,18 +175,26 @@ export function CompetitionWinnersIndexPage() {
> >
Winners Winners
</Link> </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>
) : ( ) : (
<button type="button" className="btn btn-sm btn-outline-danger" disabled title="Requires API"> <span className="text-muted small me-2">Invalid competition id</span>
Lock
</button>
)} )}
<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> </div>
</td> </td>
</tr> </tr>
@@ -145,10 +204,6 @@ export function CompetitionWinnersIndexPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{activeClassName ? (
<p className="text-muted small mt-3 mb-0">Active class context: {activeClassName}</p>
) : null}
</CompetitionPageShell> </CompetitionPageShell>
) )
} }