From 6652a794b25cac25f534a8dc83847d377bfb63cf Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 2 Jan 2026 17:47:03 +0100 Subject: [PATCH] group swap style settings --- .../Controllers/GroupAssignmentController.php | 30 +++++ app/Http/Controllers/RoundController.php | 23 ++++ app/Http/Controllers/TournamentController.php | 4 + app/Models/GroupAssignment.php | 16 +++ app/Policies/GroupAssignmentPolicy.php | 24 ++++ app/Policies/RoundPolicy.php | 20 ++- ..._02_130509_tournament_group_swap_style.php | 29 ++++ resources/js/components/common/Icon.jsx | 1 + .../js/components/rounds/GroupsButton.jsx | 43 ++++++ .../js/components/rounds/GroupsDialog.jsx | 125 ++++++++++++++++++ resources/js/components/rounds/Item.jsx | 5 + .../components/tournament/SettingsDialog.jsx | 28 +++- resources/js/helpers/permissions.js | 19 ++- resources/js/i18n/de.js | 11 ++ resources/js/i18n/en.js | 11 ++ resources/js/pages/Tournament.jsx | 20 +++ routes/api.php | 3 + routes/channels.php | 5 + 18 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/GroupAssignmentController.php create mode 100644 app/Policies/GroupAssignmentPolicy.php create mode 100644 database/migrations/2026_01_02_130509_tournament_group_swap_style.php create mode 100644 resources/js/components/rounds/GroupsButton.jsx create mode 100644 resources/js/components/rounds/GroupsDialog.jsx diff --git a/app/Http/Controllers/GroupAssignmentController.php b/app/Http/Controllers/GroupAssignmentController.php new file mode 100644 index 0000000..13a0ec8 --- /dev/null +++ b/app/Http/Controllers/GroupAssignmentController.php @@ -0,0 +1,30 @@ +validate([ + 'group' => 'string|required|in:A,B,C,D,E,F,G,H', + ]); + $this->authorize('changeAssignment', $assignment); + + $assignment->group = $validatedData['group']; + $assignment->save(); + + Protocol::groupSwap( + $assignment->tournament, + $assignment->user, + ['number' => $assignment->round_number, 'group' => $assignment->group], + $request->user(), + ); + + return $assignment->toArray(); + } + +} diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index a33785b..288b3ab 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Events\RoundAdded; use App\Events\RoundChanged; +use App\Models\GroupAssignment; use App\Models\Protocol; use App\Models\Round; use App\Models\Tournament; @@ -131,6 +132,28 @@ class RoundController extends Controller return redirect($round->seed); } + public function groups(Round $round) { + $this->authorize('seeGroups', $round); + + $assignments = GroupAssignment::query() + ->where('tournament_id', '=', $round->tournament_id) + ->where('round_number', '=', $round->number) + ->with('user') + ->get() + ->toArray(); + $groups = Round::query() + ->where('tournament_id', '=', $round->tournament_id) + ->where('number', '=', $round->number) + ->get() + ->pluck('group') + ->toArray(); + + return [ + 'groups' => $groups, + 'assignments' => $assignments, + ]; + } + public function uploadSeed(Request $request, Round $round) { $this->authorize('update', $round); diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index b3561ee..3649ae8 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -115,12 +115,16 @@ class TournamentController extends Controller $this->authorize('update', $tournament); $validatedData = $request->validate([ 'group_size' => 'integer|nullable', + 'group_swap_style' => 'string|nullable|in:admin,always,finished,never', 'result_reveal' => 'string|nullable|in:always,finishers,never,participants', 'show_numbers' => 'boolean|nullable', ]); if (array_key_exists('group_size', $validatedData)) { $tournament->group_size = $validatedData['group_size']; } + if (array_key_exists('group_swap_style', $validatedData)) { + $tournament->group_swap_style = $validatedData['group_swap_style']; + } if (isset($validatedData['result_reveal'])) { $tournament->result_reveal = $validatedData['result_reveal']; } diff --git a/app/Models/GroupAssignment.php b/app/Models/GroupAssignment.php index ef384c7..35b93e3 100644 --- a/app/Models/GroupAssignment.php +++ b/app/Models/GroupAssignment.php @@ -2,10 +2,26 @@ namespace App\Models; +use Illuminate\Broadcasting\PrivateChannel; +use Illuminate\Database\Eloquent\BroadcastsEvents; use Illuminate\Database\Eloquent\Model; class GroupAssignment extends Model { + use BroadcastsEvents; + + public function broadcastOn($event) { + $channels = [ + new PrivateChannel('Tournament.'.$this->tournament_id.'.'.$this->user_id), + ]; + return $channels; + } + + public function broadcastWith($event) { + $this->load(['user']); + } + + public function tournament() { return $this->belongsTo(Tournament::class); } diff --git a/app/Policies/GroupAssignmentPolicy.php b/app/Policies/GroupAssignmentPolicy.php new file mode 100644 index 0000000..3b2147a --- /dev/null +++ b/app/Policies/GroupAssignmentPolicy.php @@ -0,0 +1,24 @@ +isTournamentAdmin($assignment->tournament); + } + +} diff --git a/app/Policies/RoundPolicy.php b/app/Policies/RoundPolicy.php index ba8c3ad..3afff4a 100644 --- a/app/Policies/RoundPolicy.php +++ b/app/Policies/RoundPolicy.php @@ -150,6 +150,18 @@ class RoundPolicy return !!$user; } + /** + * Determine whether the user can see al group assignments for this round. + * + * @param \App\Models\User $user + * @param \App\Models\Round $round + * @return \Illuminate\Auth\Access\Response|bool + */ + public function seeGroups(User $user = null, Round $round): bool + { + return $user->isTournamentCrew($round->tournament); + } + /** * Determine whether the user can lock this round. * @@ -183,8 +195,14 @@ class RoundPolicy */ public function swapGroup(User $user, Round $round) { + if ($round->locked || $round->tournament->locked) { + return false; + } + if (in_array($round->tournament->group_swap_style, ['admin', 'never'])) { + return false; + } $result = $user->findResult($round); - if (!$result || $round->locked || $round->tournament->locked) { + if ($round->tournament->group_swap_style == 'finished' && !$result) { return false; } $remaining = $round->tournament->rounds() diff --git a/database/migrations/2026_01_02_130509_tournament_group_swap_style.php b/database/migrations/2026_01_02_130509_tournament_group_swap_style.php new file mode 100644 index 0000000..7949fef --- /dev/null +++ b/database/migrations/2026_01_02_130509_tournament_group_swap_style.php @@ -0,0 +1,29 @@ +string('group_swap_style')->default('finished'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tournaments', function (Blueprint $table) { + $table->dropColumn('group_swap_style'); + }); + } + +}; diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx index 2aebeb5..7ea4a01 100644 --- a/resources/js/components/common/Icon.jsx +++ b/resources/js/components/common/Icon.jsx @@ -71,6 +71,7 @@ Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy'); Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark'); Icon.FORFEIT = makePreset('ForfeitIcon', 'square-minus'); +Icon.GROUPS = makePreset('GroupsIcon', 'group-arrows-rotate'); Icon.HASH = makePreset('HashIcon', 'hashtag'); Icon.INFO = makePreset('Info', 'circle-info'); Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke'); diff --git a/resources/js/components/rounds/GroupsButton.jsx b/resources/js/components/rounds/GroupsButton.jsx new file mode 100644 index 0000000..03b4bd3 --- /dev/null +++ b/resources/js/components/rounds/GroupsButton.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import GroupsDialog from './GroupsDialog'; +import Icon from '../common/Icon'; + +const GroupsButton = ({ + round, + tournament, +}) => { + const [showDialog, setShowDialog] = useState(false); + + const { t } = useTranslation(); + + return <> + setShowDialog(false)} + round={round} + show={showDialog} + tournament={tournament} + /> + + ; +}; + +GroupsButton.propTypes = { + round: PropTypes.shape({ + locked: PropTypes.bool, + }), + tournament: PropTypes.shape({ + }), +}; + +export default GroupsButton; diff --git a/resources/js/components/rounds/GroupsDialog.jsx b/resources/js/components/rounds/GroupsDialog.jsx new file mode 100644 index 0000000..42ca01e --- /dev/null +++ b/resources/js/components/rounds/GroupsDialog.jsx @@ -0,0 +1,125 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Form, Modal, Table } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import Loading from '../common/Loading'; +import UserBox from '../users/Box'; +import { mayModifyGroups } from '../../helpers/permissions'; +import { useUser } from '../../hooks/user'; + +const GroupsDialog = ({ + onHide, + round, + show, + tournament, +}) => { + const [groups, setGroups] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + const { t } = useTranslation(); + const { user } = useUser(); + + const mayModify = React.useMemo(() => mayModifyGroups(user, tournament, round), [round, tournament, user]); + + React.useEffect(() => { + if (!show || !round?.id) return; + setLoading(true); + const ctrl = new AbortController(); + axios + .get(`/api/rounds/${round.id}/groups`, { signal: ctrl.signal }) + .then(response => { + setGroups(response.data); + setLoading(false); + }) + .catch((e) => { + console.error(e); + setLoading(false); + }); + return () => { + ctrl.abort(); + }; + }, [round?.id, show]); + + const changeAssignment = React.useCallback(async (asgn_id, group) => { + try { + const response = await axios.post(`/api/group-assignments/${asgn_id}/change`, { group }); + setGroups((oldGroups) => ({ + assignments: oldGroups.assignments.map((asgn) => { + if (asgn.id === asgn_id) { + return response.data; + } + return asgn; + }), + groups: oldGroups.groups, + })); + toastr.success(t('groups.changeSuccess')); + } catch (e) { + console.error(e); + toastr.error(t('groups.changeError')); + } + }, [t]); + + return + + + {t('rounds.groups')} + + + {loading ? + + : + + + + + + + + + {(groups?.assignments || []).map((asgn) => ( + + + + + ))} + +
{t('results.runner')}{t('groups.group')}
+ + + {mayModify ? + + changeAssignment(asgn.id, value)} + value={asgn.group} + > + {groups.groups.map((group) => ( + + ))} + + : + asgn.group + } +
+ } + + + +
; +}; + +GroupsDialog.propTypes = { + onHide: PropTypes.func, + round: PropTypes.shape({ + id: PropTypes.number, + }), + show: PropTypes.bool, + tournament: PropTypes.shape({ + }), +}; + +export default GroupsDialog; diff --git a/resources/js/components/rounds/Item.jsx b/resources/js/components/rounds/Item.jsx index 5212394..b37b34b 100644 --- a/resources/js/components/rounds/Item.jsx +++ b/resources/js/components/rounds/Item.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import DeleteButton from './DeleteButton'; import EditButton from './EditButton'; +import GroupsButton from './GroupsButton'; import LockButton from './LockButton'; import SeedButton from './SeedButton'; import SeedCode from './SeedCode'; @@ -15,6 +16,7 @@ import Table from '../results/Table'; import { mayDeleteRound, mayEditRound, + maySeeGroups, mayReportResult, mayVerifyResults, mayViewProtocol, @@ -109,6 +111,9 @@ const Item = ({ {mayEditRound(user, tournament, round) ? : null} + {maySeeGroups(user, tournament, round) ? + + : null} {mayViewProtocol(user, tournament, round) ? : null} diff --git a/resources/js/components/tournament/SettingsDialog.jsx b/resources/js/components/tournament/SettingsDialog.jsx index a24f383..ec38a9a 100644 --- a/resources/js/components/tournament/SettingsDialog.jsx +++ b/resources/js/components/tournament/SettingsDialog.jsx @@ -112,7 +112,7 @@ const SettingsDialog = ({ value={tournament.show_numbers} /> - {Tournament.hasAssignedGroups(tournament) ? ( + {Tournament.hasAssignedGroups(tournament) ? <>
{i18n.t('tournaments.groupSize')}
- ) : null} +
+ {i18n.t('tournaments.groupSwapStyle')} + + settings(tournament, { group_swap_style: value })} + value={tournament.group_swap_style} + > + {['always', 'finished', 'admin', 'never'].map((key) => + + )} + +
+ : null}
{i18n.t('tournaments.resultReveal')} settings(tournament, { result_reveal: value })} - style={{ width: '50%' }} value={tournament.result_reveal} > {['never', 'finishers', 'participants', 'always'].map((key) => @@ -194,8 +213,9 @@ SettingsDialog.propTypes = { tournament: PropTypes.shape({ accept_applications: PropTypes.bool, discord: PropTypes.string, - locked: PropTypes.bool, group_size: PropTypes.number, + group_swap_style: PropTypes.string, + locked: PropTypes.bool, result_reveal: PropTypes.string, show_numbers: PropTypes.bool, }), diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 542d5a6..2308b47 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -214,7 +214,14 @@ export const maySeeResult = (user, tournament, round, result) => { }; export const maySwapGroup = (user, tournament, round, result) => { - if (!user || !result || tournament?.group_size <= 1 || tournament.locked || !tournament?.rounds || !round || round.locked) { + if (!user || tournament?.group_size <= 1 || tournament.locked || !tournament.rounds || !round || round.locked) { + return false; + } + const style = tournament.group_swap_style || 'finished'; + if (['admin', 'never'].includes(style)) { + return false; + } + if (style === 'finished' && !result) { return false; } const remaining_rounds = tournament.rounds.filter( @@ -223,10 +230,18 @@ export const maySwapGroup = (user, tournament, round, result) => { return remaining_rounds.length > 0; }; -export const mayModifyResults = (user, tournament, round) => { +export const maySeeGroups = (user, tournament, round) => { return isTournamentCrew(user, tournament); }; +export const mayModifyGroups = (user, tournament, round) => { + return tournament?.group_swap_style !== 'never' && !round?.locked && isTournamentAdmin(user, tournament); +}; + +export const mayModifyResults = (user, tournament, round) => { + return !round?.locked && isTournamentCrew(user, tournament); +}; + export const mayModifyResult = (user, tournament, round, result) => { return mayModifyResults(user, tournament) && user && result && !result.verified_at && user.id !== result.user_id; }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index ae56a37..db4c576 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -384,8 +384,11 @@ export default { uploading: 'Am Hochladen...', }, groups: { + changeError: 'Fehler beim Gruppenwechsel', + changeSuccess: 'Gruppe gewechselt', complete: 'Abgeschlossen', empty: 'Noch keine Gruppen verfügbar', + group: 'Gruppe', heading: 'Gruppen', loginRequired: 'Dieses Turnier nutzt Gruppenzuweisung. Bitte melde dich an, um deine Seeds zu laden.', missingAssignments: 'Dieses Turnier nutzt Gruppenzuweisung. Falls du teilnehmen möchtest, hol dir bitter hier deine Zuweisungen ab.', @@ -682,6 +685,7 @@ export default { editError: 'Fehler beim Speichern', editSuccess: 'Gespeichert', empty: 'Noch keine Runde gestartet', + groups: 'Gruppenzuweisung', heading: 'Runden', new: 'Neue Runde', noSeed: 'Noch kein Seed', @@ -925,6 +929,13 @@ export default { discordSettingsSuccess: 'Discord Einstellungen gespeichert', discordSuccess: 'Discord verknüpft', groupSize: 'Seeds pro Runde', + groupSwapStyle: 'Seed-Wechsel', + groupSwapStyles: { + admin: 'Durch Admins', + always: 'Immer', + finished: 'Wenn beendet', + never: 'Nie', + }, inviteBot: 'Bot einladen', locked: 'Turnier sperren', lockError: 'Fehler beim Sperren', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index d633cae..ca922e4 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -384,8 +384,11 @@ export default { uploading: 'Uploading...', }, groups: { + changeError: 'Error changing groups', + changeSuccess: 'Group assignment changed', complete: 'Complete', empty: 'No groups available yet', + group: 'Group', heading: 'Groups', loginRequired: 'This tournament uses assigned groups. Please sign in to obtain your seeds.', missingAssignments: 'This tournament uses assigned groups. If you want to participate, please grab your assignments here.', @@ -683,6 +686,7 @@ export default { editError: 'Error saving round', editSuccess: 'Saved successfully', empty: 'No rounds yet', + groups: 'Group assignments', heading: 'Rounds', new: 'New round', noSeed: 'No seed set', @@ -926,6 +930,13 @@ export default { discordSettingsSuccess: 'Discord settings saved', discordSuccess: 'Discord associated', groupSize: 'Seeds per Round', + groupSwapStyle: 'Seed swapping', + groupSwapStyles: { + admin: 'By admins', + always: 'Always', + finished: 'After finishing', + never: 'Never', + }, inviteBot: 'Invite bot', locked: 'Lock rounds', lockError: 'Error locking tournament', diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx index 3b65a14..87c7a48 100644 --- a/resources/js/pages/Tournament.jsx +++ b/resources/js/pages/Tournament.jsx @@ -254,6 +254,26 @@ export const Component = () => { }; }, []); + useEffect(() => { + if (!tournament?.id || !user?.id) return; + window.Echo.private(`Tournament.${tournament.id}.${user.id}`) + .listen('.GroupAssignmentUpdated', ({ model }) => { + console.log(model); + setTournament((t) => ({ + ...t, + group_assignments: t.group_assignments.map((asgn) => { + if (asgn.id === model.id) { + return model; + } + return asgn; + }), + })); + }); + return () => { + window.Echo.leave(`Tournament.${tournament.id}.${user.id}`); + }; + }, [tournament?.id, user?.id]); + if (loading) { return ; } diff --git a/routes/api.php b/routes/api.php index 5afc0ef..e32b450 100644 --- a/routes/api.php +++ b/routes/api.php @@ -76,6 +76,8 @@ Route::get('events', 'App\Http\Controllers\EventController@search'); Route::get('events/{event:name}', 'App\Http\Controllers\EventController@single'); Route::post('events/{event}/add-episode', 'App\Http\Controllers\EpisodeController@create'); +Route::post('group-assignments/{assignment}/change', 'App\Http\Controllers\GroupAssignmentController@changeAssignment'); + Route::get('markers/{map}', 'App\Http\Controllers\TechniqueController@forMap'); Route::get('pages/{type}', 'App\Http\Controllers\TechniqueController@byType'); @@ -93,6 +95,7 @@ Route::post('results/{result}/unverify', 'App\Http\Controllers\ResultController@ Route::post('rounds', 'App\Http\Controllers\RoundController@create'); Route::put('rounds/{round}', 'App\Http\Controllers\RoundController@update'); Route::delete('rounds/{round}', 'App\Http\Controllers\RoundController@delete'); +Route::get('rounds/{round}/groups', 'App\Http\Controllers\RoundController@groups'); Route::post('rounds/{round}/lock', 'App\Http\Controllers\RoundController@lock'); Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed'); Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock'); diff --git a/routes/channels.php b/routes/channels.php index 0aee0d2..cea1e31 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -43,3 +43,8 @@ Broadcast::channel('Tournament.{id}', function ($user, $id) { $tournament = Tournament::findOrFail($id); return true; }); + +Broadcast::channel('Tournament.{id}.{user_id}', function ($user, $id, $user_id) { + $tournament = Tournament::findOrFail($id); + return $user->id == $user_id; +}); -- 2.47.3