]> git.localhorst.tv Git - alttp.git/commitdiff
enable group swap
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 27 Nov 2025 10:45:26 +0000 (11:45 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 27 Nov 2025 10:45:26 +0000 (11:45 +0100)
16 files changed:
app/Http/Controllers/TournamentController.php
app/Models/GroupAssignment.php
app/Models/Protocol.php
app/Models/Tournament.php
app/Policies/RoundPolicy.php
app/Policies/TournamentPolicy.php
resources/js/components/common/Icon.jsx
resources/js/components/groups/Item.jsx
resources/js/components/groups/SwapButton.jsx [new file with mode: 0644]
resources/js/components/protocol/Item.jsx
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tournament.jsx
resources/sass/groups.scss
routes/api.php

index 0998283b0863688b354e551e08c9ca0ca4bdbed7..ecba817ecc1d1914d9a2f9a6e51454532c48b8a5 100644 (file)
@@ -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())
index a0fdfa261c641338a1fe1129741b2ff13519f9b0..ef384c7502c28a1579858a61d2d1cffab7b164c1 100644 (file)
@@ -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',
        ];
index cfade454818b628d0c0b8f0b1b497c03820c6d49..76b096fff4b089b0948675645a14e561383fc340 100644 (file)
@@ -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,
index 74f2956c6006b9207ab918005eb47960f0be1872..71ac44baa2b2b977e83fc29846a37dfdf1fd761a 100644 (file)
@@ -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;
index 40373b147ec8deaea748ba25d57f7d0614963145..bd1f0e96bc86da05bb2190a21500808e514b8344 100644 (file)
@@ -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;
+       }
+
 }
index 66dfdaba3856af7116f105ec349756622760abce..c43af70f8f5c02ab71277caba763974941165015 100644 (file)
@@ -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;
+       }
+
 }
index 16f26b8e026073af3aab59d7b91e70f39e794c70..143f0b13729abe704937c163825c793c8a982bc3 100644 (file)
@@ -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']);
index 1322307e0a96255a31a180b364e644adc87b4c91..57204c711a4ce9ee3a28c34f4b97019fcce0db26 100644 (file)
@@ -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 = ({
                </h3>
                <div className="group-details">
                        {getStatusIcon(round, result, t)}
-                       <div className="group-seed">
+                       <div className="button-bar group-seed">
                                {round.code && round.code.length ?
                                        <SeedCode code={round.code} game={round.game || 'alttpr'} />
                                : null}
                                <SeedButton round={round} tournament={tournament} />
+                               {actions.swapGroup && maySwapGroup(user, tournament, round, result) ?
+                                       <SwapButton onClick={() => actions.swapGroup(round)} />
+                               : null}
                        </div>
-                       <div className="group-result">
+                       <div className="button-bar group-result">
                                {mayReportResult(user, tournament) ?
                                        <ReportButton round={round} tournament={tournament} user={user} />
                                : 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 (file)
index 0000000..0fbba01
--- /dev/null
@@ -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 <Button onClick={onClick} variant="outline-secondary">
+               <Icon.SWAP />
+       </Button>;
+};
+
+SwapButton.propTypes = {
+       onClick: PropTypes.func,
+};
+
+export default SwapButton;
index d215bb92a9c4d674e7daec07800778bfef25bf05..ebd874d7566951de93bb3d3cb1693448eb1a2f35 100644 (file)
@@ -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),
                                },
                        );
index cbb607c195508f34f4024875de9ee4e90b92025f..c8590419eb64a9472943282de05e16d7ab3bed8e 100644 (file)
@@ -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);
 };
index b1cf48049b4942e7e2c70024f699c67f64cf24e7..8b3068a8b30112be6782740f54314f4408b00edc 100644 (file)
@@ -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}}</1>',
index b52fbab40e5744bb148c31816cdc8be53626e1e4..8b978e61b5ec965c12c6ed51c8d049aee41423ae 100644 (file)
@@ -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}}</1>',
index a3c4170b1be3dbd86e2d69625cb290703935331e..1f0584c52808e0a6638e0677f4a9ae91d873de2f 100644 (file)
@@ -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) => {
index d76df893122a52ac693714097a0787721c197ee1..80ea5b99e4dd030cf53cc8e4e4fe4a5349b8dd24 100644 (file)
                margin-left: auto;
                text-align: right;
        }
-       .seed-code {
-               margin-right: 1ex;
-       }
-       .result-badge {
-               margin-left: 1ex;
-       }
 }
index 5eb9e135e05451c508e2af0570f22c0226a466b0..88f19bffb11d22653234cf9a96dd59f653064173 100644 (file)
@@ -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');