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,
ContactSubmitResponse,
CompetitionScoresEditResponse,
CompetitionWinnerAdminActionResponse,
CompetitionWinnerAdminFormResponse,
CompetitionWinnerAdminIndexResponse,
CompetitionWinnerAdminPreviewResponse,
CompetitionWinnerAdminScoresResponse,
CompetitionWinnerAdminWinnersResponse,
CompetitionWinnerExportQuizResponse,
CompetitionScoresIndexResponse,
ExamDraftAdminResponse,
PublicCompetitionIndexResponse,
@@ -992,6 +999,98 @@ export async function fetchCompetitionScoresDetail(
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(
id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> },
+113
View File
@@ -1058,6 +1058,119 @@ export type CompetitionScoresIndexResponse = {
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 = {
id?: number
student_id?: number
@@ -1,47 +1,255 @@
import { useParams } from 'react-router-dom'
import { ApiScopeNote, CompetitionPageShell } from './competitionWinnersShared'
import { useEffect, useMemo, useState } from 'react'
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() {
const { id } = useParams()
const navigate = useNavigate()
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 (
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
<ApiScopeNote>
Saving competitions and per-class prize grids requires POST endpoints that mirror CI (
<code>store</code>, <code>update</code>). Connect the API to enable the fields below.
This screen now saves directly through the Laravel admin competition endpoints, including per-class winner overrides
and prize grids.
</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="col-md-8">
<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 className="col-md-6">
<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>
{(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>
<div className="form-text">Leave empty to manage winners for all classes.</div>
</div>
<div className="col-md-3">
<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 className="col-md-3">
<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>
<div className="col-md-4">
<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 className="col-md-4">
<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 className="col-12">
@@ -65,23 +273,91 @@ export function CompetitionWinnerFormPage() {
</tr>
</thead>
<tbody>
<tr>
<td colSpan={12} className="text-muted small">
Class rows load from the backend when competition settings APIs are available.
</td>
</tr>
{visibleRows.length === 0 ? (
<tr>
<td colSpan={12} className="text-muted small">
No class rows found for the selected scope.
</td>
</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>
</table>
</div>
</div>
<div className="col-12">
<button type="button" className="btn btn-primary" disabled>
Save
<div className="col-12 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(COMPETITION_WINNERS_BASE)}
disabled={saving}
>
Back
</button>
</div>
</div>
</div>
</form>
</CompetitionPageShell>
)
}
@@ -1,85 +1,101 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
import { fetchAdminCompetitionWinnerPreview, publishAdminCompetitionWinner } from '../../api/session'
import type { CompetitionWinnerAdminPreviewResponse, CompetitionWinnerPreviewStudentRow } from '../../api/types'
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` */
export function CompetitionWinnerPreviewPage() {
const { id } = useParams()
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 (
<CompetitionPageShell title="Preview Winners">
<ApiScopeNote>
Preview reflects published winners from the public API. Unpublished drafts are not shown until the backend exposes an
admin preview.
Preview is generated from the admin scoring data, so it works before a competition is publicly published.
</ApiScopeNote>
{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>
<strong>Competition:</strong> {competitionLabel(competition)}
</div>
<div>
<strong>School Year:</strong> {competition.school_year ?? '—'}
<div>
<strong>Competition:</strong> {competitionLabel(data.competition)}
</div>
<div>
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div>
</div>
<button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
{publishing ? 'Publishing...' : 'Publish Winners'}
</button>
</div>
{Object.keys(winnersByClass).length === 0 ? (
<div className="alert alert-warning">No published winners found for this competition.</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">
<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">
<thead className="table-light">
<tr>
<th>Rank</th>
<th>Student</th>
<th>Score</th>
<th>Question Count</th>
<th>Prize</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
{(rows as CompetitionWinnerPreviewStudentRow[]).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.name ?? `Student #${row.student_id ?? ''}`}</td>
<td>{row.score ?? '—'}</td>
<td>{data.questionCounts?.[classId] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</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 {
fetchCompetitionScoresDetail,
saveCompetitionScores,
fetchAdminCompetitionWinnerScores,
saveAdminCompetitionWinnerScores,
} from '../../api/session'
import type { CompetitionScoresEditResponse, CompetitionScoreStudentRow } from '../../api/types'
import type { CompetitionScoreStudentRow, CompetitionWinnerAdminScoresResponse } from '../../api/types'
import {
ApiScopeNote,
CompetitionPageShell,
@@ -12,20 +12,26 @@ import {
competitionLabel,
} 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() {
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 selectedClassId = numberOrNull(searchParams.get('class_section_id'))
const [data, setData] = useState<CompetitionWinnerAdminScoresResponse | 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)
const response = await fetchAdminCompetitionWinnerScores(competitionId, classSectionId)
setData(response)
setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
@@ -39,12 +45,23 @@ export function CompetitionWinnerScoresPage() {
void load()
}, [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) {
e.preventDefault()
if (!competitionId) return
try {
const result = await saveCompetitionScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId,
const result = await saveAdminCompetitionWinnerScores(competitionId, {
class_section_id: selectedClassId ?? data?.activeClassSectionId ?? null,
scores,
})
setMessage(result.message ?? 'Scores saved.')
@@ -59,7 +76,8 @@ export function CompetitionWinnerScoresPage() {
return (
<CompetitionPageShell title="Enter Scores">
<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>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
@@ -74,18 +92,27 @@ export function CompetitionWinnerScoresPage() {
</div>
<div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '}
{data.questionCount ?? '—'}
{data.classQuestionCount ?? '—'} | <strong>Final Winners:</strong> {data.classFinalWinners ?? '—'}
</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 } : {})}
/>
<label className="form-label">Class Section</label>
<select
className="form-select"
value={String(selectedClassId ?? data.activeClassSectionId ?? '')}
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>
) : null}
<div className="table-responsive">
@@ -94,7 +121,7 @@ export function CompetitionWinnerScoresPage() {
<tr>
<th>School ID</th>
<th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th>
<th>Score{data.classQuestionCount ? ` (max ${data.classQuestionCount})` : ''}</th>
</tr>
</thead>
<tbody>
@@ -120,8 +147,8 @@ export function CompetitionWinnerScoresPage() {
type="number"
step="1"
min="0"
max={data.questionCount ?? undefined}
disabled={Boolean(data.isLocked)}
max={data.classQuestionCount ?? undefined}
disabled={Boolean(data.competition?.is_locked)}
value={String(scores[String(studentId)] ?? '')}
onChange={(e) =>
setScores((current) => ({
@@ -139,7 +166,7 @@ export function CompetitionWinnerScoresPage() {
</table>
</div>
<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
</button>
<Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}>
@@ -1,77 +1,54 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
import { fetchAdminCompetitionWinnerWinners } from '../../api/session'
import type { CompetitionWinnerAdminWinnerRow, CompetitionWinnerAdminWinnersResponse } from '../../api/types'
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` */
export function CompetitionWinnerWinnersPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } =
usePublishedCompetitionFull(competitionId)
const [data, setData] = useState<CompetitionWinnerAdminWinnersResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const flatRows = useMemo(
() =>
Object.entries(winnersByClass).flatMap(([classId, rows]) =>
rows.map((row) => ({ ...row, _classId: classId })),
),
[winnersByClass],
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
;(async () => {
try {
const response = await fetchAdminCompetitionWinnerWinners(competitionId)
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 (
<CompetitionPageShell title="School Winners">
<ApiScopeNote>
Prize totals and rankings come from the published winners API. Export to recognition PDF requires an admin endpoint if
not yet in Laravel.
This listing comes from the admin winners table and reflects the last published winner set for the competition.
</ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
{data?.competition ? (
<>
<div className="mb-3">
<div>
<strong>Competition:</strong> {competitionLabel(competition)}
<strong>Competition:</strong> {competitionLabel(data.competition)}
</div>
<div>
<strong>School Year:</strong> {competition.school_year ?? '—'}
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'}
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div>
</div>
{flatRows.length === 0 ? (
{(data.rows ?? []).length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div>
) : (
<div className="table-responsive">
@@ -80,24 +57,24 @@ export function CompetitionWinnerWinnersPage() {
<tr>
<th>Class</th>
<th>Student</th>
<th>School ID</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>
{(data.rows ?? []).map((row: CompetitionWinnerAdminWinnerRow, index) => (
<tr key={`${row.class_section_id ?? 'class'}-${row.student_id ?? index}-${index}`}>
<td>{data.sectionMap?.[String(row.class_section_id ?? '')] ?? `Class ${row.class_section_id ?? ''}`}</td>
<td>
{row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)}
</td>
<td>{row.school_id ?? '—'}</td>
<td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
@@ -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>
)
}