Auditionadmin 20 - Bonus scores are fully functional #25

Merged
okorpheus merged 16 commits from auditionadmin-20 into master 2024-07-16 08:04:08 +00:00
51 changed files with 1726 additions and 63 deletions

View File

@ -5,20 +5,27 @@
namespace App\Actions\Tabulation; namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException; use App\Exceptions\TabulationException;
use App\Models\BonusScore;
use App\Models\Entry; use App\Models\Entry;
use App\Services\AuditionService; use App\Services\AuditionService;
use App\Services\EntryService; use App\Services\EntryService;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use function auditionSetting; use function auditionSetting;
class AllowForOlympicScoring implements CalculateEntryScore class AllowForOlympicScoring implements CalculateEntryScore
{ {
protected CalculateScoreSheetTotal $calculator; protected CalculateScoreSheetTotal $calculator;
protected AuditionService $auditionService; protected AuditionService $auditionService;
protected EntryService $entryService; protected EntryService $entryService;
public function __construct(CalculateScoreSheetTotal $calculator, AuditionService $auditionService, EntryService $entryService) public function __construct(
{ CalculateScoreSheetTotal $calculator,
AuditionService $auditionService,
EntryService $entryService
) {
$this->calculator = $calculator; $this->calculator = $calculator;
$this->auditionService = $auditionService; $this->auditionService = $auditionService;
$this->entryService = $entryService; $this->entryService = $entryService;
@ -28,6 +35,7 @@ class AllowForOlympicScoring implements CalculateEntryScore
{ {
$cacheKey = 'entryScore-'.$entry->id.'-'.$mode; $cacheKey = 'entryScore-'.$entry->id.'-'.$mode;
return Cache::remember($cacheKey, 10, function () use ($mode, $entry) { return Cache::remember($cacheKey, 10, function () use ($mode, $entry) {
$this->basicValidation($mode, $entry); $this->basicValidation($mode, $entry);
$this->areAllJudgesIn($entry); $this->areAllJudgesIn($entry);
@ -38,7 +46,7 @@ class AllowForOlympicScoring implements CalculateEntryScore
} }
protected function getJudgeTotals($mode, Entry $entry) protected function getJudgeTotals($mode, Entry $entry): array
{ {
$scores = []; $scores = [];
@ -66,9 +74,35 @@ class AllowForOlympicScoring implements CalculateEntryScore
$index++; $index++;
} }
} }
// add the bonus points for a seating mode
if ($mode === 'seating' && $sums) {
$sums[0] += $this->getBonusPoints($entry);
}
return $sums; return $sums;
} }
protected function getBonusPoints(Entry $entry)
{
$bonusScoreDefinition = $entry->audition->bonusScore()->first();
if (! $bonusScoreDefinition) {
return 0;
}
/** @noinspection PhpPossiblePolymorphicInvocationInspection */
$bonusJudges = $bonusScoreDefinition->judges;
$bonusScoreSheets = BonusScore::where('entry_id', $entry->id)->get();
foreach ($bonusScoreSheets as $sheet) {
if (! $bonusJudges->contains($sheet->user_id)) {
throw new TabulationException('Entry has a bonus score from unassigned judge');
}
}
// sum the score property of the $bonusScoreSheets
return $bonusScoreSheets->sum('score');
}
protected function basicValidation($mode, $entry): void protected function basicValidation($mode, $entry): void
{ {
if ($mode !== 'seating' && $mode !== 'advancement') { if ($mode !== 'seating' && $mode !== 'advancement') {

View File

@ -0,0 +1,79 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\ScoreEntryException;
use App\Models\BonusScore;
use App\Models\Entry;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
class EnterBonusScore
{
public function __construct()
{
}
public function __invoke(User $judge, Entry $entry, int $score): void
{
$getRelatedEntries = App::make(GetBonusScoreRelatedEntries::class);
$this->basicValidations($judge, $entry);
$this->validateJudgeValidity($judge, $entry, $score);
$entries = $getRelatedEntries($entry);
// Create the score for each related entry
foreach ($entries as $relatedEntry) {
BonusScore::create([
'entry_id' => $relatedEntry->id,
'user_id' => $judge->id,
'originally_scored_entry' => $entry->id,
'score' => $score,
]);
}
}
protected function getRelatedEntries(Entry $entry): Collection
{
$bonusScore = $entry->audition->bonusScore->first();
$relatedAuditions = $bonusScore->auditions;
// Get all entries that have a student_id equal to that of entry and an audition_id in the related auditions
return Entry::where('student_id', $entry->student_id)
->whereIn('audition_id', $relatedAuditions->pluck('id'))
->get();
}
protected function basicValidations(User $judge, Entry $entry): void
{
if (! $judge->exists) {
throw new ScoreEntryException('Invalid judge provided');
}
if (! $entry->exists) {
throw new ScoreEntryException('Invalid entry provided');
}
if ($entry->audition->bonusScore->count() === 0) {
throw new ScoreEntryException('Entry does not have a bonus score');
}
}
protected function validateJudgeValidity(User $judge, Entry $entry, $score): void
{
if (BonusScore::where('entry_id', $entry->id)->where('user_id', $judge->id)->exists()) {
throw new ScoreEntryException('That judge has already scored that entry');
}
$bonusScore = $entry->audition->bonusScore->first();
if (! $bonusScore->judges->contains($judge)) {
throw new ScoreEntryException('That judge is not assigned to judge that bonus score');
}
if ($score > $bonusScore->max_score) {
throw new ScoreEntryException('That score exceeds the maximum');
}
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Entry;
use Illuminate\Database\Eloquent\Collection;
class GetBonusScoreRelatedEntries
{
public function __construct()
{
}
public function __invoke(Entry $entry): Collection
{
return $this->getRelatedEntries($entry);
}
public function getRelatedEntries(Entry $entry): Collection
{
$bonusScore = $entry->audition->bonusScore->first();
$relatedAuditions = $bonusScore->auditions;
// Get all entries that have a student_id equal to that of entry and an audition_id in the related auditions
return Entry::where('student_id', $entry->student_id)
->whereIn('audition_id', $relatedAuditions->pluck('id'))
->get();
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use App\Models\BonusScoreDefinition;
use App\Models\User;
use App\Rules\ValidateAuditionKey;
use Exception;
use Illuminate\Http\Request;
use function redirect;
use function to_route;
class BonusScoreDefinitionController extends Controller
{
public function index()
{
$bonusScores = BonusScoreDefinition::with('auditions')->get();
// Set auditions equal to the collection of auditions that do not have a related bonus score
$unassignedAuditions = Audition::orderBy('score_order')->doesntHave('bonusScore')->get();
return view('admin.bonus-scores.index', compact('bonusScores', 'unassignedAuditions'));
}
public function store()
{
$validData = request()->validate([
'name' => 'required',
'max_score' => 'required|numeric',
'weight' => 'required|numeric',
]);
BonusScoreDefinition::create($validData);
return to_route('admin.bonus-scores.index')->with('success', 'Bonus Score Created');
}
public function destroy(BonusScoreDefinition $bonusScore)
{
if ($bonusScore->auditions()->count() > 0) {
return to_route('admin.bonus-scores.index')->with('error', 'Bonus Score has auditions attached');
}
$bonusScore->delete();
return to_route('admin.bonus-scores.index')->with('success', 'Bonus Score Deleted');
}
public function assignAuditions(Request $request)
{
$validData = $request->validate([
'bonus_score_id' => 'required|exists:bonus_score_definitions,id',
'audition' => 'required|array',
'audition.*' => ['required', new ValidateAuditionKey()],
]);
$bonusScore = BonusScoreDefinition::find($validData['bonus_score_id']);
foreach ($validData['audition'] as $auditionId => $value) {
try {
$bonusScore->auditions()->attach($auditionId);
} catch (Exception) {
return redirect()->route('admin.bonus-scores.index')->with('error',
'Error assigning auditions to bonus score');
}
}
return redirect()->route('admin.bonus-scores.index')->with('success', 'Auditions assigned to bonus score');
}
public function unassignAudition(Audition $audition)
{
if (! $audition->exists()) {
return redirect()->route('admin.bonus-scores.index')->with('error', 'Audition not found');
}
if (! $audition->bonusScore()->count() > 0) {
return redirect()->route('admin.bonus-scores.index')->with('error', 'Audition does not have a bonus score');
}
$audition->bonusScore()->detach();
return redirect()->route('admin.bonus-scores.index')->with('success', 'Audition unassigned from bonus score');
}
public function judges()
{
$bonusScores = BonusScoreDefinition::all();
$users = User::orderBy('last_name')->orderBy('first_name')->get();
return view('admin.bonus-scores.judge-assignments', compact('bonusScores', 'users'));
}
public function assignJudge(BonusScoreDefinition $bonusScore)
{
if (! $bonusScore->exists()) {
return redirect()->route('admin.bonus-scores.judges')->with('error', 'Bonus Score not found');
}
$validData = request()->validate([
'judge' => 'required|exists:users,id',
]);
$bonusScore->judges()->attach($validData['judge']);
return redirect()->route('admin.bonus-scores.judges')->with('success', 'Judge assigned to bonus score');
}
public function removeJudge(BonusScoreDefinition $bonusScore)
{
if (! $bonusScore->exists()) {
return redirect()->route('admin.bonus-scores.judges')->with('error', 'Bonus Score not found');
}
$validData = request()->validate([
'judge' => 'required|exists:users,id',
]);
$bonusScore->judges()->detach($validData['judge']);
return redirect()->route('admin.bonus-scores.judges')->with('success', 'Judge removed from bonus score');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\BonusScoreDefinition;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -17,7 +18,7 @@ class RoomController extends Controller
if (! Auth::user()->is_admin) { if (! Auth::user()->is_admin) {
abort(403); abort(403);
} }
$rooms = Room::with('auditions.entries','entries')->orderBy('name')->get(); $rooms = Room::with('auditions.entries', 'entries')->orderBy('name')->get();
return view('admin.rooms.index', ['rooms' => $rooms]); return view('admin.rooms.index', ['rooms' => $rooms]);
} }
@ -27,8 +28,9 @@ class RoomController extends Controller
$usersWithoutRooms = User::doesntHave('rooms')->orderBy('last_name')->orderBy('first_name')->get(); $usersWithoutRooms = User::doesntHave('rooms')->orderBy('last_name')->orderBy('first_name')->get();
$usersWithRooms = User::has('rooms')->orderBy('last_name')->orderBy('first_name')->get(); $usersWithRooms = User::has('rooms')->orderBy('last_name')->orderBy('first_name')->get();
$rooms = Room::with(['judges.school', 'auditions'])->get(); $rooms = Room::with(['judges.school', 'auditions'])->get();
$bonusScoresExist = BonusScoreDefinition::count() > 0;
return view('admin.rooms.judge_assignments', compact('usersWithoutRooms', 'usersWithRooms', 'rooms')); return view('admin.rooms.judge_assignments', compact('usersWithoutRooms', 'usersWithRooms', 'rooms', 'bonusScoresExist'));
} }
public function updateJudgeAssignment(Request $request, Room $room) public function updateJudgeAssignment(Request $request, Room $room)

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Judging;
use App\Http\Controllers\Controller;
use App\Models\BonusScore;
use App\Models\BonusScoreDefinition;
use App\Models\Entry;
use Illuminate\Support\Facades\Auth;
use function redirect;
class BonusScoreEntryController extends Controller
{
public function __invoke(Entry $entry)
{
if (BonusScore::where('entry_id', $entry->id)->where('user_id', Auth::user()->id)->exists()) {
return redirect()->route('judging.bonusScore.EntryList', $entry->audition)->with('error', 'You have already judged that entry');
}
/** @var BonusScoreDefinition $bonusScore */
$bonusScore = $entry->audition->bonusScore()->first();
if (! $bonusScore->judges->contains(auth()->id())) {
return redirect()->route('judging.index')->with('error', 'You are not assigned to judge this entry');
}
$maxScore = $bonusScore->max_score;
$bonusName = $bonusScore->name;
return view('judging.bonus_entry_score_sheet', compact('entry', 'maxScore', 'bonusName'));
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Judging;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\BonusScoreDefinition;
use Illuminate\Support\Facades\Auth;
class BonusScoreEntryListController extends Controller
{
public function __invoke(Audition $audition)
{
/** @var BonusScoreDefinition $bonusScore */
$bonusScore = $audition->bonusScore()->first();
if (! $bonusScore->judges->contains(auth()->id())) {
return redirect()->route('dashboard')->with('error', 'You are not assigned to judge this bonus score');
}
$entries = $audition->entries()->orderBy('draw_number')->get();
$entries = $entries->reject(fn ($entry) => $entry->hasFlag('no_show'));
$entries->each(fn ($entry) => $entry->audition = $audition);
$scores = BonusScore::where('user_id', Auth::user()->id)
->with('entry.audition')
->with('originallyScoredEntry.audition')
->get()
->keyBy('entry_id');
return view('judging.bonus_score_entry_list', compact('audition', 'entries', 'scores'));
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Judging;
use App\Actions\Tabulation\EnterBonusScore;
use App\Exceptions\ScoreEntryException;
use App\Http\Controllers\Controller;
use App\Models\Entry;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
class BonusScoreRecordController extends Controller
{
public function __invoke(Entry $entry)
{
$enterBonusScore = App::make(EnterBonusScore::class);
$validData = request()->validate([
'score' => 'required|integer',
]);
try {
$enterBonusScore(Auth::user(), $entry, $validData['score']);
} catch (ScoreEntryException $ex) {
return redirect()->back()->with('error', 'Score Entry Error - '.$ex->getMessage());
}
return redirect()->route('judging.bonusScore.EntryList', $entry->audition)->with('Score Recorded Successfully');
}
}

View File

@ -1,10 +1,11 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Judging;
use App\Actions\Tabulation\EnterScore; use App\Actions\Tabulation\EnterScore;
use App\Exceptions\AuditionServiceException; use App\Exceptions\AuditionServiceException;
use App\Exceptions\ScoreEntryException; use App\Exceptions\ScoreEntryException;
use App\Http\Controllers\Controller;
use App\Models\Audition; use App\Models\Audition;
use App\Models\Entry; use App\Models\Entry;
use App\Models\JudgeAdvancementVote; use App\Models\JudgeAdvancementVote;
@ -30,10 +31,11 @@ class JudgingController extends Controller
public function index() public function index()
{ {
$rooms = Auth::user()->judgingAssignments; $rooms = Auth::user()->judgingAssignments()->with('auditions')->get();
$rooms->load('auditions'); $bonusScoresToJudge = Auth::user()->bonusJudgingAssignments()->with('auditions')->get();
return view('judging.index', compact('rooms')); //$rooms->load('auditions');
return view('judging.index', compact('rooms', 'bonusScoresToJudge'));
} }
public function auditionEntryList(Request $request, Audition $audition) public function auditionEntryList(Request $request, Audition $audition)

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\EnterBonusScore;
use App\Actions\Tabulation\GetBonusScoreRelatedEntries;
use App\Exceptions\ScoreEntryException;
use App\Http\Controllers\Controller;
use App\Models\BonusScore;
use App\Models\Entry;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use function request;
class BonusScoreController extends Controller
{
public function chooseEntry()
{
$method = 'GET';
$formRoute = 'bonus-scores.entryBonusScoreSheet';
$title = 'Enter Bonus Scores';
return view('tabulation.choose_entry', compact('method', 'formRoute', 'title'));
}
public function entryBonusScoreSheet(GetBonusScoreRelatedEntries $getRelatedEntries)
{
$validData = request()->validate([
'entry_id' => 'required|exists:entries,id',
]);
$entry = Entry::find($validData['entry_id']);
$bonusScoreDefinition = $entry->audition->bonusScore->first();
$assignedJudges = $bonusScoreDefinition->judges;
$relatedEntries = $getRelatedEntries($entry);
$existingScores = [];
foreach ($relatedEntries as $related) {
$existingScores[$related->id] = BonusScore::where('entry_id', $related->id)
->with('judge')
->with('entry')
->with('originallyScoredEntry')
->get();
}
return view('tabulation.bonus-score-sheet',
compact('entry', 'bonusScoreDefinition', 'assignedJudges', 'existingScores', 'relatedEntries'));
}
public function saveEntryBonusScoreSheet(Entry $entry, GetBonusScoreRelatedEntries $getRelatedEntries, EnterBonusScore $saveBonusScore)
{
$validData = request()->validate([
'judge_id' => 'required|exists:users,id',
'entry_id' => 'required|exists:entries,id',
'score' => 'nullable|numeric',
]);
$judge = User::find($validData['judge_id']);
$entry = Entry::find($validData['entry_id']);
$relatedEntries = $getRelatedEntries($entry);
try {
DB::beginTransaction();
// Delete existing bonus scores for the entries by the judge
foreach ($relatedEntries as $related) {
BonusScore::where('entry_id', $related->id)->where('user_id', $judge->id)->delete();
}
// If no score was submitted, were going to just stop at deleting the scores
if (! $validData['score'] == null) {
// Set the new score
try {
$saveBonusScore($judge, $entry, $validData['score']);
} catch (ScoreEntryException $ex) {
DB::rollBack();
return redirect()->route('bonus-scores.entryBonusScoreSheet',
['entry_id' => $entry->id])->with('error', 'Error entering score - '.$ex->getMessage());
}
}
DB::commit();
} catch (\Exception) {
DB::rollBack();
return redirect()->route('bonus-scores.entryBonusScoreSheet', ['entry_id' => $entry->id])->with('error', 'Error entering score - '.$ex->getMessage());
}
return redirect()->route('bonus-scores.entryBonusScoreSheet', ['entry_id' => $entry->id])->with('success', 'New bonus score entered');
}
public function destroyBonusScore()
{
}
}

View File

@ -15,8 +15,9 @@ class EntryFlagController extends Controller
{ {
$method = 'GET'; $method = 'GET';
$formRoute = 'entry-flags.confirmNoShow'; $formRoute = 'entry-flags.confirmNoShow';
$title = 'No Show';
return view('tabulation.choose_entry', compact('method', 'formRoute')); return view('tabulation.choose_entry', compact('method', 'formRoute', 'title'));
} }
public function noShowConfirm(Request $request) public function noShowConfirm(Request $request)

View File

@ -14,8 +14,9 @@ class ScoreController extends Controller
{ {
$method = 'GET'; $method = 'GET';
$formRoute = 'scores.entryScoreSheet'; $formRoute = 'scores.entryScoreSheet';
$title = 'Enter Scores';
return view('tabulation.choose_entry', compact('method', 'formRoute')); return view('tabulation.choose_entry', compact('method', 'formRoute', 'title'));
} }
public function destroyScore(ScoreSheet $score) public function destroyScore(ScoreSheet $score)

View File

@ -48,6 +48,11 @@ class Audition extends Model
return $this->belongsTo(ScoringGuide::class); return $this->belongsTo(ScoringGuide::class);
} }
public function bonusScore(): BelongsToMany
{
return $this->belongsToMany(BonusScoreDefinition::class, 'bonus_score_audition_assignment');
}
public function display_fee(): string public function display_fee(): string
{ {
return '$'.number_format($this->entry_fee / 100, 2); return '$'.number_format($this->entry_fee / 100, 2);

26
app/Models/BonusScore.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BonusScore extends Model
{
protected $guarded = [];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
public function judge(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function originallyScoredEntry(): BelongsTo
{
return $this->belongsTo(Entry::class, 'originally_scored_entry');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class BonusScoreDefinition extends Model
{
use HasFactory;
protected $fillable = ['name', 'max_score', 'weight'];
public function auditions(): BelongsToMany
{
return $this->belongsToMany(Audition::class, 'bonus_score_audition_assignment')->orderBy('score_order');
}
public function judges(): BelongsToMany
{
return $this->belongsToMany(User::class, 'bonus_score_judge_assignment');
}
}

View File

@ -7,10 +7,10 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Facades\Cache;
class Entry extends Model class Entry extends Model
{ {
@ -55,6 +55,11 @@ class Entry extends Model
} }
public function bonusScores(): BelongsToMany
{
return $this->belongsToMany(BonusScore::class);
}
public function advancementVotes(): HasMany public function advancementVotes(): HasMany
{ {
return $this->hasMany(JudgeAdvancementVote::class); return $this->hasMany(JudgeAdvancementVote::class);

View File

@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use App\Models\ScoreSheet;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
@ -118,6 +117,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->rooms(); return $this->rooms();
} }
public function bonusJudgingAssignments(): BelongsToMany
{
return $this->belongsToMany(BonusScoreDefinition::class, 'bonus_score_judge_assignment');
}
public function advancementVotes(): HasMany public function advancementVotes(): HasMany
{ {
return $this->hasMany(JudgeAdvancementVote::class); return $this->hasMany(JudgeAdvancementVote::class);
@ -125,7 +129,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function isJudge(): bool public function isJudge(): bool
{ {
return $this->judgingAssignments()->count() > 0; return $this->judgingAssignments()->count() > 0 || $this->bonusJudgingAssignments()->count() > 0;
} }
/** /**

View File

@ -0,0 +1,19 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\DB;
class ValidateAuditionKey implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Extract the key from the attribute
$key = explode('.', $attribute)[1];
if (! DB::table('auditions')->where('id', $key)->exists()) {
$fail('Invalid audition id provided');
}
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BonusScoreDefinition>
*/
class BonusScoreDefinitionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->word,
'max_score' => $this->faker->randomNumber(2),
'weight' => $this->faker->randomFloat(2, 0, 2),
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bonus_score_definitions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->integer('max_score');
$table->float('weight');
$table->boolean('for_seating')->default(true);
$table->boolean('for_attendance')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bonus_score_definitions');
}
};

View File

@ -0,0 +1,34 @@
<?php
use App\Models\Audition;
use App\Models\BonusScoreDefinition;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bonus_score_audition_assignment', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(BonusScoreDefinition::class)
->constrained('bonus_score_definitions', 'id', 'bs_audition_assignment_bonus_score_definition_id')
->onDelete('cascade')->onUpdate('cascade');
$table->foreignIdFor(Audition::class)->unique()
->constrained()->onDelete('cascade')->onUpdate('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bonus_score_audition_assignment');
}
};

View File

@ -0,0 +1,33 @@
<?php
use App\Models\Entry;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bonus_scores', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Entry::class);
$table->foreignIdFor(User::class);
$table->foreignId('originally_scored_entry')->nullable()->constrained('entries')->nullOnDelete();
$table->integer('score');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bonus_scores');
}
};

View File

@ -0,0 +1,34 @@
<?php
use App\Models\BonusScoreDefinition;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bonus_score_judge_assignment', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(BonusScoreDefinition::class)
->constrained('bonus_score_definitions', 'id', 'bs_judge_assignment_bonus_score_definition_id')
->onDelete('cascade')->onUpdate('cascade');
$table->foreignIdFor(User::class)
->constrained()->onDelete('cascade')->onUpdate('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bonus_score_judge_assignment');
}
};

View File

@ -0,0 +1,15 @@
<x-modal-body showVar="showAddAuditionModal">
<x-slot:title>Add auditions to <span x-text="addAuditionToName"></span></x-slot:title>
<x-form.form method="POST" action="{{ route('admin.bonus-scores.addAuditions') }}">
<input type="hidden" name=bonus_score_id x-bind:value="addAuditionTo">
<div class="grid grid-cols-3">
@foreach($unassignedAuditions as $audition)
<div class="mx-5 my-1">
<x-form.checkbox name="audition[{{$audition->id}}]" label="{{ $audition->name }}" />
</div>
@endforeach
</div>
<x-form.button class="mt-3" type="submit">Add Checked Auditions</x-form.button>
</x-form.form>
</x-modal-body>

View File

@ -0,0 +1,16 @@
<x-modal-body show-var="showAddBonusScoreModal">
<x-slot:title>
Add Bonus Score
</x-slot:title>
<x-form.form id="create-bonus-score-form" action="{{ route('admin.bonus-scores.store') }}">
<x-form.body-grid columns="12">
<x-form.field name="name" label_text="Name" colspan="8" />
<x-form.field name="max_score" type="number" label_text="Max Points" colspan="2" />
<x-form.field name="weight" label_text="Weight" colspan="2" />
<div class="col-start-9 col-span-4 row-start-2">
<x-form.button >Create Bonus Score</x-form.button>
</div>
</x-form.body-grid>
</x-form.form>
</x-modal-body>

View File

@ -0,0 +1,24 @@
<x-help-modal title="Bonus Score Definitions">
<p class="mb-5">Bonus scores are most often used for an improvisation score for jazz band auditions. A bonus score
earned by an entry will be directly added
to that entries final score. When you create a bonus score, you will also specify to which auditions that bonus
score should apply. When a student
earns a bonus score for one entry, that bonus will be applied to all entries that receive that bonus score.</p>
<p class="mb-5">
Let's say you create a bonus score called, "Saxophone Improvisation," and assign Jazz Alto, Jazz Tenor, and Jazz
Bari auditions to that bonus
score. If a student is entered on all three saxes, when they receive an improv score on one sax, that score will
apply to all 3. The system
will not allow another improv score to be assigned by the same judge unless the first one is deleted. If you
want that student to improv on each instrument
separately, you will need to create a separate bonus score for each instrument.
</p>
<P>
The weight allows you to control how much influence the bonus score has on the outcome of the audition. The
bonus score is
multiplied by the weight then added to the final score. The weight may be any positive number, including
decimals.
</P>
</x-help-modal>

View File

@ -0,0 +1,22 @@
<div class="text-center">
<svg class="mx-auto w-12 h-12 text-gray-400 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">No bonus scores have been created</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new bonus score.</p>
<div class="mt-6">
<button type="button"
x-on:click="showAddBonusScoreModal = true"
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
<svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"/>
</svg>
New Bonus Score
</button>
</div>
</div>

View File

@ -0,0 +1,53 @@
<x-layout.app x-data="{ showAddBonusScoreModal: false, addAuditionTo: null, showAddAuditionModal: false, addAuditionToName: null }">
<x-slot:page_title>Bonus Score Management</x-slot:page_title>
<x-slot:title_bar_right>
@include('admin.bonus-scores.index-help-modal')
</x-slot:title_bar_right>
@if($bonusScores->count() === 0)
@include('admin.bonus-scores.index-no-bonus-scores-message')
@endif
@foreach($bonusScores as $bonusScore)
<x-card.card class="mx-auto max-w-xl mb-5">
<x-card.heading>
{{ $bonusScore->name }}
<x-slot:subheading>
Max Points: {{ $bonusScore->max_score }} | Weight: {{ $bonusScore->weight }}
</x-slot:subheading>
<x-slot:right_side>
@if($bonusScore->auditions()->count() === 0)
<x-delete-resource-modal title="Delete Bonus Score" action="{{route('admin.bonus-scores.destroy', $bonusScore)}}">
Confirm you want to delete the bonus score {{ $bonusScore->name }}
</x-delete-resource-modal>
@endif
</x-slot:right_side>
</x-card.heading>
<div class="grid grid-cols-3 mx-5 my-2">
@foreach($bonusScore->auditions as $audition)
<div class="flex gap-x-2">
<form method="post" id="unassign{{$audition->id}}" action="{{ route('admin.bonus-scores.unassignAudition', $audition) }}">
@csrf
@method('DELETE')
<button type="submit">
<x-icons.circled-x color="crimson" />
</button>
</form>
{{ $audition->name }}
</div>
@endforeach
</div>
<x-form.button
x-on:click="showAddAuditionModal=true; addAuditionTo={{ $bonusScore->id }}; addAuditionToName='{{ $bonusScore->name }}'"
class="mx-auto max-w-sm mb-3">
Add Auditions to {{ $bonusScore->name }}
</x-form.button>
</x-card.card>
@endforeach
@if($bonusScores->count() !== 0)
<x-form.button class="mx-auto max-w-xs mt-5" x-on:click="showAddBonusScoreModal=true">Add Bonus Score</x-form.button>
@endif
@include('admin.bonus-scores.index-add-auditions-to-bonus-modal')
@include('admin.bonus-scores.index-add-bonus-score-modal')
</x-layout.app>

View File

@ -0,0 +1,111 @@
<x-layout.app>
<div class="bg-white pt-3 pb-1 px-3 rounded-md">
<div class="mb-3">
<nav class="flex space-x-4" aria-label="Tabs">
<!-- Current: "bg-indigo-100 text-indigo-700", Default: "text-gray-500 hover:text-gray-700" -->
<a href="{{route('admin.rooms.judgingAssignment')}}" class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">Room Judges</a>
<a href="{{route('admin.bonus-scores.judges')}}" class="rounded-md px-3 py-2 text-sm font-medium bg-indigo-100 text-indigo-700">Bonus Judges</a>
</nav>
</div>
</div>
<ul class="grid md:grid-cols-4 gap-5 mt-5">
@foreach($bonusScores as $bonusScore)
<li id="bonus-{{$bonusScore->id}}-card" class=" rounded-xl border border-gray-200 bg-gray-50 "> {{-- card wrapper --}}
<div class="flex items-center gap-x-4 border-b border-gray-900/5 bg-white pt-2 pb-6 px-6"> {{-- card header --}}
<div class="text-sm font-medium leading-6 text-gray-900">
<p class="text-sm font-medium leading-6 text-gray-900">{{ $bonusScore->name }}</p>
</div>
<div class="relative ml-auto" x-data="{ open: false }"> {{-- Auditions Dropdown --}}
<button type="button"
class="-m-2.5 block p-2.5 text-gray-400 hover:text-gray-500"
id="options-menu-0-button"
aria-expanded="false"
aria-haspopup="true"
x-on:click="open = ! open">
<span class="sr-only">Open details</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
d="M3 10a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM8.5 10a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM15.5 8.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z"/>
</svg>
</button>
<!--
Dropdown menu, show/hide based on menu state.
Entering: "transition ease-out duration-100"
From: "transform opacity-0 scale-95"
To: "transform opacity-100 scale-100"
Leaving: "transition ease-in duration-75"
From: "transform opacity-100 scale-100"
To: "transform opacity-0 scale-95"
-->
<div
class="absolute right-5 -top-4 z-10 mt-0.5 w-32 origin-top-right rounded-md bg-white py-0.5 shadow-lg ring-1 ring-gray-900/5 focus:outline-none overflow-y-auto max-h-64"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu-0-button"
tabindex="-1"
x-show="open"
x-cloak>
<!-- Active: "bg-gray-50", Not Active: "" -->
@foreach($bonusScore->auditions as $audition)
<p class="block px-3 py-0.5 text-xs leading-6 text-gray-900">{{ $audition->name }}</p>
@endforeach
</div>
</div>
</div> {{-- End Card Header --}}
<dl class="-my-3 divide-y divide-gray-100 px-6 pb-4 pt-1 text-sm leading-6 bg-gray-50"> {{-- Judge Listing --}}
@foreach($bonusScore->judges as $judge)
<div class="flex justify-between items-center gap-x-4 py-1"> {{-- Judge Line --}}
<dt>
<p>
<span class="text-gray-700">{{ $judge->full_name() }} </span>
<span class="text-gray-500 text-xs">{{ $judge->school->name ?? '' }}</span>
</p>
<p class="text-gray-500 text-xs">{{ $judge->judging_preference }}</p>
</dt>
<dd class="text-gray-500 text-xs">
<form method="POST" action="{{route('admin.bonus-scores.judges.remove', $bonusScore) }}" id="removeJudgeFromRoom{{ $bonusScore->id }}">
@csrf
@method('DELETE')
<input type="hidden" name="judge" value="{{ $judge->id }}">
<button>
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="#d1d5db" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 9-6 6m0-6 6 6m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</button>
</form>
</dd>
</div>
@endforeach
<div class="pt-3"> {{-- Add Judge Form --}}
<form method="POST" action="{{route('admin.bonus-scores.judges.assign', $bonusScore)}}" id="assignJudgeToRoom{{ $bonusScore->id }}">
@csrf
<select name="judge"
id="judge"
class="block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6"
onchange="document.getElementById('assignJudgeToRoom{{ $bonusScore->id }}').submit()">
<option>Add a judge</option>
@foreach($users as $judge)
@if($bonusScore->judges->contains($judge->id))
@continue
@endif
<option value="{{ $judge->id }}">{{ $judge->full_name() }}
- {{ $judge->judging_preference }}</option>
@endforeach
</select>
</form>
</div>
</dl>
</li>
@endforeach
</ul>
</x-layout.app>

View File

@ -1,5 +1,17 @@
<x-layout.app> <x-layout.app>
<ul class="grid md:grid-cols-4 gap-5"> @if($bonusScoresExist)
<div class="bg-white pt-3 pb-1 px-3 rounded-md">
<div class="mb-3">
<nav class="flex space-x-4" aria-label="Tabs">
<!-- Current: "bg-indigo-100 text-indigo-700", Default: "text-gray-500 hover:text-gray-700" -->
<a href="{{route('admin.rooms.judgingAssignment')}}" class="rounded-md px-3 py-2 text-sm font-medium bg-indigo-100 text-indigo-700">Room Judges</a>
<a href="{{route('admin.bonus-scores.judges')}}" class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">Bonus Judges</a>
</nav>
</div>
</div>
@endif
<ul class="grid md:grid-cols-4 gap-5 mt-5">
@foreach($rooms as $room) @foreach($rooms as $room)
@if($room->id == 0) @if($room->id == 0)

View File

@ -3,13 +3,14 @@
'type' => 'text', 'type' => 'text',
'label' => false, 'label' => false,
'colspan' => '1', 'colspan' => '1',
'label_text' => false 'label_text' => false,
'id'=>null
]) ])
@php @php
$label_classes = "block text-sm font-medium leading-6 text-gray-900"; $label_classes = "block text-sm font-medium leading-6 text-gray-900";
$inputClasses = "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"; $inputClasses = "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6";
$inputAttributes = [ $inputAttributes = [
'id' => $name, 'id' => $id ?? $name,
'name' => $name, 'name' => $name,
'type' => $type, 'type' => $type,
'class' => $inputClasses, 'class' => $inputClasses,

View File

@ -0,0 +1,4 @@
@props(['color' => 'currentColor', 'title'=>false])
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="{{$color}}" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.707-3.707a1 1 0 0 0-1.414 1.414L10.586 12l-2.293 2.293a1 1 0 1 0 1.414 1.414L12 13.414l2.293 2.293a1 1 0 0 0 1.414-1.414L13.414 12l2.293-2.293a1 1 0 0 0-1.414-1.414L12 10.586 9.707 8.293Z" clip-rule="evenodd"/>
</svg>

View File

@ -26,6 +26,7 @@
<x-layout.navbar.menus.menu-item :href="route('admin.ensembles.index')">Ensembles</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.ensembles.index')">Ensembles</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.ensembles.seatingLimits')">Seating Limits</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.ensembles.seatingLimits')">Seating Limits</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.scoring.index')">Scoring</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.scoring.index')">Scoring</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.bonus-scores.index')">Bonus Scores</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.rooms.index')">Rooms</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.rooms.index')">Rooms</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.rooms.judgingAssignment')">Judges</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.rooms.judgingAssignment')">Judges</x-layout.navbar.menus.menu-item>
<x-layout.navbar.menus.menu-item :href="route('admin.draw.index')">Run Draw</x-layout.navbar.menus.menu-item> <x-layout.navbar.menus.menu-item :href="route('admin.draw.index')">Run Draw</x-layout.navbar.menus.menu-item>

View File

@ -21,6 +21,7 @@
<div class="absolute left-1/2 z-10 mt-5 flex w-screen max-w-min -translate-x-1/2 px-4" x-show="open" x-cloak> <div class="absolute left-1/2 z-10 mt-5 flex w-screen max-w-min -translate-x-1/2 px-4" x-show="open" x-cloak>
<div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5"> <div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5">
<a href="{{ route('scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Scores</a> <a href="{{ route('scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Scores</a>
<a href="{{ route('bonus-scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Bonus Scores</a>
<a href="{{ route('entry-flags.noShowSelect') }}" class="block p-2 hover:text-indigo-600">Enter No-Shows</a> <a href="{{ route('entry-flags.noShowSelect') }}" class="block p-2 hover:text-indigo-600">Enter No-Shows</a>
<a href="{{ route('seating.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a> <a href="{{ route('seating.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a>
<a href="{{ route('advancement.status') }}" class="block p-2 hover:text-indigo-600">{{ auditionSetting('advanceTo') }} Status</a> <a href="{{ route('advancement.status') }}" class="block p-2 hover:text-indigo-600">{{ auditionSetting('advanceTo') }} Status</a>

View File

@ -1,12 +1,12 @@
@props(['title'=>false]) @props(['title'=>false, 'showVar'=>'showModal'])
<div <div
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50" class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
x-show="showModal" x-cloak x-show="{{ $showVar }}" x-cloak
> >
<!-- Modal inner --> <!-- Modal inner -->
<div <div
class="max-w-3xl px-6 py-4 mx-auto text-left bg-white rounded shadow-lg" class="max-w-3xl px-6 py-4 mx-auto text-left bg-white rounded shadow-lg"
@click.away="showModal = false" @click.away="{{ $showVar }} = false"
x-transition:enter="motion-safe:ease-out duration-300" x-transition:enter="motion-safe:ease-out duration-300"
x-transition:enter-start="opacity-0 scale-90" x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
@ -17,7 +17,7 @@
<h5 {{ $title->attributes->merge(['class' => 'mr-3 text-black max-w-none']) }}>{{ $title ?? '' }}</h5> <h5 {{ $title->attributes->merge(['class' => 'mr-3 text-black max-w-none']) }}>{{ $title ?? '' }}</h5>
@endif @endif
<button type="button" class="z-50 cursor-pointer" @click="showModal = false"> <button type="button" class="z-50 cursor-pointer" @click="{{ $showVar}} = false">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@ -11,7 +11,7 @@
<div> <div>
@if($with_title_area) @if($with_title_area)
<div class="mb-4 mt-4 sm:px 4 sm:flex sm:items-center"> <div class="mb-2 mt-4 sm:px 4 sm:flex sm:items-center">
<div class="sm:flex-auto sm:items-center"> <div class="sm:flex-auto sm:items-center">
@if($title)<h1 {{ $title->attributes->merge(['class' => 'text-base font-semibold leading-6 text-gray-900']) }}>{{ $title }}</h1>@endif @if($title)<h1 {{ $title->attributes->merge(['class' => 'text-base font-semibold leading-6 text-gray-900']) }}>{{ $title }}</h1>@endif
@if($subtitle)<p {{ $subtitle->attributes->merge(['class' => 'mt-2 text-sm text-gray-700']) }}>{{ $subtitle }}</p>@endif @if($subtitle)<p {{ $subtitle->attributes->merge(['class' => 'mt-2 text-sm text-gray-700']) }}>{{ $subtitle }}</p>@endif

View File

@ -0,0 +1,16 @@
<x-layout.app>
<x-slot:page_title>Enter {{ $bonusName }} Score</x-slot:page_title>
<x-card.card class="mx-auto max-w-sm">
<x-card.heading>{{ $entry->audition->name }} {{$entry->draw_number}}</x-card.heading>
<x-form.form method="POST"
id="score-entry-for-{{$entry->id}}"
action="{{route('judging.bonusScores.recordScore', $entry)}}">
<x-form.field name="score"
class="mb-5"
label_text="{{ $bonusName }} Score"
type="number"
max="{{ $maxScore }}"/>
<x-form.button type="submit" class="mb-5">Enter {{ $bonusName }} Score</x-form.button>
</x-form.form>
</x-card.card>
</x-layout.app>

View File

@ -0,0 +1,37 @@
@php use Carbon\Carbon; @endphp
<x-layout.app>
<x-slot:page_title>Judging Dashboard - Bonus Scores</x-slot:page_title>
<x-table.table>
<x-slot:title>{{ $audition->name }} - Bonus Score</x-slot:title>
<x-table.body>
<thead>
<tr>
<x-table.th>Entry</x-table.th>
<x-table.th>Score</x-table.th>
<x-table.th>Scored On</x-table.th>
<x-table.th>Score Timestamp</x-table.th>
</tr>
</thead>
<x-table.body>
@foreach($entries as $entry)
<tr>
@if($scores->has($entry->id))
<x-table.td>{{ $audition->name }} {{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $scores[$entry->id]->score }}</x-table.td>
<x-table.td>{{ $scores[$entry->id]->originallyScoredEntry->audition->name }}</x-table.td>
<x-table.td>{{ Carbon::create($scores[$entry->id]->created_at)->setTimezone('America/Chicago')->format('m/d/y H:i') }}</x-table.td>
@else
<x-table.td>
<a href="{{ route('judging.bonusScore.entry', $entry) }}">
{{ $audition->name }} {{ $entry->draw_number }}
</a>
</x-table.td>
@endif
</tr>
@endforeach
</x-table.body>
</x-table.body>
</x-table.table>
</x-layout.app>

View File

@ -1,7 +1,8 @@
<x-layout.app> <x-layout.app>
<x-slot:page_title>Judging Dashboard</x-slot:page_title> <x-slot:page_title>Judging Dashboard</x-slot:page_title>
<h2 class="overflow-hidden mx-auto max-w-md py-3 px-1 text-base font-semibold leading-7 text-gray-900">Choose auditon to judge</h2> <h2 class="overflow-hidden mx-auto max-w-md py-3 px-1 text-base font-semibold leading-7 text-gray-900">Choose
audition to judge</h2>
@foreach($rooms as $room) @foreach($rooms as $room)
<x-card.card class="mx-auto max-w-md mb-3"> <x-card.card class="mx-auto max-w-md mb-3">
@ -16,5 +17,20 @@
</x-card.card> </x-card.card>
@endforeach @endforeach
@foreach($bonusScoresToJudge as $bonusScore)
<x-card.card class="mx-auto max-w-md mb-3">
<x-card.heading>{{ $bonusScore->name }}</x-card.heading>
<x-card.list.body>
@foreach($bonusScore->auditions as $audition)
<a href="{{ route('judging.bonusScore.EntryList', $audition) }}">
<x-card.list.row class="!py-3 ml-3">
{{ $audition->name }}
</x-card.list.row>
</a>
@endforeach
</x-card.list.body>
</x-card.card>
@endforeach
</x-layout.app> </x-layout.app>

View File

@ -0,0 +1,71 @@
@php use Illuminate\Support\Carbon; @endphp
<x-layout.app>
<x-slot:page_title>Enter {{ $bonusScoreDefinition->name }} Score</x-slot:page_title>
<x-card.card>
<x-card.heading>
{{ $entry->student->full_name() }}
<x-slot:subheading>{{ $entry->student->school->name }}</x-slot:subheading>
</x-card.heading>
</x-card.card>
<div class="grid grid-cols-3 gap-3 w-full mt-5">
<x-card.card class="col-span-2">
<x-card.heading>Existing Scores</x-card.heading>
<div class="px-6">
@foreach($relatedEntries as $related)
<x-table.table class="mb-8 ">
<x-slot:title class="border rounded-md pl-3 bg-gray-100">
{{ $related->audition->name }} #{{ $related->draw_number }}
</x-slot:title>
<x-slot:subtitle>
Entry ID: {{ $related->id }}
</x-slot:subtitle>
<thead>
<tr>
<x-table.th>Judge</x-table.th>
<x-table.th>Audition Scored</x-table.th>
<x-table.th>Score</x-table.th>
<x-table.th>Timestamp</x-table.th>
</tr>
</thead>
<x-table.body>
@foreach($existingScores[$related->id] as $score)
<tr>
<td>{{ $score->judge->full_name() }}</td>
<td>{{ $score->originallyScoredEntry->audition->name }}</td>
<td>{{ $score->score }}</td>
<td>{{ Carbon::create($score->created_at)->setTimezone('America/Chicago')->format('m/d/y H:i') }}</td>
</tr>
@endforeach
</x-table.body>
</x-table.table>
@endforeach
</div>
</x-card.card>
<x-card.card >
<x-card.heading>Enter Score</x-card.heading>
<div class="mx-5 border-b text-sm">
<p class="mb-3">NOTE: Entering score will delete any existing scores for that entry by that judge</p>
<p>Submitting the form with no score value will delete scores by that judge</p>
</div>
<x-form.form class="my-3" method="POST" action="{{route('bonus-scores.saveEntryBonusScoreSheet', $entry)}}" x-data="{ judgeChanged: false }">
<x-form.select name="judge_id" class="mb-5" required x-on:change="judgeChanged=true">
<x-slot:label>Judge</x-slot:label>
<option value="" x-bind:disabled="judgeChanged">Choose Judge</option>
@foreach($assignedJudges as $judge)
<option value="{{ $judge->id }}">{{ $judge->full_name() }}</option>
@endforeach
</x-form.select>
<x-form.select name="entry_id" class="mb-5" required>
<x-slot:label>Scored Audition</x-slot:label>
@foreach($relatedEntries as $related)
<option value="{{$related->id}}" {{$related->id == $entry->id ? 'selected':' '}}>{{ $related->audition->name }}</option>
@endforeach
</x-form.select>
<x-form.field label_text="Score" name="score" type="number" max="{{ $bonusScoreDefinition->max_score }}"/>
<x-form.button class="mt-5" x-show="judgeChanged">Enter Score</x-form.button>
</x-form.form>
</x-card.card>
</div>
</x-layout.app>

View File

@ -2,13 +2,14 @@
/** /**
* @var string $method Method for the select form * @var string $method Method for the select form
* @var string $formRoute Route for the form action. Should be a route name * @var string $formRoute Route for the form action. Should be a route name
* @var string $title Title of the page
*/ */
@endphp @endphp
<x-layout.app> <x-layout.app>
<x-slot:page_title>Choose Entry</x-slot:page_title> <x-slot:page_title>{{ $title }}</x-slot:page_title>
<x-card.card class="mx-auto max-w-sm"> <x-card.card class="mx-auto max-w-sm">
<x-card.heading>Choose Entry</x-card.heading> <x-card.heading>{{ $title }} - Choose Entry</x-card.heading>
<div class=""> <div class="">
<x-form.form method="{{ $method }}" action="{{ route($formRoute) }}" class="mb-4 mt-3" id="entry-select-form"> <x-form.form method="{{ $method }}" action="{{ route($formRoute) }}" class="mb-4 mt-3" id="entry-select-form">
<x-form.field name="entry_id" label_text="Entry ID"></x-form.field> <x-form.field name="entry_id" label_text="Entry ID"></x-form.field>

View File

@ -1,20 +1,51 @@
<?php <?php
// Admin Routes // Admin Routes
use App\Http\Controllers\Admin\AuditionController;
use App\Http\Controllers\Admin\AuditionSettings;
use App\Http\Controllers\Admin\BonusScoreDefinitionController;
use App\Http\Controllers\Admin\DrawController;
use App\Http\Controllers\Admin\EnsembleController;
use App\Http\Controllers\Admin\EntryController;
use App\Http\Controllers\Admin\EventController;
use App\Http\Controllers\Admin\RoomController;
use App\Http\Controllers\Admin\SchoolController;
use App\Http\Controllers\Admin\ScoringGuideController;
use App\Http\Controllers\Admin\StudentController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Middleware\CheckIfAdmin; use App\Http\Middleware\CheckIfAdmin;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->group(function () { Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->group(function () {
Route::view('/', 'admin.dashboard')->name('admin.dashboard'); Route::view('/', 'admin.dashboard')->name('admin.dashboard');
Route::post('/auditions/roomUpdate', [\App\Http\Controllers\Admin\AuditionController::class, 'roomUpdate']); // Endpoint for JS assigning auditions to rooms Route::post('/auditions/roomUpdate', [
Route::post('/scoring/assign_guide_to_audition', [\App\Http\Controllers\Admin\AuditionController::class, 'scoringGuideUpdate'])->name('ajax.assignScoringGuideToAudition'); // Endpoint for JS assigning scoring guides to auditions AuditionController::class, 'roomUpdate',
]); // Endpoint for JS assigning auditions to rooms
Route::post('/scoring/assign_guide_to_audition', [
AuditionController::class, 'scoringGuideUpdate',
])->name('ajax.assignScoringGuideToAudition'); // Endpoint for JS assigning scoring guides to auditions
Route::get('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'index'])->name('audition-settings'); Route::get('/settings', [AuditionSettings::class, 'index'])->name('audition-settings');
Route::post('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'save'])->name('audition-settings-save'); Route::post('/settings',
[AuditionSettings::class, 'save'])->name('audition-settings-save');
// Admin Bonus Scores Routes
Route::prefix('bonus-scores')->controller(BonusScoreDefinitionController::class)->group(function (
) {
Route::get('/', 'index')->name('admin.bonus-scores.index');
Route::post('/', 'store')->name('admin.bonus-scores.store');
Route::post('/assign_auditions', 'assignAuditions')->name('admin.bonus-scores.addAuditions');
Route::delete('/{audition}/unassign_audition', 'unassignAudition')->name('admin.bonus-scores.unassignAudition');
Route::delete('/{bonusScore}', 'destroy')->name('admin.bonus-scores.destroy');
Route::get('/judges', 'judges')->name('admin.bonus-scores.judges');
Route::delete('{bonusScore}/judges/', 'removeJudge')->name('admin.bonus-scores.judges.remove');
Route::post('{bonusScore}/judges/', 'assignJudge')->name('admin.bonus-scores.judges.assign');
});
// Admin Ensemble Routes // Admin Ensemble Routes
Route::prefix('ensembles')->controller(\App\Http\Controllers\Admin\EnsembleController::class)->group(function () { Route::prefix('ensembles')->controller(EnsembleController::class)->group(function () {
Route::get('/', 'index')->name('admin.ensembles.index'); Route::get('/', 'index')->name('admin.ensembles.index');
Route::post('/', 'store')->name('admin.ensembles.store'); Route::post('/', 'store')->name('admin.ensembles.store');
Route::delete('/{ensemble}', 'destroy')->name('admin.ensembles.destroy'); Route::delete('/{ensemble}', 'destroy')->name('admin.ensembles.destroy');
@ -22,45 +53,52 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
Route::patch('/{ensemble}', 'updateEnsemble')->name('admin.ensembles.update'); Route::patch('/{ensemble}', 'updateEnsemble')->name('admin.ensembles.update');
Route::get('/seating-limits', 'seatingLimits')->name('admin.ensembles.seatingLimits'); Route::get('/seating-limits', 'seatingLimits')->name('admin.ensembles.seatingLimits');
Route::get('/seating-limits/{ensemble}', 'seatingLimits')->name('admin.ensembles.seatingLimits.ensemble'); Route::get('/seating-limits/{ensemble}', 'seatingLimits')->name('admin.ensembles.seatingLimits.ensemble');
Route::post('/seating-limits/{ensemble}', 'seatingLimitsSet')->name('admin.ensembles.seatingLimits.ensemble.set'); Route::post('/seating-limits/{ensemble}',
'seatingLimitsSet')->name('admin.ensembles.seatingLimits.ensemble.set');
}); });
// Admin Event Routes // Admin Event Routes
Route::prefix('events')->controller(\App\Http\Controllers\Admin\EventController::class)->group(function () { Route::prefix('events')->controller(EventController::class)->group(function () {
Route::get('/', 'index')->name('admin.events.index'); Route::get('/', 'index')->name('admin.events.index');
Route::post('/', 'store')->name('admin.events.store'); Route::post('/', 'store')->name('admin.events.store');
Route::delete('/{event}', 'destroy')->name('admin.events.destroy'); Route::delete('/{event}', 'destroy')->name('admin.events.destroy');
}); });
// Admin Rooms Routes // Admin Rooms Routes
Route::prefix('rooms')->controller(\App\Http\Controllers\Admin\RoomController::class)->group(function () { Route::prefix('rooms')->controller(RoomController::class)->group(function () {
Route::get('/', 'index')->name('admin.rooms.index'); Route::get('/', 'index')->name('admin.rooms.index');
Route::get('/create', 'create')->name('admin.rooms.create'); Route::get('/create', 'create')->name('admin.rooms.create');
Route::post('/', 'store')->name('admin.rooms.store'); Route::post('/', 'store')->name('admin.rooms.store');
Route::post('/{room}/edit', 'edit')->name('admin.rooms.edit'); Route::post('/{room}/edit', 'edit')->name('admin.rooms.edit');
Route::patch('/{room}', 'update')->name('admin.rooms.update'); Route::patch('/{room}', 'update')->name('admin.rooms.update');
Route::delete('/{room}', 'destroy')->name('admin.rooms.destroy'); Route::delete('/{room}', 'destroy')->name('admin.rooms.destroy');
Route::get('/judging_assignments', 'judgingAssignment')->name('admin.rooms.judgingAssignment'); // Screen to assign judges to rooms Route::get('/judging_assignments',
Route::match(['post', 'delete'], '/{room}/judge', 'updateJudgeAssignment')->name('admin.rooms.updateJudgeAssignment'); 'judgingAssignment')->name('admin.rooms.judgingAssignment'); // Screen to assign judges to rooms
Route::match(['post', 'delete'], '/{room}/judge',
'updateJudgeAssignment')->name('admin.rooms.updateJudgeAssignment');
}); });
// Admin Scoring Guides // Admin Scoring Guides
Route::prefix('scoring')->controller(\App\Http\Controllers\Admin\ScoringGuideController::class)->group(function () { Route::prefix('scoring')->controller(ScoringGuideController::class)->group(function () {
Route::get('/', 'index')->name('admin.scoring.index'); // Scoring Setup Homepage Route::get('/', 'index')->name('admin.scoring.index'); // Scoring Setup Homepage
Route::post('/guides', 'store')->name('admin.scoring.store'); // Save a new scoring guide Route::post('/guides', 'store')->name('admin.scoring.store'); // Save a new scoring guide
Route::get('/guides/{guide}/edit/{tab?}', 'edit')->name('admin.scoring.edit'); // Edit scoring guide Route::get('/guides/{guide}/edit/{tab?}', 'edit')->name('admin.scoring.edit'); // Edit scoring guide
Route::patch('/guides/{guide}/edit', 'update')->name('admin.scoring.update'); // Save changes to audition guide (rename) Route::patch('/guides/{guide}/edit',
Route::post('/guides/{guide}/subscore', 'subscore_store')->name('admin.scoring.subscore_store'); // Save a new subscore 'update')->name('admin.scoring.update'); // Save changes to audition guide (rename)
Route::patch('/guides/{guide}/subscore/{subscore}', 'subscore_update')->name('admin.scoring.subscore_update'); // Modify a subscore Route::post('/guides/{guide}/subscore',
Route::delete('/guides/{guide}/subscore/{subscore}', 'subscore_destroy')->name('admin.scoring.subscore_destroy'); // Delete a subscore 'subscore_store')->name('admin.scoring.subscore_store'); // Save a new subscore
Route::patch('/guides/{guide}/subscore/{subscore}',
'subscore_update')->name('admin.scoring.subscore_update'); // Modify a subscore
Route::delete('/guides/{guide}/subscore/{subscore}',
'subscore_destroy')->name('admin.scoring.subscore_destroy'); // Delete a subscore
Route::post('/reorder-display', 'reorder_display')->name('admin.scoring.reorder_display'); Route::post('/reorder-display', 'reorder_display')->name('admin.scoring.reorder_display');
Route::post('/reorder-tiebreak', 'reorder_tiebreak')->name('admin.scoring.reorder_tiebreak'); Route::post('/reorder-tiebreak', 'reorder_tiebreak')->name('admin.scoring.reorder_tiebreak');
Route::delete('/guides/{guide}', 'destroy')->name('admin.scoring.destroy'); // Delete a scoring guide Route::delete('/guides/{guide}', 'destroy')->name('admin.scoring.destroy'); // Delete a scoring guide
}); });
// Admin Auditions Routes // Admin Auditions Routes
Route::prefix('auditions')->controller(\App\Http\Controllers\Admin\AuditionController::class)->group(function () { Route::prefix('auditions')->controller(AuditionController::class)->group(function () {
Route::get('/', 'index')->name('admin.auditions.index'); Route::get('/', 'index')->name('admin.auditions.index');
Route::get('/create', 'create')->name('admin.auditions.create'); Route::get('/create', 'create')->name('admin.auditions.create');
Route::post('/', 'store')->name('admin.auditions.store'); Route::post('/', 'store')->name('admin.auditions.store');
@ -71,15 +109,16 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
}); });
// Admin Audition Draw Routes // Admin Audition Draw Routes
Route::prefix('draw')->controller(\App\Http\Controllers\Admin\DrawController::class)->group(function () { Route::prefix('draw')->controller(DrawController::class)->group(function () {
Route::get('/', 'index')->name('admin.draw.index'); Route::get('/', 'index')->name('admin.draw.index');
Route::post('/', 'store')->name('admin.draw.store'); Route::post('/', 'store')->name('admin.draw.store');
Route::get('/clear', 'edit')->name('admin.draw.edit'); // Select auditions for which the user would like to clear the draw Route::get('/clear',
'edit')->name('admin.draw.edit'); // Select auditions for which the user would like to clear the draw
Route::delete('/', 'destroy')->name('admin.draw.destroy'); // Clear the draw for the selected auditions Route::delete('/', 'destroy')->name('admin.draw.destroy'); // Clear the draw for the selected auditions
}); });
// Admin Entries Routes // Admin Entries Routes
Route::prefix('entries')->controller(\App\Http\Controllers\Admin\EntryController::class)->group(function () { Route::prefix('entries')->controller(EntryController::class)->group(function () {
Route::get('/', 'index')->name('admin.entries.index'); Route::get('/', 'index')->name('admin.entries.index');
Route::get('/create', 'create')->name('admin.entries.create'); Route::get('/create', 'create')->name('admin.entries.create');
Route::post('/', 'store')->name('admin.entries.store'); Route::post('/', 'store')->name('admin.entries.store');
@ -90,7 +129,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
}); });
// Admin Student Routes // Admin Student Routes
Route::prefix('students')->controller(\App\Http\Controllers\Admin\StudentController::class)->group(function () { Route::prefix('students')->controller(StudentController::class)->group(function () {
Route::get('/', 'index')->name('admin.students.index'); Route::get('/', 'index')->name('admin.students.index');
Route::get('/create', 'create')->name('admin.students.create'); Route::get('/create', 'create')->name('admin.students.create');
Route::post('/', 'store')->name('admin.students.store'); Route::post('/', 'store')->name('admin.students.store');
@ -100,7 +139,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
}); });
// Admin School Routes // Admin School Routes
Route::prefix('schools')->controller(\App\Http\Controllers\Admin\SchoolController::class)->group(function () { Route::prefix('schools')->controller(SchoolController::class)->group(function () {
Route::post('/{school}/add_domain', 'add_domain')->name('admin.schools.add_domain'); Route::post('/{school}/add_domain', 'add_domain')->name('admin.schools.add_domain');
Route::get('/', 'index')->name('admin.schools.index'); Route::get('/', 'index')->name('admin.schools.index');
Route::get('/create', 'create')->name('admin.schools.create'); Route::get('/create', 'create')->name('admin.schools.create');
@ -115,7 +154,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
}); });
// Admin User Routes // Admin User Routes
Route::prefix('users')->controller(\App\Http\Controllers\Admin\UserController::class)->group(function () { Route::prefix('users')->controller(UserController::class)->group(function () {
Route::get('/', 'index')->name('admin.users.index'); Route::get('/', 'index')->name('admin.users.index');
Route::get('/create', 'create')->name('admin.users.create'); Route::get('/create', 'create')->name('admin.users.create');
Route::post('/', 'store')->name('admin.users.store'); Route::post('/', 'store')->name('admin.users.store');

View File

@ -1,6 +1,10 @@
<?php <?php
// Judging Routes // Judging Routes
use App\Http\Controllers\JudgingController; use App\Http\Controllers\Judging\BonusScoreEntryController;
use App\Http\Controllers\Judging\BonusScoreEntryListController;
use App\Http\Controllers\Judging\BonusScoreRecordController;
use App\Http\Controllers\Judging\JudgingController;
use App\Http\Middleware\CheckIfCanJudge; use App\Http\Middleware\CheckIfCanJudge;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -11,3 +15,10 @@ Route::middleware(['auth', 'verified', CheckIfCanJudge::class])->prefix('judging
Route::post('/entry/{entry}', 'saveScoreSheet')->name('judging.saveScoreSheet'); Route::post('/entry/{entry}', 'saveScoreSheet')->name('judging.saveScoreSheet');
Route::patch('/entry/{entry}', 'updateScoreSheet')->name('judging.updateScoreSheet'); Route::patch('/entry/{entry}', 'updateScoreSheet')->name('judging.updateScoreSheet');
}); });
// Bonus score judging routes
Route::middleware(['auth', 'verified', CheckIfCanJudge::class])->prefix('judging/bonus_scores')->group(function () {
Route::get('/{audition}', BonusScoreEntryListController::class)->name('judging.bonusScore.EntryList'); // List of entries in an audition
Route::get('/entries/{entry}', BonusScoreEntryController::class)->name('judging.bonusScore.entry'); // Form to enter an entries score
Route::post('/entries/{entry}', BonusScoreRecordController::class)->name('judging.bonusScores.recordScore'); // Record the score
});

View File

@ -2,13 +2,13 @@
// Tabulation Routes // Tabulation Routes
use App\Http\Controllers\Tabulation\AdvancementController; use App\Http\Controllers\Tabulation\AdvancementController;
use App\Http\Controllers\Tabulation\BonusScoreController;
use App\Http\Controllers\Tabulation\DoublerDecisionController; use App\Http\Controllers\Tabulation\DoublerDecisionController;
use App\Http\Controllers\Tabulation\EntryFlagController; use App\Http\Controllers\Tabulation\EntryFlagController;
use App\Http\Controllers\Tabulation\ScoreController; use App\Http\Controllers\Tabulation\ScoreController;
use App\Http\Controllers\Tabulation\SeatAuditionFormController; use App\Http\Controllers\Tabulation\SeatAuditionFormController;
use App\Http\Controllers\Tabulation\SeatingPublicationController; use App\Http\Controllers\Tabulation\SeatingPublicationController;
use App\Http\Controllers\Tabulation\SeatingStatusController; use App\Http\Controllers\Tabulation\SeatingStatusController;
use App\Http\Controllers\Tabulation\TabulationController;
use App\Http\Middleware\CheckIfCanTab; use App\Http\Middleware\CheckIfCanTab;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -22,6 +22,14 @@ Route::middleware(['auth', 'verified', CheckIfCanTab::class])->group(function ()
Route::delete('/{score}', 'destroyScore')->name('scores.destroy'); Route::delete('/{score}', 'destroyScore')->name('scores.destroy');
}); });
// Bonus Score Management
Route::prefix('bonus-scores/')->controller(BonusScoreController::class)->group(function () {
Route::get('/choose_entry', 'chooseEntry')->name('bonus-scores.chooseEntry');
Route::get('/entry', 'entryBonusScoreSheet')->name('bonus-scores.entryBonusScoreSheet');
Route::post('/entry/{entry}', 'saveEntryBonusScoreSheet')->name('bonus-scores.saveEntryBonusScoreSheet');
Route::delete('/{bonusScore}', 'destroyBonusScore')->name('bonus-scores.destroy');
});
// Entry Flagging // Entry Flagging
Route::prefix('entry-flags/')->controller(EntryFlagController::class)->group(function () { Route::prefix('entry-flags/')->controller(EntryFlagController::class)->group(function () {
Route::get('/choose_no_show', 'noShowSelect')->name('entry-flags.noShowSelect'); Route::get('/choose_no_show', 'noShowSelect')->name('entry-flags.noShowSelect');

View File

@ -1,19 +1,7 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EntryController;
use App\Http\Controllers\FilterController; use App\Http\Controllers\FilterController;
use App\Http\Controllers\JudgingController;
use App\Http\Controllers\PdfInvoiceController;
use App\Http\Controllers\SchoolController;
use App\Http\Controllers\StudentController;
use App\Http\Controllers\Tabulation\DoublerDecisionController;
use App\Http\Controllers\TestController; use App\Http\Controllers\TestController;
use App\Http\Controllers\UserController;
use App\Http\Middleware\CheckIfAdmin;
use App\Http\Middleware\CheckIfCanJudge;
use App\Http\Middleware\CheckIfCanTab;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
require __DIR__.'/admin.php'; require __DIR__.'/admin.php';
@ -23,7 +11,6 @@ require __DIR__.'/user.php';
Route::get('/test', [TestController::class, 'flashTest'])->middleware('auth', 'verified'); Route::get('/test', [TestController::class, 'flashTest'])->middleware('auth', 'verified');
Route::view('/', 'welcome')->middleware('guest')->name('home'); Route::view('/', 'welcome')->middleware('guest')->name('home');
Route::get('/results', [App\Http\Controllers\ResultsPage::class, '__invoke'])->name('results'); Route::get('/results', [App\Http\Controllers\ResultsPage::class, '__invoke'])->name('results');

View File

@ -0,0 +1,134 @@
<?php
use App\Actions\Tabulation\EnterBonusScore;
use App\Exceptions\ScoreEntryException;
use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\BonusScoreDefinition;
use App\Models\Entry;
use App\Models\Student;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->enterBonusScore = App::make(EnterBonusScore::class);
});
it('rejects a non existent entry', function () {
$judge = User::factory()->create();
$entry = Entry::factory()->make();
$this->enterBonusScore->__invoke($judge, $entry, 42);
})->throws(ScoreEntryException::class, 'Invalid entry provided');
it('rejects a non existent judge', function () {
$judge = User::factory()->make();
$entry = Entry::factory()->create();
$this->enterBonusScore->__invoke($judge, $entry, 42);
})->throws(ScoreEntryException::class, 'Invalid judge provided');
it('rejects a submission if the entries audition does not have a bonus score', function () {
$judge = User::factory()->create();
$entry = Entry::factory()->create();
$this->enterBonusScore->__invoke($judge, $entry, 42);
})->throws(ScoreEntryException::class, 'Entry does not have a bonus score');
it('rejects a submission if the entry already has a score from the given judge', function () {
// Arrange
$judge = User::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->bonusScore()->attach($bonusScore->id);
$score = BonusScore::create([
'entry_id' => $entry->id,
'user_id' => $judge->id,
'originally_scored_entry' => $entry->id,
'score' => 42,
]);
// Act & Assert
$this->enterBonusScore->__invoke($judge, $entry, 43);
})->throws(ScoreEntryException::class, 'That judge has already scored that entry');
it('rejects a submission for a judge not assigned to judge that bonus score', function () {
// Arrange
$judge = User::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->bonusScore()->attach($bonusScore->id);
// Act & Assert
$this->enterBonusScore->__invoke($judge, $entry, 43);
})->throws(ScoreEntryException::class, 'That judge is not assigned to judge that bonus score');
it('rejects a submission for a score that exceeds the maximum', function () {
// Arrange
$judge = User::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 50]);
$bonusScore->judges()->attach($judge);
$entry = Entry::factory()->create();
$entry->audition->bonusScore()->attach($bonusScore->id);
// Act & Assert
$this->enterBonusScore->__invoke($judge, $entry, 51);
})->throws(ScoreEntryException::class, 'That score exceeds the maximum');
it('records a valid bonus score submission on the submitted entry', function () {
// Arrange
$judge = User::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]);
$entry = Entry::factory()->create();
$entry->audition->bonusScore()->attach($bonusScore->id);
$bonusScore->judges()->attach($judge);
// Act & Assert
$this->enterBonusScore->__invoke($judge, $entry, 42);
expect(
BonusScore::where('entry_id', $entry->id)
->where('user_id', $judge->id)
->where('score', 42)->exists())
->toBeTrue();
});
it('records a valid bonus score on all related entries', function () {
// Arrange
$judge = User::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create(['name' => 'Saxophone Improvisation', 'max_score' => 100]);
$bonusScore->judges()->attach($judge);
$jazzAltoAudition = Audition::factory()->create(['name' => 'Jazz Alto Saxophone']);
$jazzTenorAudition = Audition::factory()->create(['name' => 'Jazz Tenor Saxophone']);
$jazzBariAudition = Audition::factory()->create(['name' => 'Jazz Bari Saxophone']);
$bonusScore->auditions()->attach($jazzAltoAudition->id);
$bonusScore->auditions()->attach($jazzTenorAudition->id);
$bonusScore->auditions()->attach($jazzBariAudition->id);
$saxStudent = Student::factory()->create();
$jazzAltoEntry = Entry::factory()->create([
'student_id' => $saxStudent->id, 'audition_id' => $jazzAltoAudition->id,
]);
$jazzTenorEntry = Entry::factory()->create(['student_id' => $saxStudent->id,
'audition_id' => $jazzTenorAudition->id,
]);
$jazzBariEntry = Entry::factory()->create(['student_id' => $saxStudent->id, 'audition_id' => $jazzBariAudition->id,
]);
Entry::factory()->count(4)->create(['audition_id' => $jazzAltoAudition->id]);
Entry::factory()->count(4)->create(['audition_id' => $jazzTenorAudition->id]);
Entry::factory()->count(4)->create(['audition_id' => $jazzBariAudition->id]);
// Act
$this->enterBonusScore->__invoke($judge, $jazzAltoEntry, 42);
// Assert
expect(
BonusScore::where('entry_id', $jazzAltoEntry->id)
->where('user_id', $judge->id)
->where('originally_scored_entry', $jazzAltoEntry->id)
->where('score', 42)->exists())
->toBeTrue()
->and(BonusScore::count())->toBe(3)
->and(
BonusScore::where('entry_id', $jazzTenorEntry->id)
->where('user_id', $judge->id)
->where('originally_scored_entry', $jazzAltoEntry->id)
->where('score', 42)->exists())
->toBeTrue()
->and(
BonusScore::where('entry_id', $jazzBariEntry->id)
->where('user_id', $judge->id)
->where('originally_scored_entry', $jazzAltoEntry->id)
->where('score', 42)->exists())
->toBeTrue();
});

View File

@ -0,0 +1,67 @@
<?php
use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\BonusScoreDefinition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
it('denies access go a guest', function () {
$response = $this->get(route('judging.bonusScore.EntryList', 1));
$response->assertRedirect(route('home'));
});
it('denies access to a user not assigned to judge this bonus score', function () {
$bonusScore = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
$audition->bonusScore()->attach($bonusScore->id);
$room = Room::factory()->create();
$user = User::factory()->create();
$room->addJudge($user->id);
actingAs($user);
$this->get(route('judging.bonusScore.EntryList', $audition->id))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not assigned to judge this bonus score');
});
it('shows all entries to an authorized judge', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
$audition->bonusScore()->attach($bonusScore->id);
$entries[1] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 1]);
$entries[2] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 2]);
$entries[3] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 3]);
$entries[4] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 4]);
$entries = collect($entries);
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge->id);
actingAs($judge);
// Act & Assert
$response = $this->get(route('judging.bonusScore.EntryList', $audition->id));
$response->assertOk();
$entries->each(fn ($entry) => $response->assertSee($entry->audition->name.' '.$entry->draw_number));
});
it('shows existing scores for an entry', function () {
$bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]);
$audition = Audition::factory()->create();
$audition->bonusScore()->attach($bonusScore->id);
$entry = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 1]);
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge->id);
BonusScore::create([
'entry_id' => $entry->id,
'user_id' => $judge->id,
'originally_scored_entry' => $entry->id,
'score' => 42,
]);
actingAs($judge);
// Act
$response = $this->get(route('judging.bonusScore.EntryList', $audition));
$response->assertOk()
->assertSeeInOrder(['<tr>', e($audition->name), $entry->draw_number, 42, '</tr>'], false);
});

View File

@ -0,0 +1,68 @@
<?php
use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\BonusScoreDefinition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Sinnbeck\DomAssertions\Asserts\AssertForm;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
it('denies access go a guest', function () {
$response = $this->get(route('judging.bonusScore.entry', 1));
$response->assertRedirect(route('home'));
});
it('denies access to a user not assigned to judge this bonus score', function () {
$bonusScore = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
$audition->bonusScore()->attach($bonusScore->id);
$entry = Entry::factory()->create(['audition_id' => $audition->id]);
$room = Room::factory()->create();
$user = User::factory()->create();
$room->addJudge($user->id);
actingAs($user);
$this->get(route('judging.bonusScore.entry', $entry->id))
->assertRedirect(route('judging.index'))
->assertSessionHas('error', 'You are not assigned to judge this entry');
});
it('denies access if a score already exists for the entry by the user', function () {
$entry = Entry::factory()->create();
$judge = User::factory()->create();
$bonusScoreDefinition = BonusScoreDefinition::factory()->create();
$bonusScoreDefinition->judges()->attach($judge->id);
BonusScore::create([
'entry_id' => $entry->id,
'user_id' => $judge->id,
'originally_scored_entry' => $entry->id,
'score' => 42,
]);
actingAs($judge);
$this->get(route('judging.bonusScore.entry', $entry))
->assertRedirect(route('judging.bonusScore.EntryList', $entry->audition))
->assertSessionHas('error', 'You have already judged that entry');
});
it('has a proper score entry form for a valid request', function () {
// Arrange
$audition = Audition::factory()->create();
$bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]);
$bonusScore->auditions()->attach($audition->id);
$entry = Entry::factory()->create(['audition_id' => $audition->id]);
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge->id);
actingAs($judge);
// Act & Assert
$request = $this->get(route('judging.bonusScore.entry', $entry));
$request->assertOk()
->assertFormExists('#score-entry-for-'.$entry->id, function (AssertForm $form) use ($entry) {
$form->hasCSRF()
->hasMethod('POST')
->hasAction(route('judging.bonusScores.recordScore', $entry))
->containsInput(['name' => 'score'])
->containsButton(['type' => 'submit']);
});
});

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Models\Audition; use App\Models\Audition;
use App\Models\BonusScoreDefinition;
use App\Models\Entry; use App\Models\Entry;
use App\Models\Room; use App\Models\Room;
use App\Models\ScoringGuide; use App\Models\ScoringGuide;
@ -113,3 +114,37 @@ it('does not show the user room and auditions they are not assigned to judge', f
->assertDontSee($otherRoom->name) ->assertDontSee($otherRoom->name)
->assertDontSee($otherAudition->name); ->assertDontSee($otherAudition->name);
}); });
it('shows bonus scores the user is assigned to judge', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge);
$this->actingAs($judge);
// Act & Assert
$this->get(route('judging.index'))
->assertSee($bonusScore->name);
});
it('does not show bonus scores the user is not assigned to judge', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$otherBonusScore = BonusScoreDefinition::factory()->create();
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge);
$this->actingAs($judge);
// Act & Assert
$this->get(route('judging.index'))
->assertSee($bonusScore->name)
->assertDontSee($otherBonusScore->name);
});
it('shows auditions in a bonus score assignment', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
$bonusScore->auditions()->attach($audition);
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge);
$this->actingAs($judge);
// Act & Assert
$this->get(route('judging.index'))
->assertSee($audition->name);
});

View File

@ -0,0 +1,151 @@
<?php
use App\Models\Audition;
use App\Models\BonusScoreDefinition;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Sinnbeck\DomAssertions\Asserts\AssertForm;
uses(RefreshDatabase::class);
it('denies access to guests and non administrators', function () {
$this->get(route('admin.bonus-scores.index'))
->assertRedirect(route('home'));
actAsNormal();
$this->get(route('admin.bonus-scores.index'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
actAsTab();
$this->get(route('admin.bonus-scores.index'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
});
it('grants access to an administrator', function () {
// Arrange
actAsAdmin();
// Act & Assert
$this->get(route('admin.bonus-scores.index'))
->assertOk()
->assertViewIs('admin.bonus-scores.index');
});
it('if no bonus scores exist, show a create bonus score message', function () {
// Arrange
actAsAdmin();
// Act & Assert
$this->get(route('admin.bonus-scores.index'))
->assertOk()
->assertSee('No bonus scores have been created');
});
it('includes a form to add a new bonus score', function () {
// Arrange
actAsAdmin();
// Act & Assert
$this->get(route('admin.bonus-scores.index'))
->assertOk()
->assertFormExists('#create-bonus-score-form', function (AssertForm $form) {
/** @noinspection PhpUndefinedMethodInspection */
$form->hasCSRF()
->hasMethod('POST')
->hasAction(route('admin.bonus-scores.store'))
->containsInput(['name' => 'name'])
->containsInput(['name' => 'max_score', 'type' => 'number'])
->containsInput(['name' => 'weight']);
});
});
it('can create a new bonus score', function () {
// Arrange
$submissionData = [
'name' => 'New Bonus Score',
'max_score' => 10,
'weight' => 1,
];
// Act & Assert
actAsAdmin();
$this->post(route('admin.bonus-scores.store'), $submissionData)
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('success', 'Bonus Score Created');
$test = BonusScoreDefinition::where('name', 'New Bonus Score')->first();
expect($test->exists())->toBeTrue();
});
it('shows existing bonus scores', function () {
// Arrange
$bonusScores = BonusScoreDefinition::factory()->count(3)->create();
actAsAdmin();
// Act & Assert
$response = $this->get(route('admin.bonus-scores.index'));
$response->assertOk();
$bonusScores->each(fn ($bonusScore) => $response->assertSee($bonusScore->name));
});
it('can delete a bonus score with no auditions', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
actAsAdmin();
// Act & Assert
$this->delete(route('admin.bonus-scores.destroy', $bonusScore))
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('success', 'Bonus Score Deleted');
expect(BonusScoreDefinition::count())->toBe(0);
});
it('will not delete a bonus score that has auditions attached', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->hasAuditions(1)->create();
actAsAdmin();
// Act & Assert
$this->delete(route('admin.bonus-scores.destroy', $bonusScore))
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('error', 'Bonus Score has auditions attached');
expect(BonusScoreDefinition::count())->toBe(1);
});
it('can assign auditions to a bonus score', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$auditions = Audition::factory()->count(3)->create();
$submissionData = [
'bonus_score_id' => $bonusScore->id,
];
foreach ($auditions as $audition) {
$submissionData['audition'][$audition->id] = 'on';
}
// Act & Assert
actAsAdmin();
$this->post(route('admin.bonus-scores.addAuditions'), $submissionData)
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('success', 'Auditions assigned to bonus score');
$bonusScore->refresh();
$auditions->each(fn ($audition) => expect($bonusScore->auditions->contains($audition))->toBeTrue());
});
it('can unassign auditions from a bonus score', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->hasAuditions(3)->create();
$audition = $bonusScore->auditions->first();
// Act & Assert
actAsAdmin();
$this->delete(route('admin.bonus-scores.unassignAudition', $audition))
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('success', 'Audition unassigned from bonus score');
$bonusScore->refresh();
expect($bonusScore->auditions->contains($audition))->toBeFalse();
});
it('sends a message when attempting to unassign an audition that is not assigned', function () {
$bonusScore = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
actAsAdmin();
$this->delete(route('admin.bonus-scores.unassignAudition', $audition))
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('error', 'Audition does not have a bonus score');
});
it('will not allow an audition to be assigned to multiple bonus scores', function () {
$bonusScore1 = BonusScoreDefinition::factory()->create();
$bonusScore2 = BonusScoreDefinition::factory()->create();
$audition = Audition::factory()->create();
$bonusScore1->auditions()->attach($audition);
$submissionData = [
'bonus_score_id' => $bonusScore2->id,
'audition' => [$audition->id => 'on'],
];
actAsAdmin();
$this->post(route('admin.bonus-scores.addAuditions'), $submissionData)
->assertRedirect(route('admin.bonus-scores.index'))
->assertSessionHas('error', 'Error assigning auditions to bonus score');
});

View File

@ -0,0 +1,70 @@
<?php
use App\Models\BonusScoreDefinition;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('denies access to guests and non administrators', function () {
$this->get(route('admin.bonus-scores.judges'))
->assertRedirect(route('home'));
actAsNormal();
$this->get(route('admin.bonus-scores.judges'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
actAsTab();
$this->get(route('admin.bonus-scores.judges'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
});
it('grants access to an administrator', function () {
// Arrange
actAsAdmin();
// Act & Assert
$this->get(route('admin.bonus-scores.judges'))
->assertOk()
->assertViewIs('admin.bonus-scores.judge-assignments');
});
it('shows a link to the room judge assignment screen', function () {
// Arrange
actAsAdmin();
// Act & Assert
$this->get(route('admin.bonus-scores.judges'))
->assertOk()
->assertSee(route('admin.rooms.judgingAssignment'));
});
it('shows a card for each bonus score', function () {
// Arrange
$bonusScores = BonusScoreDefinition::factory()->count(3)->create();
actAsAdmin();
// Act & Assert
$response = $this->get(route('admin.bonus-scores.judges'));
$response->assertOk();
$bonusScores->each(fn ($bonus) => $response->assertElementExists('#bonus-'.$bonus->id.'-card'));
});
it('can assign a judge to a bonus score', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$judge = User::factory()->create();
actAsAdmin();
// Act & Assert
$this->post(route('admin.bonus-scores.judges.assign', $bonusScore), ['judge' => $judge->id])
->assertRedirect(route('admin.bonus-scores.judges'))
->assertSessionHas('success', 'Judge assigned to bonus score');
expect($bonusScore->judges()->count())->toBe(1);
});
it('can assign a judge to a room', function () {
// Arrange
$bonusScore = BonusScoreDefinition::factory()->create();
$judge = User::factory()->create();
$bonusScore->judges()->attach($judge->id);
actAsAdmin();
// Act & Assert
$this->delete(route('admin.bonus-scores.judges.remove', $bonusScore), ['judge' => $judge->id])
->assertRedirect(route('admin.bonus-scores.judges'))
->assertSessionHas('success', 'Judge removed from bonus score');
expect($bonusScore->judges()->count())->toBe(0);
});