diff --git a/app/Http/Controllers/Admin/AuditionSettings.php b/app/Http/Controllers/Admin/AuditionSettings.php index 07fecd3..b02a8be 100644 --- a/app/Http/Controllers/Admin/AuditionSettings.php +++ b/app/Http/Controllers/Admin/AuditionSettings.php @@ -24,7 +24,8 @@ class AuditionSettings extends Controller 'organizerName' => ['required'], 'organizerEmail' => ['required', 'email'], 'registrationCode' => ['required'], - 'fee_structure' => ['required', 'in:oneFeePerEntry,oneFeePerStudent'], // Options should align with the boot method of InvoiceDataServiceProvider + 'fee_structure' => ['required', 'in:oneFeePerEntry,oneFeePerStudent'], + // Options should align with the boot method of InvoiceDataServiceProvider 'late_fee' => ['nullable', 'numeric', 'min:0'], 'school_fee' => ['nullable', 'numeric', 'min:0'], 'payment_address' => ['required'], @@ -32,6 +33,8 @@ class AuditionSettings extends Controller 'payment_state' => ['required', 'max:2'], 'payment_zip' => ['required', 'min:5'], 'advanceTo' => ['nullable'], + 'nomination_ensemble_rules' => ['required', 'in:disabled,scobda'], + // Options should align with the boot method of NominationEnsembleServiceProvider ]); // Olympic Scoring Switch @@ -43,6 +46,9 @@ class AuditionSettings extends Controller // Enable Invoicing Switch $validData['invoicing_enabled'] = $request->get('invoicing_enabled') == '1'; + // Enable collect shirt size switch + $validData['student_data_collect_shirt_size'] = $request->get('student_data_collect_shirt_size') == '1'; + // Store currency values as cents $validData['late_fee'] = $validData['late_fee'] * 100; $validData['school_fee'] = $validData['school_fee'] * 100; diff --git a/app/Http/Controllers/Admin/StudentController.php b/app/Http/Controllers/Admin/StudentController.php index 9c0d9e3..0055604 100644 --- a/app/Http/Controllers/Admin/StudentController.php +++ b/app/Http/Controllers/Admin/StudentController.php @@ -7,6 +7,7 @@ use App\Models\Audition; use App\Models\AuditLogEntry; use App\Models\Entry; use App\Models\Event; +use App\Models\NominationEnsemble; use App\Models\School; use App\Models\Student; use Illuminate\Support\Facades\Auth; @@ -14,6 +15,8 @@ use Illuminate\Support\Facades\Auth; use function abort; use function auth; use function compact; +use function max; +use function min; use function request; use function to_route; use function view; @@ -54,8 +57,8 @@ class StudentController extends Controller if (! Auth::user()->is_admin) { abort(403); } - $minGrade = Audition::min('minimum_grade'); - $maxGrade = Audition::max('maximum_grade'); + $minGrade = min(Audition::min('minimum_grade'), NominationEnsemble::min('minimum_grade')); + $maxGrade = max(Audition::max('maximum_grade'), NominationEnsemble::max('maximum_grade')); $schools = School::orderBy('name')->get(); return view('admin.students.create', ['schools' => $schools, 'minGrade' => $minGrade, 'maxGrade' => $maxGrade]); @@ -105,8 +108,8 @@ class StudentController extends Controller if (! Auth::user()->is_admin) { abort(403); } - $minGrade = Audition::min('minimum_grade'); - $maxGrade = Audition::max('maximum_grade'); + $minGrade = min(Audition::min('minimum_grade'), NominationEnsemble::min('minimum_grade')); + $maxGrade = max(Audition::max('maximum_grade'), NominationEnsemble::max('maximum_grade')); $schools = School::orderBy('name')->get(); $student->loadCount('entries'); $entries = $student->entries; diff --git a/app/Http/Controllers/NominationEnsembles/NominationAdminController.php b/app/Http/Controllers/NominationEnsembles/NominationAdminController.php new file mode 100644 index 0000000..a1db51c --- /dev/null +++ b/app/Http/Controllers/NominationEnsembles/NominationAdminController.php @@ -0,0 +1,22 @@ +with('ensemble')->get(); + + return view('nomination_ensembles.scobda.admin.index', compact('nominations')); + } + + public function show(NominationEnsembleEntry $entry) + { + // TODO: Implement show() method. + } + + public function create() + { + // TODO: Implement create() method. + } + + public function store() + { + // TODO: Implement store() method. + } + + public function edit(NominationEnsembleEntry $entry) + { + // TODO: Implement edit() method. + } + + public function update(NominationEnsembleEntry $entry) + { + // TODO: Implement update() method. + } + + public function destroy(NominationEnsembleEntry $entry) + { + // TODO: Implement destroy() method. + } +} diff --git a/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleController.php b/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleController.php new file mode 100644 index 0000000..ac6eac2 --- /dev/null +++ b/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleController.php @@ -0,0 +1,113 @@ +all()); + $validated = request()->validate([ + 'ensemble_name' => 'required|unique:nomination_ensembles,name', + 'entry_deadline' => 'required|date', + 'min_grade' => 'required|numeric|min:0', + 'max_grade' => 'required|numeric|gte:min_grade', + 'max_nominations' => 'required|numeric|min:1', + 'target_size' => 'required|numeric|min:1', + 'rounding_direction' => 'required|in:up,down', + 'instrument_list' => 'required|string', + ], [ + 'maximum_grade.gte' => 'The maximum grade must be greater than the minimum grade.', + 'rounding_direction.in' => 'The rounding direction must be either "up" or "down".', + ]); + $instrument_list = preg_replace('/\s*,\s*/', ',', $validated['instrument_list']); + $instrument_array = explode(',', $instrument_list); + + $ensemble = new NominationEnsemble(); + $ensemble->name = $validated['ensemble_name']; + $ensemble->entry_deadline = $validated['entry_deadline']; + $ensemble->minimum_grade = $validated['min_grade']; + $ensemble->maximum_grade = $validated['max_grade']; + $data = []; + $data['max_nominations'] = $validated['max_nominations']; + $data['target_size'] = $validated['target_size']; + $data['instruments'] = $instrument_array; + $data['rounding_direction'] = $validated['rounding_direction']; + $ensemble->data = $data; + $ensemble->save(); + + return redirect()->route('nomination.admin.ensemble.index')->with('success', 'Nomination Ensemble has been created.'); + } + + public function edit(NominationEnsemble $ensemble) + { + // TODO: Implement edit() method. + } + + public function update(NominationEnsemble $ensemble) + { + $validated = request()->validate([ + 'ensemble_name' => [ + 'required', + Rule::unique('nomination_ensembles', 'name')->ignore($ensemble->id), + ], + 'entry_deadline' => 'required|date', + 'min_grade' => 'required|numeric|min:0', + 'max_grade' => 'required|numeric|gte:min_grade', + 'max_nominations' => 'required|numeric|min:1', + 'target_size' => 'required|numeric|min:1', + 'rounding_direction' => 'required|in:up,down', + 'instrument_list' => 'required|string', + ], [ + 'maximum_grade.gte' => 'The maximum grade must be greater than the minimum grade.', + 'rounding_direction.in' => 'The rounding direction must be either "up" or "down".', + ]); + $instrument_list = preg_replace('/\s*,\s*/', ',', $validated['instrument_list']); + $instrument_array = explode(',', $instrument_list); + + $ensemble->name = $validated['ensemble_name']; + $ensemble->entry_deadline = $validated['entry_deadline']; + $ensemble->minimum_grade = $validated['min_grade']; + $ensemble->maximum_grade = $validated['max_grade']; + $data = []; + $data['max_nominations'] = $validated['max_nominations']; + $data['target_size'] = $validated['target_size']; + $data['instruments'] = $instrument_array; + $data['rounding_direction'] = $validated['rounding_direction']; + $ensemble->data = $data; + $ensemble->save(); + + return redirect()->route('nomination.admin.ensemble.index')->with('success', 'Nomination Ensemble has been modified.'); + } + + public function destroy(NominationEnsemble $ensemble) + { + $ensemble->delete(); + + // TODO: Delete associated nomionations. + return redirect()->route('nomination.admin.ensemble.index')->with('success', 'Nomination Ensemble has been deleted.'); + } +} diff --git a/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleEntryController.php b/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleEntryController.php new file mode 100644 index 0000000..95fa5b0 --- /dev/null +++ b/app/Http/Controllers/NominationEnsembles/ScobdaNominationEnsembleEntryController.php @@ -0,0 +1,224 @@ +format('Y-m-d'); + + $ensembles = NominationEnsemble::all(); + // populate an array with each ensemble id as a key. Each item will be a collection of students available to be nominated + $availableStudents = []; + // populate an array with each ensemble id as a key. Each item will be a collection of available instruments + $availableInstruments = []; + // populate an array with each ensemble id as a key. Each item will be a collection of nominationEntries already made + $nominatedStudents = []; + // an array of bool values with each ensemble id as a key. It will be true if additional nominations are available + $nominationsAvailable = []; + foreach ($ensembles as $ensemble) { + // Gather a collection of students who may be nominated for this ensemble + $availableStudents[$ensemble->id] = Student::where('grade', '<=', $ensemble->maximum_grade) + ->where('grade', '>=', $ensemble->minimum_grade) + ->where('school_id', auth()->user()->school_id) + ->orderBy('last_name') + ->orderBy('first_name') + ->get(); + $availableInstruments[$ensemble->id] = $ensemble->data['instruments']; + $nominatedStudents[$ensemble->id] = $this->collapseNominations(auth()->user()->school, $ensemble, + 'nominations'); + + $nominatedStudentIds = []; + + // Removed students already nominated from available students + foreach ($nominatedStudents[$ensemble->id] as $nominatedStudent) { + $nominatedStudentIds[] = $nominatedStudent->student_id; + } + $availableStudents[$ensemble->id] = $availableStudents[$ensemble->id]->reject(function ($student) use ( + $nominatedStudentIds + ) { + return in_array($student->id, $nominatedStudentIds); + }); + + $nominationsAvailable[$ensemble->id] = $ensemble->data['max_nominations'] > count($nominatedStudents[$ensemble->id]); + + } + + return view('nomination_ensembles.scobda.entries.index', + compact('ensembles', 'availableStudents', 'availableInstruments', 'nominatedStudents', + 'nominationsAvailable', 'currentDate')); + } + + public function show(NominationEnsembleEntry $entry) + { + // TODO: Implement show() method. + } + + public function create() + { + // TODO: Implement create() method. + } + + public function store() + { + $validData = request()->validate([ + 'ensemble' => [ + 'required', + 'exists:App\Models\NominationEnsemble,id', + ], + 'new_student' => [ + 'required', + 'exists:App\Models\Student,id', + ], + 'new_instrument' => 'required', + ]); + + if (NominationEnsembleEntry::where('student_id', $validData['new_student']) + ->where('nomination_ensemble_id', $validData['ensemble']) + ->count() > 0) { + return redirect()->route('nomination.entry.index')->with('error', + 'Student already nominated for that ensemble'); + } + + $proposedEnsemble = NominationEnsemble::find($validData['ensemble']); + + $currentDate = Carbon::now('America/Chicago'); + $currentDate = $currentDate->format('Y-m-d'); + if ($proposedEnsemble->entry_deadline < $currentDate) { + return redirect()->route('nomination.entry.index')->with('error', + 'The nomination deadline for that ensemble has passed'); + } + + if (! in_array($validData['new_instrument'], $proposedEnsemble->data['instruments'])) { + return redirect()->route('nomination.entry.index')->with('error', + 'Invalid Instrument specified'); + } + + $student = Student::find($validData['new_student']); + if (auth()->user()->school_id !== $student->school_id) { + return redirect()->route('nomination.entry.index')->with('error', + 'You may only nominate students from your school'); + } + $nextRank = $this->collapseNominations($student->school, $proposedEnsemble, 'next'); + if ($nextRank > $proposedEnsemble->data['max_nominations']) { + return redirect()->route('nomination.entry.index')->with('error', + 'You have already used all of your nominations'); + } + + $entry = new NominationEnsembleEntry(); + $entry->student_id = $validData['new_student']; + $entry->nomination_ensemble_id = $validData['ensemble']; + $data = []; + $data['rank'] = $nextRank; + $data['instrument'] = $validData['new_instrument']; + $entry->data = $data; + $entry->save(); + + return redirect()->route('nomination.entry.index')->with('success', + 'Nomination Recorded'); + } + + public function edit(NominationEnsembleEntry $entry) + { + // TODO: Implement edit() method. + } + + public function update(NominationEnsembleEntry $entry) + { + // TODO: Implement update() method. + } + + public function destroy(NominationEnsembleEntry $entry) + { + if ($entry->student->school_id !== auth()->user()->school_id) { + return redirect()->route('nomination.entry.index')->with('error', + 'You may only delete nominations from your school'); + } + + $currentDate = Carbon::now('America/Chicago'); + $currentDate = $currentDate->format('Y-m-d'); + if ($entry->ensemble->entry_deadline < $currentDate) { + return redirect()->route('nomination.entry.index')->with('error', + 'You cannot delete nominations after the deadline'); + } + + $entry->delete(); + + return redirect()->route('nomination.entry.index')->with('success', 'Nomination Deleted'); + } + + /** + * Given a school and nomination ensemble, consolidate the rank valuek + * + * if returnType is next, the next available rank will be returned + * if returnType is nominations, a collection of nominations will be returned + * + * @return int|array + * + * @var returnType = next|nominations + */ + private function collapseNominations(School $school, NominationEnsemble $ensemble, $returnType = 'next') + { + $nominations = $school->nominations()->get()->where('nomination_ensemble_id', + $ensemble->id)->sortBy('data.rank'); + $n = 1; + foreach ($nominations as $nomination) { + $nomination->update(['data->rank' => $n]); + $n++; + } + + if ($returnType == 'next') { + return $n; + } + + return $nominations; + } + + public function move() + { + + $validData = request()->validate([ + 'direction' => 'required|in:up,down', + 'nominationId' => 'required|exists:App\Models\NominationEnsembleEntry,id', + ]); + $direction = $validData['direction']; + $nomination = NominationEnsembleEntry::findOrFail($validData['nominationId']); + + // Verify the entry deadline for the ensemble has not passed + $currentDate = Carbon::now('America/Chicago'); + $currentDate = $currentDate->format('Y-m-d'); + if ($nomination->ensemble->entry_deadline < $currentDate) { + return redirect()->route('nomination.entry.index')->with('error', + 'The entry deadline for that nomination ensemble has passed'); + } + + // Verify the student being moved is from the users school + if (auth()->user()->school_id !== $nomination->student_id) { + return redirect()->route('nomination.entry.index')->with('error', + 'You cannot modify nominations of another school'); + } + + $data = $nomination->data; + if ($validData['direction'] == 'up') { + $data['rank'] = $nomination->data['rank'] - 1.5; + } + if ($validData['direction'] == 'down') { + $data['rank'] = $nomination->data['rank'] + 1.5; + } + $nomination->update(['data' => $data]); + $this->collapseNominations($nomination->student->school, $nomination->ensemble, 'next'); + + return redirect()->route('nomination.entry.index')->with('success', 'Nomination Moved'); + + } +} diff --git a/app/Http/Controllers/NominationEnsembles/ScobdaNominationSeatingController.php b/app/Http/Controllers/NominationEnsembles/ScobdaNominationSeatingController.php new file mode 100644 index 0000000..72ae571 --- /dev/null +++ b/app/Http/Controllers/NominationEnsembles/ScobdaNominationSeatingController.php @@ -0,0 +1,103 @@ +id) + ->where('data->accepted', true) + ->orderByRaw('CAST(data->"$.rank" AS UNSIGNED)') + ->get(); + $acceptedNominations = $acceptedNominations->groupBy(function ($item) { + return $item->data['instrument']; + }); + + return view('nomination_ensembles.scobda.admin.seating.index', + compact('ensembles', 'ensemble', 'acceptedNominations')); + + } + + public function seat(NominationEnsemble $ensemble) + { + $nominations = NominationEnsembleEntry::where('nomination_ensemble_id', + $ensemble->id)->orderByRaw('CAST(data->"$.rank" AS UNSIGNED)')->inRandomOrder()->get(); + $rankGroupedNominations = $nominations->groupBy(function ($entry) { + return $entry->data['rank']; + }); + + $validData = request()->validate([ + 'action' => ['required', 'in:seat,clear'], + ]); + $action = $validData['action']; + + if ($action == 'clear') { + foreach ($nominations as $nomination) { + $data = $nomination->data; + unset($data['accepted']); + $nomination->update(['data' => $data]); + } + + $data = $ensemble->data; + $data['seated'] = false; + $ensemble->data = $data; + $ensemble->update(); + + return redirect()->route('nomination.admin.seating.show', + ['ensemble' => $ensemble])->with('Seating Cleared'); + } + + $acceptedNominations = collect(); + $rankOn = 1; + // Collect students to add to the ensemble + while ($rankOn <= $ensemble->data['max_nominations'] && $rankGroupedNominations->has($rankOn)) { + // If were at or over the target size of the ensemble, stop adding people + if ($acceptedNominations->count() >= $ensemble->data['target_size']) { + break; + } + // Add people of the current rank to the ensemble + foreach ($rankGroupedNominations[$rankOn] as $nomination) { + $acceptedNominations->push($nomination); + } + $rankOn++; + + // If we want to round down the ensemble size, quit adding people if hte next rank will exceed the target + if ( + $rankGroupedNominations->has($rankOn) && + $acceptedNominations->count() + $rankGroupedNominations[$rankOn]->count() >= $ensemble->data['target_size'] && + $ensemble->data['rounding_direction'] === 'down' + ) { + break; + } + } + + foreach ($acceptedNominations as $nomination) { + $data = $nomination->data; + $data['accepted'] = true; + $nomination->update(['data' => $data]); + } + + $data = $ensemble->data; + $data['seated'] = true; + $ensemble->data = $data; + $ensemble->update(); + + return redirect()->route('nomination.admin.seating.show', ['ensemble' => $ensemble])->with('Seating Complete'); + } +} diff --git a/app/Http/Controllers/StudentController.php b/app/Http/Controllers/StudentController.php index 1b24434..52628ae 100644 --- a/app/Http/Controllers/StudentController.php +++ b/app/Http/Controllers/StudentController.php @@ -25,7 +25,10 @@ class StudentController extends Controller $students = Auth::user()->students()->withCount('entries')->get(); $auditions = Audition::all(); - return view('students.index', ['students' => $students, 'auditions' => $auditions]); + $shirtSizes = Student::$shirtSizes; + + return view('students.index', + ['students' => $students, 'auditions' => $auditions, 'shirtSizes' => $shirtSizes]); } /** @@ -51,6 +54,14 @@ class StudentController extends Controller new UniqueFullNameAtSchool(request('first_name'), request('last_name'), Auth::user()->school_id), ], 'grade' => ['required', 'integer'], + 'shirt_size' => [ + 'nullable', + function ($attribute, $value, $fail) { + if (! array_key_exists($value, Student::$shirtSizes)) { + $fail("The selected $attribute is invalid."); + } + }, + ], ]); $student = Student::create([ @@ -59,6 +70,9 @@ class StudentController extends Controller 'grade' => request('grade'), 'school_id' => Auth::user()->school_id, ]); + if (request('shirt_size') !== 'none') { + $student->update(['optional_data->shirt_size' => $request['shirt_size']]); + } $message = 'Created student #'.$student->id.' - '.$student->full_name().'
Grade: '.$student->grade.'
School: '.$student->school->name; AuditLogEntry::create([ 'user' => auth()->user()->email, @@ -90,7 +104,9 @@ class StudentController extends Controller abort(403); } - return view('students.edit', ['student' => $student]); + $shirtSizes = Student::$shirtSizes; + + return view('students.edit', ['student' => $student, 'shirtSizes' => $shirtSizes]); } /** @@ -106,6 +122,14 @@ class StudentController extends Controller 'first_name' => ['required'], 'last_name' => ['required'], 'grade' => ['required', 'integer'], + 'shirt_size' => [ + 'nullable', + function ($attribute, $value, $fail) { + if (! array_key_exists($value, Student::$shirtSizes)) { + $fail("The selected $attribute is invalid."); + } + }, + ], ]); if (Student::where('first_name', request('first_name')) @@ -122,6 +146,9 @@ class StudentController extends Controller 'last_name' => request('last_name'), 'grade' => request('grade'), ]); + + $student->update(['optional_data->shirt_size' => $request['shirt_size']]); + $message = 'Updated student #'.$student->id.'
Name: '.$student->full_name().'
Grade: '.$student->grade.'
School: '.$student->school->name; AuditLogEntry::create([ 'user' => auth()->user()->email, diff --git a/app/Models/NominationEnsemble.php b/app/Models/NominationEnsemble.php new file mode 100644 index 0000000..fec8ef7 --- /dev/null +++ b/app/Models/NominationEnsemble.php @@ -0,0 +1,24 @@ + 'array', + ]; + } + + public function entries(): HasMany + { + return $this->hasMany(NominationEnsembleEntry::class); + } +} diff --git a/app/Models/NominationEnsembleEntry.php b/app/Models/NominationEnsembleEntry.php new file mode 100644 index 0000000..c09fbdd --- /dev/null +++ b/app/Models/NominationEnsembleEntry.php @@ -0,0 +1,31 @@ + 'array', + ]; + } + + public function ensemble(): BelongsTo + { + return $this->belongsTo(NominationEnsemble::class, 'nomination_ensemble_id'); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } +} diff --git a/app/Models/School.php b/app/Models/School.php index 93c886e..b359b63 100644 --- a/app/Models/School.php +++ b/app/Models/School.php @@ -51,4 +51,15 @@ class School extends Model 'id', 'id'); } + + public function nominations(): HasManyThrough + { + return $this->hasManyThrough( + NominationEnsembleEntry::class, + Student::class, + 'school_id', + 'student_id', + 'id', + 'id'); + } } diff --git a/app/Models/Student.php b/app/Models/Student.php index dece4dd..925fc29 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -12,8 +12,34 @@ class Student extends Model { use HasFactory; + public static $shirtSizes = [ + 'none' => '---', + 'YS' => 'Youth Small', + 'YM' => 'Youth Medium', + 'YL' => 'Youth Large', + 'YXL' => 'Youth Extra Large', + 'S' => 'Small', + 'M' => 'Medium', + 'L' => 'Large', + 'XL' => 'Extra Large', + '2XL' => '2XL', + '3XL' => '3XL', + ]; + protected $guarded = []; + protected function casts(): array + { + return [ + 'optional_data' => 'array', + ]; + } + + public function nominations(): HasMany + { + return $this->hasMany(NominationEnsembleEntry::class); + } + public function school(): BelongsTo { return $this->belongsTo(School::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d739a78..7c22988 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,14 @@ use App\Actions\Tabulation\CalculateEntryScore; use App\Actions\Tabulation\CalculateScoreSheetTotal; use App\Actions\Tabulation\CalculateScoreSheetTotalDivideByTotalWeights; use App\Actions\Tabulation\CalculateScoreSheetTotalDivideByWeightedPossible; +use App\Http\Controllers\NominationEnsembles\NominationAdminController; +use App\Http\Controllers\NominationEnsembles\NominationEnsembleController; +use App\Http\Controllers\NominationEnsembles\NominationEnsembleEntryController; +use App\Http\Controllers\NominationEnsembles\NominationSeatingController; +use App\Http\Controllers\NominationEnsembles\ScobdaNominationAdminController; +use App\Http\Controllers\NominationEnsembles\ScobdaNominationEnsembleController; +use App\Http\Controllers\NominationEnsembles\ScobdaNominationEnsembleEntryController; +use App\Http\Controllers\NominationEnsembles\ScobdaNominationSeatingController; use App\Models\Audition; use App\Models\Entry; use App\Models\Room; @@ -62,6 +70,11 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(UpdateEntry::class, UpdateEntry::class); $this->app->singleton(SetHeadDirector::class, SetHeadDirector::class); + // Nomination Ensemble + $this->app->bind(NominationEnsembleController::class, ScobdaNominationEnsembleController::class); + $this->app->bind(NominationEnsembleEntryController::class, ScobdaNominationEnsembleEntryController::class); + $this->app->bind(NominationAdminController::class, ScobdaNominationAdminController::class); + $this->app->bind(NominationSeatingController::class, ScobdaNominationSeatingController::class); } /** diff --git a/database/factories/NominationEnsembleEntryFactory.php b/database/factories/NominationEnsembleEntryFactory.php new file mode 100644 index 0000000..1f04ba9 --- /dev/null +++ b/database/factories/NominationEnsembleEntryFactory.php @@ -0,0 +1,23 @@ + $this->faker->randomNumber(), + 'nomination_ensemble_id' => $this->faker->randomNumber(), + 'data' => $this->faker->words(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/migrations/2025_01_31_202548_add_collect_student_tshirt_size_setting.php b/database/migrations/2025_01_31_202548_add_collect_student_tshirt_size_setting.php new file mode 100644 index 0000000..14bc1a9 --- /dev/null +++ b/database/migrations/2025_01_31_202548_add_collect_student_tshirt_size_setting.php @@ -0,0 +1,33 @@ +where('setting_key', 'student_data_collect_shirt_size') + ->exists(); + + // If it doesn't insert the new row + if (! $exists) { + DB::table('site_settings')->insert([ + 'setting_key' => 'student_data_collect_shirt_size', + 'setting_value' => '0', + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2025_01_31_204732_add_optional_data_column_to_students_table.php b/database/migrations/2025_01_31_204732_add_optional_data_column_to_students_table.php new file mode 100644 index 0000000..f003abb --- /dev/null +++ b/database/migrations/2025_01_31_204732_add_optional_data_column_to_students_table.php @@ -0,0 +1,28 @@ +json('optional_data')->nullable()->after('grade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('students', function (Blueprint $table) { + $table->dropColumn('optional_data'); + }); + } +}; diff --git a/database/migrations/2025_02_01_184324_create_nomination_ensembles_table.php b/database/migrations/2025_02_01_184324_create_nomination_ensembles_table.php new file mode 100644 index 0000000..6f29f1b --- /dev/null +++ b/database/migrations/2025_02_01_184324_create_nomination_ensembles_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->date('entry_deadline'); + $table->integer('minimum_grade'); + $table->integer('maximum_grade'); + $table->json('data')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('nomination_ensembles'); + } +}; diff --git a/database/migrations/2025_02_02_225428_add_unique_constraint_to_name_in_nomination_ensembles_table.php b/database/migrations/2025_02_02_225428_add_unique_constraint_to_name_in_nomination_ensembles_table.php new file mode 100644 index 0000000..bd6fc7b --- /dev/null +++ b/database/migrations/2025_02_02_225428_add_unique_constraint_to_name_in_nomination_ensembles_table.php @@ -0,0 +1,28 @@ +unique('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('nomination_ensembles', function (Blueprint $table) { + $table->dropUnique(['name']); + }); + } +}; diff --git a/database/migrations/2025_02_02_225836_add_setting_to_enable_nomination_ensembles.php b/database/migrations/2025_02_02_225836_add_setting_to_enable_nomination_ensembles.php new file mode 100644 index 0000000..ef6583c --- /dev/null +++ b/database/migrations/2025_02_02_225836_add_setting_to_enable_nomination_ensembles.php @@ -0,0 +1,33 @@ +where('setting_key', 'nomination_ensemble_rules') + ->exists(); + + // If it doesn't insert the new row + if (! $exists) { + DB::table('site_settings')->insert([ + 'setting_key' => 'nomination_ensemble_rules', + 'setting_value' => 'disabled', + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + + } +}; diff --git a/database/migrations/2025_02_02_231905_create_nomination_ensemble_entries_table.php b/database/migrations/2025_02_02_231905_create_nomination_ensemble_entries_table.php new file mode 100644 index 0000000..794fcf9 --- /dev/null +++ b/database/migrations/2025_02_02_231905_create_nomination_ensemble_entries_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignIdFor(Student::class)->constrained()->cascadeOnUpdate()->restrictOnDelete(); + $table->foreignIdFor(NominationEnsemble::class)->constrained()->cascadeOnUpdate()->restrictOnDelete(); + $table->json('data'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('nomination_ensemble_entries'); + } +}; diff --git a/database/seeders/ScobdaNominationEnsembleAndEntrySeeder.php b/database/seeders/ScobdaNominationEnsembleAndEntrySeeder.php new file mode 100644 index 0000000..d53668f --- /dev/null +++ b/database/seeders/ScobdaNominationEnsembleAndEntrySeeder.php @@ -0,0 +1,76 @@ +truncate(); + DB::table('nomination_ensembles')->truncate(); + DB::statement('SET FOREIGN_KEY_CHECKS=1;'); + + // Create First Year Ensemble + $ensemble = new NominationEnsemble(); + $ensemble->name = 'First Year Band'; + $ensemble->entry_deadline = '2028-01-01'; + $ensemble->minimum_grade = 5; + $ensemble->maximum_grade = 8; + $instruments = [ + 'Flute', + 'Oboe', + 'Bassoon', + 'Clarinet', + 'Bass Clarinet', + 'Contra Clarinet', + 'Alto Sax', + 'Tenor Sax', + 'Bari Sax', + 'Trumpet', + 'Horn', + 'Trombone', + 'Euphonium', + 'Tuba', + 'String Bass', + 'Percussion', + ]; + $data = [ + 'instruments' => $instruments, + 'target_size' => 100, + 'max_nominations' => 10, + 'rounding_direction' => 'up', + ]; + $ensemble->data = $data; + $ensemble->save(); + + // Fill the nominations table + $faker = Faker::create(); + $schools = School::all(); + foreach ($schools as $school) { + $students = Student::factory()->count(10)->create(['school_id' => $school->id, 'grade' => 5]); + $n = 1; + foreach ($students as $student) { + $nomData = [ + 'rank' => $n, + 'instrument' => $faker->randomElement($instruments), + ]; + NominationEnsembleEntry::create([ + 'student_id' => $student->id, + 'nomination_ensemble_id' => $ensemble->id, + 'data' => $nomData, + ]); + $n++; + } + } + } +} diff --git a/resources/views/admin/audition-settings.blade.php b/resources/views/admin/audition-settings.blade.php index d5284c9..70a434e 100644 --- a/resources/views/admin/audition-settings.blade.php +++ b/resources/views/admin/audition-settings.blade.php @@ -19,6 +19,28 @@ + + Nomination Ensemble Rules + {{-- Values should be one of the options in the boot method NominationEnsembleServiceProvider --}} + + + + + + + + Optional Student Data + +
+ + Collect Student Shirt Size +
diff --git a/resources/views/components/icons/down-arrow.blade.php b/resources/views/components/icons/down-arrow.blade.php new file mode 100644 index 0000000..820107b --- /dev/null +++ b/resources/views/components/icons/down-arrow.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/icons/pencil.blade.php b/resources/views/components/icons/pencil.blade.php new file mode 100644 index 0000000..e5ca2e7 --- /dev/null +++ b/resources/views/components/icons/pencil.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/icons/up-arrow.blade.php b/resources/views/components/icons/up-arrow.blade.php new file mode 100644 index 0000000..7867615 --- /dev/null +++ b/resources/views/components/icons/up-arrow.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/layout/navbar/menus/admin.blade.php b/resources/views/components/layout/navbar/menus/admin.blade.php index b9cff60..cfae63a 100644 --- a/resources/views/components/layout/navbar/menus/admin.blade.php +++ b/resources/views/components/layout/navbar/menus/admin.blade.php @@ -25,6 +25,9 @@ Schools Students Entries + @if(auditionSetting('nomination_ensemble_rules') !== 'disabled') + Nominations + @endif View Logs Export Results Export Entries diff --git a/resources/views/components/layout/navbar/menus/my_audition.blade.php b/resources/views/components/layout/navbar/menus/my_audition.blade.php index 00ea2ba..147b5d1 100644 --- a/resources/views/components/layout/navbar/menus/my_audition.blade.php +++ b/resources/views/components/layout/navbar/menus/my_audition.blade.php @@ -29,6 +29,9 @@ @if(Auth::user()->school_id) My Students My Entries + @if(auditionSetting('nomination_ensemble_rules') !== 'disabled') + My Nominations + @endif My Doubler Requests My School @if(auditionSetting('invoicing_enabled')) diff --git a/resources/views/components/layout/navbar/menus/setup.blade.php b/resources/views/components/layout/navbar/menus/setup.blade.php index 892a9d2..807811d 100644 --- a/resources/views/components/layout/navbar/menus/setup.blade.php +++ b/resources/views/components/layout/navbar/menus/setup.blade.php @@ -34,6 +34,9 @@ Print Cards Print Sign-In Sheets Print Room and Judge Assignments + @if(auditionSetting('nomination_ensemble_rules') !== 'disabled') + Nomination Ensemble Setup + @endif diff --git a/resources/views/components/layout/navbar/menus/tabulation.blade.php b/resources/views/components/layout/navbar/menus/tabulation.blade.php index bc0f361..ba33e98 100644 --- a/resources/views/components/layout/navbar/menus/tabulation.blade.php +++ b/resources/views/components/layout/navbar/menus/tabulation.blade.php @@ -33,6 +33,9 @@ @if(auditionSetting('advanceTo')) {{ auditionSetting('advanceTo') }} Status @endif + @if(auditionSetting('nomination_ensemble_rules') !== 'disabled') + Nomination Ensemble Seating + @endif diff --git a/resources/views/nomination_ensembles/scobda/admin/ensembles/index.blade.php b/resources/views/nomination_ensembles/scobda/admin/ensembles/index.blade.php new file mode 100644 index 0000000..cec3d9f --- /dev/null +++ b/resources/views/nomination_ensembles/scobda/admin/ensembles/index.blade.php @@ -0,0 +1,73 @@ + + Nomination Ensembles + + + + Add Nomination Ensemble + + + + + + + + + + Round + + + + + Instrument List (comma separated) + + + + + + + + Nomination Ensembles +
+ @foreach($ensembles as $ensemble) + + + {{ $ensemble->name }} + + + + Are you sure you want to delete this nomination ensemble? + + + + + @method('PATCH') + + + + + + + + + Round + + + + + Instrument List (comma separated) + {{ implode(', ',$ensemble->data['instruments']) }} + + + + + + @endforeach +
+
+
+ +
diff --git a/resources/views/nomination_ensembles/scobda/admin/index.blade.php b/resources/views/nomination_ensembles/scobda/admin/index.blade.php new file mode 100644 index 0000000..1d5f461 --- /dev/null +++ b/resources/views/nomination_ensembles/scobda/admin/index.blade.php @@ -0,0 +1,84 @@ + + + Nomination Administration + + + Nominations + + + Name + School + Nomination + Rank + Instrument + + + + @foreach($nominations as $nomination) + + {{ $nomination->student->full_name('lf') }} + {{ $nomination->student->school->name }} + {{ $nomination->ensemble->name }} + {{ $nomination->data['rank'] }} + {{ $nomination->data['instrument'] }} + + @endforeach + + + + diff --git a/resources/views/nomination_ensembles/scobda/admin/seating/index.blade.php b/resources/views/nomination_ensembles/scobda/admin/seating/index.blade.php new file mode 100644 index 0000000..4370a76 --- /dev/null +++ b/resources/views/nomination_ensembles/scobda/admin/seating/index.blade.php @@ -0,0 +1,67 @@ + + Nomination Ensemble Seating + +
+ +
+ +
+ +
+ @if($ensemble) + + + {{ $ensemble->name }} + + @if($ensemble->data['seated'] ?? false) + + + Clear Seats + + @else + + + Seat Ensemble + + @endif + + + + @foreach($ensemble->data['instruments'] as $instrument) + @php($seatOn = 1) + @continue(! $acceptedNominations->has($instrument)) + + {{ $instrument }} + Student Name + School (Nom Rank) + + @foreach($acceptedNominations[$instrument] as $nom) + + {{ $seatOn }} + {{ $nom->student->full_name() }} + {{ $nom->student->school->name }} ({{ $nom->data['rank'] }}) + + + @php($seatOn++) + @endforeach + @endforeach + + + @endif +
+ +
+ +
diff --git a/resources/views/nomination_ensembles/scobda/entries/index.blade.php b/resources/views/nomination_ensembles/scobda/entries/index.blade.php new file mode 100644 index 0000000..0e7c6d6 --- /dev/null +++ b/resources/views/nomination_ensembles/scobda/entries/index.blade.php @@ -0,0 +1,99 @@ +@php($n=1) + + + Nomination Entries + + + + @foreach($ensembles as $ensemble) + + {{ $ensemble->name }} + + {{ $ensemble->data['max_nominations'] }} nominations accepted
+ Entry Deadline {{ \Carbon\Carbon::parse($ensemble->entry_deadline)->format('M j, Y') }} +
+ + + + Rank + Student + Instrument + + + + {{-- List existing nominations--}} + @foreach($nominatedStudents[$ensemble->id] as $nomination) + + {{ $nomination->data['rank'] }} + {{ $nomination->student->full_name() }} + {{ $nomination->data['instrument'] }} + @if($currentDate <= $ensemble->entry_deadline) + + + Confirm you wish to delete the nomination + of {{ $nomination->student->full_name() }}
+ for the {{ $ensemble->name }} ensemble. +
+
+ @csrf + + + +
+
+ @csrf + + + +
+
+ @endif + + @endforeach + + {{-- LINE TO ADD A NOMINATION--}} + @if($currentDate <= $ensemble->entry_deadline && $nominationsAvailable[$ensemble->id] && $availableStudents[$ensemble->id]->count() > 0) + + + + NEW + + + @foreach($availableStudents[$ensemble->id] as $student) + + @endforeach + + + + + + @foreach($availableInstruments[$ensemble->id] as $instrument) + + @endforeach + + + + + Add + + + + @endif +
+
+ +
+ @endforeach + +
+ +
diff --git a/resources/views/students/edit.blade.php b/resources/views/students/edit.blade.php index 747982a..70a6b5a 100644 --- a/resources/views/students/edit.blade.php +++ b/resources/views/students/edit.blade.php @@ -7,7 +7,15 @@ - + @if(auditionSetting('student_data_collect_shirt_size')) + + Shirt Size + @foreach($shirtSizes as $abbreviation => $name) + + @endforeach + + @endif + diff --git a/resources/views/students/index.blade.php b/resources/views/students/index.blade.php index 090f5f4..5b9bd5f 100644 --- a/resources/views/students/index.blade.php +++ b/resources/views/students/index.blade.php @@ -1,4 +1,4 @@ -@php use App\Models\Audition;use Illuminate\Support\Facades\Auth; @endphp +@php use App\Models\Audition;use App\Models\NominationEnsemble;use Illuminate\Support\Facades\Auth; @endphp Students @@ -8,19 +8,29 @@ Add Student - + - {{-- --}} Grade - @php($n = Audition::min('minimum_grade')) - @php($maxGrade = Audition::max('maximum_grade')) + @php($n = min(Audition::min('minimum_grade'),NominationEnsemble::min('minimum_grade'))) + @php($maxGrade = max(Audition::max('maximum_grade'), NominationEnsemble::max('maximum_grade'))) @while($n <= $maxGrade) @php($n++); @endwhile + + @if(auditionSetting('student_data_collect_shirt_size')) + + Shirt Size + @foreach($shirtSizes as $abbreviation => $name) + + @endforeach + + @endif + + Save @@ -35,21 +45,28 @@ Name Grade + @if(auditionSetting('student_data_collect_shirt_size')) + Shirt + @endif Edit - + @foreach($students as $student) {{ $student->full_name(true) }} {{ $student->grade }} + @if(auditionSetting('student_data_collect_shirt_size')) + {{ $student->optional_data['shirt_size'] ?? '' }} + @endif @if( $student->entries_count === 0) -
+ @csrf @method('DELETE') prefix('nomination/admin/')->group(function () { + Route::prefix('ensemble/')->controller(NominationEnsembleController::class)->group(function () { + Route::get('/', 'index')->name('nomination.admin.ensemble.index'); + Route::post('/', 'store')->name('nomination.admin.ensemble.store'); + Route::patch('/{ensemble}', 'update')->name('nomination.admin.ensemble.update'); + Route::delete('/{ensemble}', 'destroy')->name('nomination.admin.ensemble.destroy'); + }); + + Route::prefix('nominations/')->controller(NominationAdminController::class)->group(function () { + Route::get('/', 'index')->name('nomination.admin.index'); + }); + + Route::prefix('seating/')->controller(NominationSeatingController::class)->group(function () { + Route::get('/', 'index')->name('nomination.admin.seating.index'); + Route::get('/{ensemble}', 'show')->name('nomination.admin.seating.show'); + Route::post('/{ensemble}', 'seat')->name('nomination.admin.seating.seat'); + }); +}); + +Route::middleware(['auth', 'verified'])->prefix('nominations/')->group(function () { + Route::controller(NominationEnsembleEntryController::class)->group(function () { + Route::get('/', 'index')->name('nomination.entry.index'); + Route::post('/', 'store')->name('nomination.entry.store'); + Route::delete('/{entry}', 'destroy')->name('nomination.entry.destroy'); + Route::post('/move', 'move')->name('nomination.entry.move'); + }); +}); diff --git a/routes/web.php b/routes/web.php index de4abbe..8ca8d03 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ require __DIR__.'/admin.php'; require __DIR__.'/judging.php'; require __DIR__.'/tabulation.php'; require __DIR__.'/user.php'; +require __DIR__.'/nominationEnsemble.php'; Route::get('/test', [TestController::class, 'flashTest'])->middleware('auth', 'verified');