364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
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'
|
|
|
|
type CompetitionFormState = {
|
|
title: string
|
|
class_section_id: string
|
|
semester: string
|
|
school_year: string
|
|
start_date: string
|
|
end_date: string
|
|
}
|
|
|
|
function numberOrNull(value: string): number | null {
|
|
const trimmed = value.trim()
|
|
if (trimmed === '') return null
|
|
const parsed = Number(trimmed)
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
}
|
|
|
|
function initialFormState(payload: CompetitionWinnerAdminFormResponse): CompetitionFormState {
|
|
const competition = payload.competition
|
|
return {
|
|
title: String(competition?.title ?? ''),
|
|
class_section_id:
|
|
competition?.class_section_id === null || competition?.class_section_id === undefined
|
|
? ''
|
|
: String(competition.class_section_id),
|
|
semester: String(competition?.semester ?? payload.defaultSemester ?? ''),
|
|
school_year: String(competition?.school_year ?? payload.defaultSchoolYear ?? ''),
|
|
start_date: String(competition?.start_date ?? ''),
|
|
end_date: String(competition?.end_date ?? ''),
|
|
}
|
|
}
|
|
|
|
function normalizeRowValue(value: number | string | null | undefined): string {
|
|
return value === null || value === undefined ? '' : String(value)
|
|
}
|
|
|
|
/** CI `admin/competition_winners/form.php` */
|
|
export function CompetitionWinnerFormPage() {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const isEdit = Boolean(id)
|
|
const competitionId = Number(id)
|
|
const [form, setForm] = useState<CompetitionFormState>({
|
|
title: '',
|
|
class_section_id: '',
|
|
semester: '',
|
|
school_year: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
})
|
|
const [classRows, setClassRows] = useState<CompetitionWinnerAdminClassRow[]>([])
|
|
const [classSections, setClassSections] = useState<CompetitionWinnerAdminFormResponse['classSections']>([])
|
|
const [saving, setSaving] = useState(false)
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
;(async () => {
|
|
try {
|
|
const response =
|
|
isEdit && Number.isFinite(competitionId) && competitionId > 0
|
|
? await fetchAdminCompetitionWinnerSettings(competitionId)
|
|
: await fetchAdminCompetitionWinnerCreate()
|
|
|
|
if (!response.ok) {
|
|
setError(response.message ?? 'Unable to load competition settings.')
|
|
return
|
|
}
|
|
|
|
setForm(initialFormState(response))
|
|
setClassRows(response.classRows ?? [])
|
|
setClassSections(response.classSections ?? [])
|
|
setError(null)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unable to load competition settings.')
|
|
}
|
|
})()
|
|
}, [competitionId, isEdit])
|
|
|
|
const visibleRows = useMemo(() => {
|
|
const selectedClassId = numberOrNull(form.class_section_id)
|
|
if (selectedClassId === null) return classRows
|
|
return classRows.filter((row) => row.class_section_id === selectedClassId)
|
|
}, [classRows, form.class_section_id])
|
|
|
|
function updateClassRow(
|
|
classSectionId: number,
|
|
key:
|
|
| 'question_count'
|
|
| 'override_winners'
|
|
| 'prize_1'
|
|
| 'prize_2'
|
|
| 'prize_3'
|
|
| 'prize_4'
|
|
| 'prize_5'
|
|
| 'prize_6',
|
|
value: string,
|
|
) {
|
|
setClassRows((current) =>
|
|
current.map((row) => {
|
|
if (row.class_section_id !== classSectionId) return row
|
|
return {
|
|
...row,
|
|
[key]: value.trim() === '' ? null : Number(value),
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
|
|
try {
|
|
setSaving(true)
|
|
setMessage(null)
|
|
setError(null)
|
|
|
|
const winnerOverrides = Object.fromEntries(
|
|
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.override_winners)]),
|
|
)
|
|
const questionCounts = Object.fromEntries(
|
|
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.question_count)]),
|
|
)
|
|
const prizes = Object.fromEntries(
|
|
classRows.map((row) => [
|
|
String(row.class_section_id),
|
|
{
|
|
1: normalizeRowValue(row.prize_1),
|
|
2: normalizeRowValue(row.prize_2),
|
|
3: normalizeRowValue(row.prize_3),
|
|
4: normalizeRowValue(row.prize_4),
|
|
5: normalizeRowValue(row.prize_5),
|
|
6: normalizeRowValue(row.prize_6),
|
|
},
|
|
]),
|
|
)
|
|
|
|
const result = await saveAdminCompetitionWinner(
|
|
{
|
|
title: form.title,
|
|
class_section_id: numberOrNull(form.class_section_id),
|
|
semester: form.semester || null,
|
|
school_year: form.school_year || null,
|
|
start_date: form.start_date || null,
|
|
end_date: form.end_date || null,
|
|
winner_overrides: winnerOverrides,
|
|
question_counts: questionCounts,
|
|
prizes,
|
|
},
|
|
isEdit && Number.isFinite(competitionId) && competitionId > 0 ? competitionId : undefined,
|
|
)
|
|
|
|
if (!result.ok) {
|
|
setError(result.message ?? 'Unable to save competition.')
|
|
return
|
|
}
|
|
|
|
const nextId = result.id ?? competitionId
|
|
setMessage(result.message ?? 'Competition saved.')
|
|
if (Number.isFinite(nextId) && nextId > 0) {
|
|
navigate(`${COMPETITION_WINNERS_BASE}/${nextId}/settings`, { replace: true })
|
|
return
|
|
}
|
|
|
|
navigate(COMPETITION_WINNERS_BASE)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unable to save competition.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
|
|
<ApiScopeNote>
|
|
This screen now saves directly through the Laravel admin competition endpoints, including per-class winner overrides
|
|
and prize grids.
|
|
</ApiScopeNote>
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
{message ? <div className="alert alert-success">{message}</div> : null}
|
|
|
|
<form className="card shadow-sm" onSubmit={(e) => void onSubmit(e)}>
|
|
<div className="card-body row g-3">
|
|
<div className="col-md-8">
|
|
<label className="form-label">Title *</label>
|
|
<input
|
|
className="form-control"
|
|
value={form.title}
|
|
onChange={(e) => setForm((current) => ({ ...current, title: e.target.value }))}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Class Section (optional)</label>
|
|
<select
|
|
className="form-select"
|
|
value={form.class_section_id}
|
|
onChange={(e) => setForm((current) => ({ ...current, class_section_id: e.target.value }))}
|
|
>
|
|
<option value="">All classes</option>
|
|
{(classSections ?? []).map((section) => (
|
|
<option key={String(section?.class_section_id ?? '')} value={String(section?.class_section_id ?? '')}>
|
|
{section?.class_section_name ?? section?.class_section_id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="form-text">Leave empty to manage winners for all classes.</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Semester</label>
|
|
<input
|
|
className="form-control"
|
|
value={form.semester}
|
|
onChange={(e) => setForm((current) => ({ ...current, semester: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">School Year</label>
|
|
<input
|
|
className="form-control"
|
|
value={form.school_year}
|
|
onChange={(e) => setForm((current) => ({ ...current, school_year: e.target.value }))}
|
|
placeholder="2025-2026"
|
|
/>
|
|
<div className="form-text">Counts use the school year above.</div>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label">Start Date</label>
|
|
<input
|
|
className="form-control"
|
|
type="date"
|
|
value={form.start_date}
|
|
onChange={(e) => setForm((current) => ({ ...current, start_date: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label">End Date</label>
|
|
<input
|
|
className="form-control"
|
|
type="date"
|
|
value={form.end_date}
|
|
onChange={(e) => setForm((current) => ({ ...current, end_date: e.target.value }))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="col-12">
|
|
<h5 className="mt-2">Winners per Class</h5>
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered table-sm" data-no-mgmt-sticky>
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th>Class</th>
|
|
<th style={{ width: 140 }}>Students</th>
|
|
<th style={{ width: 140 }}>Auto Winners</th>
|
|
<th style={{ width: 150 }}>Questions</th>
|
|
<th style={{ width: 120 }}>1st</th>
|
|
<th style={{ width: 120 }}>2nd</th>
|
|
<th style={{ width: 120 }}>3rd</th>
|
|
<th style={{ width: 120 }}>4th</th>
|
|
<th style={{ width: 120 }}>5th</th>
|
|
<th style={{ width: 120 }}>6th</th>
|
|
<th style={{ width: 160 }}>Override</th>
|
|
<th style={{ width: 140 }}>Final Winners</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={12} className="text-muted small">
|
|
No class rows found for the selected scope.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
visibleRows.map((row) => {
|
|
const finalWinners =
|
|
row.override_winners === null || row.override_winners === undefined
|
|
? row.auto_winners
|
|
: row.override_winners
|
|
|
|
return (
|
|
<tr key={row.class_section_id}>
|
|
<td>{row.class_section_name}</td>
|
|
<td>{row.student_count}</td>
|
|
<td>{row.auto_winners}</td>
|
|
<td>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
value={normalizeRowValue(row.question_count)}
|
|
onChange={(e) => updateClassRow(row.class_section_id, 'question_count', e.target.value)}
|
|
/>
|
|
</td>
|
|
{[1, 2, 3, 4, 5, 6].map((rank) => (
|
|
<td key={rank}>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={normalizeRowValue(row[`prize_${rank}` as keyof CompetitionWinnerAdminClassRow] as number | null | undefined)}
|
|
onChange={(e) =>
|
|
updateClassRow(
|
|
row.class_section_id,
|
|
`prize_${rank}` as
|
|
| 'prize_1'
|
|
| 'prize_2'
|
|
| 'prize_3'
|
|
| 'prize_4'
|
|
| 'prize_5'
|
|
| 'prize_6',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</td>
|
|
))}
|
|
<td>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
type="number"
|
|
min="0"
|
|
value={normalizeRowValue(row.override_winners)}
|
|
onChange={(e) => updateClassRow(row.class_section_id, 'override_winners', e.target.value)}
|
|
/>
|
|
</td>
|
|
<td>{finalWinners}</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-12 d-flex gap-2">
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => navigate(COMPETITION_WINNERS_BASE)}
|
|
disabled={saving}
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</CompetitionPageShell>
|
|
)
|
|
}
|