From e8d1b8c1c7925f3b9335ac49f11fa756c5d19373 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Jun 2026 03:22:22 -0400 Subject: [PATCH] page fixes --- src/api/session.ts | 99 ++++++ src/api/types.ts | 113 +++++++ .../CompetitionWinnerFormPage.tsx | 318 ++++++++++++++++-- .../CompetitionWinnerPreviewPage.tsx | 114 ++++--- .../CompetitionWinnerScoresPage.tsx | 71 ++-- .../CompetitionWinnerWinnersPage.tsx | 85 ++--- .../CompetitionWinnersIndexPage.tsx | 233 ++++++++----- 7 files changed, 798 insertions(+), 235 deletions(-) diff --git a/src/api/session.ts b/src/api/session.ts index af59d7c..b2bd240 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -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(`/api/v1/competition-scores/${id}${query}`) } +export async function fetchAdminCompetitionWinnersIndex(): Promise { + return apiFetch('/api/v1/competition-winners') +} + +export async function fetchAdminCompetitionWinnerCreate(): Promise { + return apiFetch('/api/v1/competition-winners/create') +} + +export async function fetchAdminCompetitionWinnerSettings(id: number): Promise { + return apiFetch(`/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 + question_counts?: Record + prizes?: Record> + }, + id?: number, +): Promise { + return apiFetch(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 { + const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : '' + return apiFetch(`/api/v1/competition-winners/${id}/scores${query}`) +} + +export async function saveAdminCompetitionWinnerScores( + id: number, + payload: { class_section_id?: number | null; scores: Record }, +): 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 { + return apiFetch(`/api/v1/competition-winners/${id}/preview`) +} + +export async function fetchAdminCompetitionWinnerWinners(id: number): Promise { + return apiFetch(`/api/v1/competition-winners/${id}/winners`) +} + +export async function publishAdminCompetitionWinner(id: number): Promise { + return apiFetch(`/api/v1/competition-winners/${id}/publish`, { + method: 'POST', + }) +} + +export async function lockAdminCompetitionWinner(id: number): Promise { + return apiFetch(`/api/v1/competition-winners/${id}/lock`, { + method: 'POST', + }) +} + +export async function unlockAdminCompetitionWinner(id: number): Promise { + return apiFetch(`/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 { + return apiFetch(`/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 }, diff --git a/src/api/types.ts b/src/api/types.ts index 2a03cdb..36c8869 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 + 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 + classSections?: CompetitionWinnerAdminClassSectionRow[] + activeClassSectionId?: number + classSectionName?: string | null + classSelectionLocked?: boolean + classStudentCount?: number + classAutoWinners?: number + classOverrideWinners?: number | null + classFinalWinners?: number + classQuestionCount?: number | null + classPrizeMap?: Record + 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 + classCounts?: Record + overrideMap?: Record + questionCounts?: Record + prizeMap?: Record> + winnersCountByClass?: Record + rankedByClass?: Record + topByClass?: Record + 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 + message?: string +} + +export type CompetitionWinnerExportQuizResponse = { + ok: boolean + message?: string + quizIndexes?: Record +} + export type CompetitionScoreStudentRow = { id?: number student_id?: number diff --git a/src/pages/competitionWinners/CompetitionWinnerFormPage.tsx b/src/pages/competitionWinners/CompetitionWinnerFormPage.tsx index d3cbd2f..7bd47c2 100644 --- a/src/pages/competitionWinners/CompetitionWinnerFormPage.tsx +++ b/src/pages/competitionWinners/CompetitionWinnerFormPage.tsx @@ -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({ + title: '', + class_section_id: '', + semester: '', + school_year: '', + start_date: '', + end_date: '', + }) + const [classRows, setClassRows] = useState([]) + const [classSections, setClassSections] = useState([]) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState(null) + const [error, setError] = useState(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) { + 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 ( - Saving competitions and per-class prize grids requires POST endpoints that mirror CI ( - store, update). 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. + {error ?
{error}
: null} + {message ?
{message}
: null} -
+
void onSubmit(e)}>
- + setForm((current) => ({ ...current, title: e.target.value }))} + required + />
- setForm((current) => ({ ...current, class_section_id: e.target.value }))} + > + {(classSections ?? []).map((section) => ( + + ))}
Leave empty to manage winners for all classes.
- + setForm((current) => ({ ...current, semester: e.target.value }))} + />
- + setForm((current) => ({ ...current, school_year: e.target.value }))} + placeholder="2025-2026" + />
Counts use the school year above.
- + setForm((current) => ({ ...current, start_date: e.target.value }))} + />
- + setForm((current) => ({ ...current, end_date: e.target.value }))} + />
@@ -65,23 +273,91 @@ export function CompetitionWinnerFormPage() { - - - Class rows load from the backend when competition settings APIs are available. - - + {visibleRows.length === 0 ? ( + + + No class rows found for the selected scope. + + + ) : ( + visibleRows.map((row) => { + const finalWinners = + row.override_winners === null || row.override_winners === undefined + ? row.auto_winners + : row.override_winners + + return ( + + {row.class_section_name} + {row.student_count} + {row.auto_winners} + + updateClassRow(row.class_section_id, 'question_count', e.target.value)} + /> + + {[1, 2, 3, 4, 5, 6].map((rank) => ( + + + updateClassRow( + row.class_section_id, + `prize_${rank}` as + | 'prize_1' + | 'prize_2' + | 'prize_3' + | 'prize_4' + | 'prize_5' + | 'prize_6', + e.target.value, + ) + } + /> + + ))} + + updateClassRow(row.class_section_id, 'override_winners', e.target.value)} + /> + + {finalWinners} + + ) + }) + )}
-
- +
- +
) } diff --git a/src/pages/competitionWinners/CompetitionWinnerPreviewPage.tsx b/src/pages/competitionWinners/CompetitionWinnerPreviewPage.tsx index e7db864..3bdb9ee 100644 --- a/src/pages/competitionWinners/CompetitionWinnerPreviewPage.tsx +++ b/src/pages/competitionWinners/CompetitionWinnerPreviewPage.tsx @@ -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(null) - const [winnersByClass, setWinnersByClass] = useState>({}) - const [sectionMap, setSectionMap] = useState>({}) - const [error, setError] = useState(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(null) + const [message, setMessage] = useState(null) + const [error, setError] = useState(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 ( - 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. {error ?
{error}
: null} - {competition ? ( + {message ?
{message}
: null} + {data?.competition ? ( <> -
+
- Competition: {competitionLabel(competition)} -
-
- School Year: {competition.school_year ?? '—'} +
+ Competition: {competitionLabel(data.competition)} +
+
+ School Year: {data.competition.school_year ?? '—'} +
+
+ Published: {data.competition.is_published ? 'Yes' : 'No'} +
+
- {Object.keys(winnersByClass).length === 0 ? ( -
No published winners found for this competition.
+ {Object.keys(data.topByClass ?? {}).length === 0 ? ( +
No ranked winners found for this competition yet.
) : ( - Object.entries(winnersByClass).map(([classId, rows]) => ( + Object.entries(data.topByClass ?? {}).map(([classId, rows]) => (
-
Top Winners - {sectionMap[classId] ?? `Class ${classId}`}
+
+ Top Winners - {data.classMap?.[classId] ?? `Class ${classId}`} +
+ - {rows.map((row, index) => ( + {(rows as CompetitionWinnerPreviewStudentRow[]).map((row, index) => ( - + + ))} diff --git a/src/pages/competitionWinners/CompetitionWinnerScoresPage.tsx b/src/pages/competitionWinners/CompetitionWinnerScoresPage.tsx index c0c3428..ccaabbc 100644 --- a/src/pages/competitionWinners/CompetitionWinnerScoresPage.tsx +++ b/src/pages/competitionWinners/CompetitionWinnerScoresPage.tsx @@ -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(null) + const selectedClassId = numberOrNull(searchParams.get('class_section_id')) + const [data, setData] = useState(null) const [scores, setScores] = useState>({}) const [message, setMessage] = useState(null) const [error, setError] = useState(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 ( - 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. {error ?
{error}
: null} {message ?
{message}
: null} @@ -74,18 +92,27 @@ export function CompetitionWinnerScoresPage() {
Students: {data.classStudentCount ?? 0} | Questions:{' '} - {data.questionCount ?? '—'} + {data.classQuestionCount ?? '—'} | Final Winners: {data.classFinalWinners ?? '—'}
void onSubmit(e)}> {data.classSelectionLocked === false ? (
- - setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})} - /> + +
) : null}
@@ -94,7 +121,7 @@ export function CompetitionWinnerScoresPage() {
- + @@ -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() {
Rank Student ScoreQuestion Count Prize
{row.rank ?? index + 1} - {row.name ?? - (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || - `Student #${row.student_id ?? ''}`)} - {row.name ?? `Student #${row.student_id ?? ''}`} {row.score ?? '—'}{data.questionCounts?.[classId] ?? '—'} {row.prize_amount ?? '—'}
School ID StudentScore{data.questionCount ? ` (max ${data.questionCount})` : ''}Score{data.classQuestionCount ? ` (max ${data.classQuestionCount})` : ''}
- diff --git a/src/pages/competitionWinners/CompetitionWinnerWinnersPage.tsx b/src/pages/competitionWinners/CompetitionWinnerWinnersPage.tsx index a025f16..377d0fc 100644 --- a/src/pages/competitionWinners/CompetitionWinnerWinnersPage.tsx +++ b/src/pages/competitionWinners/CompetitionWinnerWinnersPage.tsx @@ -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(null) - const [winnersByClass, setWinnersByClass] = useState>({}) - const [sectionMap, setSectionMap] = useState>({}) - const [questionCounts, setQuestionCounts] = useState>({}) - const [error, setError] = useState(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(null) + const [error, setError] = useState(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 ( - 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. {error ?
{error}
: null} - {competition ? ( + {data?.competition ? ( <>
- Competition: {competitionLabel(competition)} + Competition: {competitionLabel(data.competition)}
- School Year: {competition.school_year ?? '—'} + School Year: {data.competition.school_year ?? '—'}
- Published: {competition.is_published ? 'Yes' : 'No'} + Published: {data.competition.is_published ? 'Yes' : 'No'}
- {flatRows.length === 0 ? ( + {(data.rows ?? []).length === 0 ? (
No published winners yet.
) : (
@@ -80,24 +57,24 @@ export function CompetitionWinnerWinnersPage() { Class Student + School ID Rank Score - Question Count Prize - {flatRows.map((row, index) => ( - - {sectionMap[String(row._classId)] ?? `Class ${row._classId}`} + {(data.rows ?? []).map((row: CompetitionWinnerAdminWinnerRow, index) => ( + + {data.sectionMap?.[String(row.class_section_id ?? '')] ?? `Class ${row.class_section_id ?? ''}`} {row.name ?? (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `Student #${row.student_id ?? ''}`)} + {row.school_id ?? '—'} {row.rank ?? '—'} {row.score ?? '—'} - {questionCounts[String(row._classId)] ?? '—'} {row.prize_amount ?? '—'} ))} diff --git a/src/pages/competitionWinners/CompetitionWinnersIndexPage.tsx b/src/pages/competitionWinners/CompetitionWinnersIndexPage.tsx index 072fa22..d229365 100644 --- a/src/pages/competitionWinners/CompetitionWinnersIndexPage.tsx +++ b/src/pages/competitionWinners/CompetitionWinnersIndexPage.tsx @@ -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([]) - const [adminRows, setAdminRows] = useState([]) + const [rows, setRows] = useState([]) const [sectionMap, setSectionMap] = useState>({}) - const [activeClassName, setActiveClassName] = useState(null) + const [message, setMessage] = useState(null) const [error, setError] = useState(null) + const [busyAction, setBusyAction] = useState(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 ( - 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. {error ?
{error}
: null} + {message ?
{message}
: null}
@@ -79,63 +126,75 @@ export function CompetitionWinnersIndexPage() { - {tableRows.length === 0 ? ( + {rows.length === 0 ? ( No competitions found. ) : ( - 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 ( - - {id} - {String(c.title ?? `Competition #${id}`)} + + {id ?? '—'} + {title} {sectionName} Tiered per class - {isPublished ? 'Yes' : 'No'} - {isLocked ? 'Yes' : 'No'} + {row.is_published ? 'Yes' : 'No'} + {row.is_locked ? 'Yes' : 'No'}
- - Settings - - - Enter Scores - - - Preview - - - Winners - - - {isLocked ? ( - + {id !== null ? ( + <> + + Settings + + + Enter Scores + + + Preview + + + Winners + + ) : ( - + Invalid competition id )} + +
@@ -145,10 +204,6 @@ export function CompetitionWinnersIndexPage() {
- - {activeClassName ? ( -

Active class context: {activeClassName}

- ) : null} ) }