page fixes
This commit is contained in:
@@ -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> },
|
||||||
|
|||||||
@@ -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>
|
||||||
<tr>
|
{visibleRows.length === 0 ? (
|
||||||
<td colSpan={12} className="text-muted small">
|
<tr>
|
||||||
Class rows load from the backend when competition settings APIs are available.
|
<td colSpan={12} className="text-muted small">
|
||||||
</td>
|
No class rows found for the selected scope.
|
||||||
</tr>
|
</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>
|
</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>
|
||||||
</div>
|
<strong>Competition:</strong> {competitionLabel(data.competition)}
|
||||||
<div>
|
</div>
|
||||||
<strong>School Year:</strong> {competition.school_year ?? '—'}
|
<div>
|
||||||
|
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
|
||||||
|
{publishing ? 'Publishing...' : 'Publish Winners'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(winnersByClass).length === 0 ? (
|
{Object.keys(data.topByClass ?? {}).length === 0 ? (
|
||||||
<div className="alert alert-warning">No published winners found for this competition.</div>
|
<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)
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
void load()
|
||||||
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.')
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
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,63 +126,75 @@ 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">
|
||||||
<Link
|
{id !== null ? (
|
||||||
className="btn btn-sm btn-outline-secondary"
|
<>
|
||||||
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
|
<Link
|
||||||
>
|
className="btn btn-sm btn-outline-secondary"
|
||||||
Settings
|
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
|
||||||
</Link>
|
>
|
||||||
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
|
Settings
|
||||||
Enter Scores
|
</Link>
|
||||||
</Link>
|
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
|
||||||
<Link
|
Enter Scores
|
||||||
className="btn btn-sm btn-outline-secondary"
|
</Link>
|
||||||
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
|
<Link
|
||||||
>
|
className="btn btn-sm btn-outline-secondary"
|
||||||
Preview
|
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
|
||||||
</Link>
|
>
|
||||||
<Link
|
Preview
|
||||||
className="btn btn-sm btn-outline-secondary"
|
</Link>
|
||||||
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
|
<Link
|
||||||
>
|
className="btn btn-sm btn-outline-secondary"
|
||||||
Winners
|
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
|
||||||
</Link>
|
>
|
||||||
<button type="button" className="btn btn-sm btn-outline-success" disabled title="Requires API">
|
Winners
|
||||||
Export Scores to Quiz
|
</Link>
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user