From 8f8cd949592e6412f832071836c872f26bef3f2e Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Thu, 27 Nov 2025 11:45:26 +0100 Subject: [PATCH] enable group swap --- app/Http/Controllers/TournamentController.php | 31 +++++++++++++++++++ app/Models/GroupAssignment.php | 7 +++++ app/Models/Protocol.php | 14 +++++++++ app/Models/Tournament.php | 12 +++++-- app/Policies/RoundPolicy.php | 13 ++++++++ app/Policies/TournamentPolicy.php | 14 ++++++++- resources/js/components/common/Icon.jsx | 1 + resources/js/components/groups/Item.jsx | 12 +++++-- resources/js/components/groups/SwapButton.jsx | 17 ++++++++++ resources/js/components/protocol/Item.jsx | 2 ++ resources/js/helpers/permissions.js | 5 +++ resources/js/i18n/de.js | 6 ++++ resources/js/i18n/en.js | 6 ++++ resources/js/pages/Tournament.jsx | 16 +++++++++- resources/sass/groups.scss | 6 ---- routes/api.php | 1 + 16 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 resources/js/components/groups/SwapButton.jsx diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index 0998283..ecba817 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -209,6 +209,37 @@ class TournamentController extends Controller ->toJson(); } + public function swapGroup(Request $request, Tournament $tournament) { + $this->authorize('swapGroups', $tournament); + $user = $request->user(); + + $validatedData = $request->validate([ + 'round_number' => 'numeric|required', + ]); + $number = $validatedData['round_number']; + + $existing = GroupAssignment::query() + ->whereBelongsTo($tournament) + ->whereBelongsTo($user) + ->where('round_number', '=', $number) + ->firstOrFail(); + + $round = $existing->getRound(); + $this->authorize('swapGroup', $round); + + $new_group = $tournament->pickGroup($number, $user, [$existing->group]); + $existing->group = $new_group; + $existing->save(); + + Protocol::groupSwap($tournament, $user, ['number' => $number, 'group' => $new_group], $user); + + return GroupAssignment::query() + ->whereBelongsTo($tournament) + ->whereBelongsTo($user) + ->get() + ->toJson(); + } + public function web(Request $request, Tournament $tournament) { $view = view('app') ->with('title', $tournament->getTranslatedTitle()) diff --git a/app/Models/GroupAssignment.php b/app/Models/GroupAssignment.php index a0fdfa2..ef384c7 100644 --- a/app/Models/GroupAssignment.php +++ b/app/Models/GroupAssignment.php @@ -14,6 +14,13 @@ class GroupAssignment extends Model { return $this->belongsTo(User::class); } + public function getRound(): Round { + return $this->tournament->rounds() + ->where('number', '=', $this->round_number) + ->where('group', '=', $this->group) + ->firstOrFail(); + } + protected $casts = [ 'user_id' => 'string', ]; diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index cfade45..76b096f 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -66,6 +66,20 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function groupSwap(Tournament $tournament, User $assignee, $pick, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'group.swap', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'assignee' => static::userMemo($assignee), + 'picks' => [$pick], + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function resultCommented(Tournament $tournament, Result $result, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index 74f2956..71ac44b 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -73,9 +73,15 @@ class Tournament extends Model { } } - public function pickGroup($number, User $user) { - $available_rounds = $this->rounds()->where('number', '=', $number)->get(); - $assigned_groups = $this->group_assignments()->where('round_number', '=', $number)->get(); + public function pickGroup($number, User $user, $exclude = []) { + $available_rounds = $this->rounds() + ->where('number', '=', $number) + ->whereNotIn('group', $exclude) + ->get(); + $assigned_groups = $this->group_assignments() + ->where('round_number', '=', $number) + ->whereNotIn('group', $exclude) + ->get(); $weights = array(); foreach ($available_rounds as $round) { $weights[$round->group] = $assigned_groups->count() + 1; diff --git a/app/Policies/RoundPolicy.php b/app/Policies/RoundPolicy.php index 40373b1..bd1f0e9 100644 --- a/app/Policies/RoundPolicy.php +++ b/app/Policies/RoundPolicy.php @@ -174,4 +174,17 @@ class RoundPolicy return $this->lock($user, $round); } + /** + * Determine whether the user can swap groups within this round. + * + * @param \App\Models\User $user + * @param \App\Models\Round $round + * @return \Illuminate\Auth\Access\Response|bool + */ + public function swapGroup(User $user, Round $round) + { + $result = $user->findResult($round); + return $result && !$round->locked; + } + } diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index 66dfdab..c43af70 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -129,7 +129,7 @@ class TournamentPolicy } /** - * Determine whether the user self assign groups within the tournament. + * Determine whether the user can self assign groups within the tournament. * * @param \App\Models\User $user * @param \App\Models\Tournament $tournament @@ -140,4 +140,16 @@ class TournamentPolicy return !$tournament->locked && !!$user; } + /** + * Determine whether the user can swap groups within the tournament. + * + * @param \App\Models\User $user + * @param \App\Models\Tournament $tournament + * @return \Illuminate\Auth\Access\Response|bool + */ + public function swapGroups(User $user, Tournament $tournament) + { + return !$tournament->locked && !!$user; + } + } diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx index 16f26b8..143f0b1 100644 --- a/resources/js/components/common/Icon.jsx +++ b/resources/js/components/common/Icon.jsx @@ -102,6 +102,7 @@ Icon.STEP_BACKWARD = makePreset('StepBackwardIcon', 'backward-step'); Icon.STEP_FORWARD = makePreset('StepForwardIcon', 'forward-step'); Icon.STOP = makePreset('StopIcon', 'stop'); Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']); +Icon.SWAP = makePreset('SwapIcon', 'rotate'); Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award'); Icon.TIME_REVERSE = makePreset('TimeReverseIcon', 'clock-rotate-left'); Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']); diff --git a/resources/js/components/groups/Item.jsx b/resources/js/components/groups/Item.jsx index 1322307..57204c7 100644 --- a/resources/js/components/groups/Item.jsx +++ b/resources/js/components/groups/Item.jsx @@ -3,12 +3,13 @@ import React from 'react'; import { Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import SwapButton from './SwapButton'; import Icon from '../common/Icon'; import Badge from '../results/Badge'; import ReportButton from '../results/ReportButton'; import SeedButton from '../rounds/SeedButton'; import SeedCode from '../rounds/SeedCode'; -import { mayReportResult } from '../../helpers/permissions'; +import { mayReportResult, maySwapGroup } from '../../helpers/permissions'; import { formatNumberAlways } from '../../helpers/Round'; import { findResult } from '../../helpers/User'; import { useUser } from '../../hooks/user'; @@ -35,6 +36,7 @@ const getStatusIcon = (round, result, t) => { } const Item = ({ + actions, round, tournament, }) => { @@ -56,13 +58,16 @@ const Item = ({
{getStatusIcon(round, result, t)} -
+
{round.code && round.code.length ? : null} + {actions.swapGroup && maySwapGroup(user, tournament, round, result) ? + actions.swapGroup(round)} /> + : null}
-
+
{mayReportResult(user, tournament) ? : null} @@ -74,6 +79,7 @@ const Item = ({ Item.propTypes = { actions: PropTypes.shape({ + swapGroup: PropTypes.func, }), round: PropTypes.shape({ code: PropTypes.arrayOf(PropTypes.string), diff --git a/resources/js/components/groups/SwapButton.jsx b/resources/js/components/groups/SwapButton.jsx new file mode 100644 index 0000000..0fbba01 --- /dev/null +++ b/resources/js/components/groups/SwapButton.jsx @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button } from 'react-bootstrap'; + +import Icon from '../common/Icon'; + +const SwapButton = ({ onClick }) => { + return ; +}; + +SwapButton.propTypes = { + onClick: PropTypes.func, +}; + +export default SwapButton; diff --git a/resources/js/components/protocol/Item.jsx b/resources/js/components/protocol/Item.jsx index d215bb9..ebd874d 100644 --- a/resources/js/components/protocol/Item.jsx +++ b/resources/js/components/protocol/Item.jsx @@ -72,11 +72,13 @@ const getEntryDescription = (entry, t) => { }, ); case 'group.assign': + case 'group.swap': return t( `protocol.description.${entry.type}`, { ...entry, assignee: getEntryDetailsAssignee(entry), + count: (entry?.details?.picks?.length) || 0, picks: getEntryDetailsPicks(entry), }, ); diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index cbb607c..c859041 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -3,6 +3,7 @@ import * as Episode from './Episode'; import Round from './Round'; +import User from './User'; export const hasGlobalRole = (user, role) => user && role && user.global_roles && user.global_roles.includes(role); @@ -212,6 +213,10 @@ export const maySeeResult = (user, tournament, round, result) => { return maySeeResults(user, tournament, round); }; +export const maySwapGroup = (user, tournament, round, result) => { + return user && result && tournament?.group_size > 1 && !tournament.locked && round && !round.locked; +}; + export const mayModifyResults = (user, tournament, round) => { return isTournamentCrew(user, tournament); }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index b1cf480..8b3068a 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -391,6 +391,9 @@ export default { selfAssignButton: 'Gruppen zuweisen', selfAssignError: 'Fehler beim Zuweisen', selfAssignSuccess: 'Gruppen zugewiesen', + swapGroup: 'Gruppe wechseln', + swapGroupError: 'Fehler beim Wechsel', + swapGroupSuccess: 'Gruppe gewechselt', tournamentClosed: 'Dieses Turnier ist geschlossen.', verified: 'Bestätigt', }, @@ -581,6 +584,9 @@ export default { }, group: { assign: 'Gruppen {{picks}} für {{assignee}} zugewiesen', + assign_one: 'Gruppe {{picks}} für {{assignee}} zugewiesen', + swap: 'Gruppen {{picks}} für {{assignee}} gewechselt', + swap_one: 'Gruppe {{picks}} für {{assignee}} gewechselt', }, result: { comment: 'Ergebnis von Runde {{number}} kommentiert: <1>{{comment}}', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index b52fbab..8b978e6 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -391,6 +391,9 @@ export default { selfAssignButton: 'Assign groups', selfAssignError: 'Error assigning groups', selfAssignSuccess: 'Groups assigned', + swapGroup: 'Swap group', + swapGroupError: 'Error swapping group', + swapGroupSuccess: 'Swapped', tournamentClosed: 'This tournament has closed.', verified: 'Verified', }, @@ -581,6 +584,9 @@ export default { }, group: { assign: 'Assigned groups {{picks}} for {{assignee}}', + assign_one: 'Assigned group {{picks}} for {{assignee}}', + swap: 'Swapped groups {{picks}} for {{assignee}}', + swap_one: 'Swapped group {{picks}} for {{assignee}}', }, result: { comment: 'Result of round {{number}} commented: <1>{{comment}}', diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx index a3c4170..1f0584c 100644 --- a/resources/js/pages/Tournament.jsx +++ b/resources/js/pages/Tournament.jsx @@ -175,6 +175,19 @@ export const Component = () => { } }, [id, t]); + const swapGroup = React.useCallback(async (round) => { + try { + const response = await axios.post(`/api/tournaments/${id}/swap-group`, { round_number: round.number }); + toastr.success(t('groups.swapGroupSuccess')); + setTournament(tournament => ({ + ...tournament, + group_assignments: response.data, + })); + } catch (e) { + toastr.error(t('groups.swapGroupError', e)); + } + }, [id, t]); + const modifyResult = React.useCallback(async (result, values) => { try { const response = await axios.post(`/api/results/${result.id}/modify`, values); @@ -215,9 +228,10 @@ export const Component = () => { modifyResult: mayModifyResults(user, tournament) ? modifyResult : null, moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null, selfAssignGroups, + swapGroup, unverifyResult: mayUnverifyResults(user, tournament) ? unverifyResult : null, verifyResult: mayVerifyResults(user, tournament) ? verifyResult : null, - }), [addRound, modifyResult, moreRounds, selfAssignGroups, tournament, unverifyResult, user, verifyResult]); + }), [addRound, modifyResult, moreRounds, selfAssignGroups, swapGroup, tournament, unverifyResult, user, verifyResult]); useEffect(() => { const cb = (e) => { diff --git a/resources/sass/groups.scss b/resources/sass/groups.scss index d76df89..80ea5b9 100644 --- a/resources/sass/groups.scss +++ b/resources/sass/groups.scss @@ -19,10 +19,4 @@ margin-left: auto; text-align: right; } - .seed-code { - margin-right: 1ex; - } - .result-badge { - margin-left: 1ex; - } } diff --git a/routes/api.php b/routes/api.php index 5eb9e13..88f19bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -113,6 +113,7 @@ Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentCon Route::post('tournaments/{tournament}/open', 'App\Http\Controllers\TournamentController@open'); Route::post('tournaments/{tournament}/settings', 'App\Http\Controllers\TournamentController@settings'); Route::post('tournaments/{tournament}/self-assign-groups', 'App\Http\Controllers\TournamentController@selfAssignGroups'); +Route::post('tournaments/{tournament}/swap-group', 'App\Http\Controllers\TournamentController@swapGroup'); Route::post('tournaments/{tournament}/unlock', 'App\Http\Controllers\TournamentController@unlock'); Route::get('users', 'App\Http\Controllers\UserController@search'); -- 2.47.3