]> git.localhorst.tv Git - alttp.git/commitdiff
tournament crew management master
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 3 Feb 2026 17:49:55 +0000 (18:49 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 3 Feb 2026 17:49:55 +0000 (18:49 +0100)
17 files changed:
app/Http/Controllers/TournamentController.php
app/Models/Protocol.php
app/Policies/TournamentPolicy.php
resources/js/components/applications/Button.jsx
resources/js/components/protocol/Item.jsx
resources/js/components/protocol/Protocol.jsx
resources/js/components/tournament/ApplyButton.jsx
resources/js/components/tournament/Crew.jsx [new file with mode: 0644]
resources/js/components/tournament/CrewDialog.jsx [new file with mode: 0644]
resources/js/components/tournament/Detail.jsx
resources/js/components/tournament/ExportButton.jsx
resources/js/components/tournament/SettingsButton.jsx
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tournament.jsx
routes/api.php

index f4cacdd5848729f58f3ec605866407a3522cb1ab..2274045690227a960d2781cbf535f7c08c414874 100644 (file)
@@ -3,6 +3,7 @@
 namespace App\Http\Controllers;
 
 use App\Events\ApplicationAdded;
 namespace App\Http\Controllers;
 
 use App\Events\ApplicationAdded;
+use App\Events\ParticipantChanged;
 use App\Events\TournamentChanged;
 use App\Models\Application;
 use App\Models\GroupAssignment;
 use App\Events\TournamentChanged;
 use App\Models\Application;
 use App\Models\GroupAssignment;
@@ -33,17 +34,7 @@ class TournamentController extends Controller
                        'applications.user',
                        'description',
                ]);
                        'applications.user',
                        'description',
                ]);
-               $participants = $tournament->participants()
-                       ->where(function ($query) use ($request, $tournament) {
-                               $query->where('placement', '<=', $tournament->limit_scoreboard);
-                               $query->orWhereJsonContains('roles', 'admin');
-                               $query->orWhereJsonContains('roles', 'monitor');
-                               if ($request->user()) {
-                                       $query->orWhere('user_id', '=', $request->user()->id);
-                               }
-                       })
-                       ->with(['user'])
-                       ->get();
+               $participants = $this->getParticipantsForSingle($request, $tournament);
                $rounds = $tournament->rounds()
                        ->with(['results', 'results.user', 'results.verified_by'])
                        ->limit($tournament->ceilRoundLimit(25))
                $rounds = $tournament->rounds()
                        ->with(['results', 'results.user', 'results.verified_by'])
                        ->limit($tournament->ceilRoundLimit(25))
@@ -102,6 +93,38 @@ class TournamentController extends Controller
                return $tournament->toArray();
        }
 
                return $tournament->toArray();
        }
 
+       public function manageCrew(Request $request, Tournament $tournament) {
+               $this->authorize('manageCrew', $tournament);
+
+               $validatedData = $request->validate([
+                       'toggle_role' => 'string|in:admin,monitor,member',
+                       'user_id' => 'numeric|exists:App\Models\User,id',
+               ]);
+
+               $participant = $tournament->participants()->where('user_id', '=', $validatedData['user_id'])->firstOrNew();
+               $roles = $participant->roles;
+               $key = array_search($validatedData['toggle_role'], $participant->roles);
+               if ($key === false) {
+                       $roles[] = $validatedData['toggle_role'];
+               } else {
+                       unset($roles[$key]);
+               }
+               $participant->roles = array_values($roles);
+               $participant->save();
+
+               if ($key === false) {
+                       Protocol::crewRoleAdded($tournament, $participant->user, $validatedData['toggle_role'], $request->user());
+               } else {
+                       Protocol::crewRoleRemoved($tournament, $participant->user, $validatedData['toggle_role'], $request->user());
+               }
+               ParticipantChanged::dispatch($participant);
+
+               $participants = $this->getParticipantsForSingle($request, $tournament);
+               $json = $tournament->toArray();
+               $json['participants'] = $participants->toArray();
+               return $json;
+       }
+
        public function moreRounds(Request $request, Tournament $tournament) {
                $this->authorize('view', $tournament);
 
        public function moreRounds(Request $request, Tournament $tournament) {
                $this->authorize('view', $tournament);
 
@@ -282,4 +305,18 @@ class TournamentController extends Controller
                return $view;
        }
 
                return $view;
        }
 
+       private function getParticipantsForSingle(Request $request, Tournament $tournament) {
+               return $tournament->participants()
+                       ->where(function ($query) use ($request, $tournament) {
+                               $query->where('placement', '<=', $tournament->limit_scoreboard);
+                               $query->orWhereJsonContains('roles', 'admin');
+                               $query->orWhereJsonContains('roles', 'monitor');
+                               if ($request->user()) {
+                                       $query->orWhere('user_id', '=', $request->user()->id);
+                               }
+                       })
+                       ->with(['user'])
+                       ->get();
+       }
+
 }
 }
index 3ab422f0eb26719d1685dd7b927c7364b41cdd7e..a8bfb0d6bda4b0641ca7ba3135a00026f3391893 100644 (file)
@@ -52,6 +52,34 @@ class Protocol extends Model
                ProtocolAdded::dispatch($protocol);
        }
 
                ProtocolAdded::dispatch($protocol);
        }
 
+       public static function crewRoleAdded(Tournament $tournament, User $crew, $role, User $user) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user->id,
+                       'type' => 'crew.add',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                               'role' => $role,
+                               'user' => static::userMemo($crew),
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
+       public static function crewRoleRemoved(Tournament $tournament, User $crew, $role, User $user) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user->id,
+                       'type' => 'crew.remove',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                               'role' => $role,
+                               'user' => static::userMemo($crew),
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
        public static function groupAssignment(Tournament $tournament, User $assignee, $picks, User $user) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
        public static function groupAssignment(Tournament $tournament, User $assignee, $picks, User $user) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
index 22854d52cff52d228ef36ea17d085e1fba58bf54..a3770ca2956e42f365db7b42f8ecdaf354305a66 100644 (file)
@@ -116,6 +116,18 @@ class TournamentPolicy
                return $tournament->accept_applications && !$user->isRunner($tournament) && !$user->isApplicant($tournament);
        }
 
                return $tournament->accept_applications && !$user->isRunner($tournament) && !$user->isApplicant($tournament);
        }
 
+       /**
+        * Determine whether the user can manage the crew for the tournament.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Tournament  $tournament
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function manageCrew(User $user, Tournament $tournament)
+       {
+               return $user->isAdmin() || $user->isTournamentAdmin($tournament);
+       }
+
        /**
         * Determine whether the user can view the tournament protocol.
         *
        /**
         * Determine whether the user can view the tournament protocol.
         *
index 2361b09107b00a081aa1b5484e9d9d41866d03af..fa7251e78046d2dfb16f38e69b1523a4f0ad08ca 100644 (file)
@@ -24,6 +24,7 @@ const ApplicationsButton = ({ tournament }) => {
        return <>
                <Button
                        onClick={() => setShowDialog(true)}
        return <>
                <Button
                        onClick={() => setShowDialog(true)}
+                       size="sm"
                        title={t('tournaments.applications')}
                        variant="primary"
                >
                        title={t('tournaments.applications')}
                        variant="primary"
                >
index ebd874d7566951de93bb3d3cb1693448eb1a2f35..ab25281214e89225fa91310666c213eff8c8b7b0 100644 (file)
@@ -44,6 +44,13 @@ const getEntryDetailsPicks = entry => {
        return entry.details.picks.map(p => `${p.number}${p.group}`).join(', ');
 }
 
        return entry.details.picks.map(p => `${p.number}${p.group}`).join(', ');
 }
 
+const getEntryDetailsRole = (entry, t) => {
+       if (!entry?.details?.role) {
+               return '';
+       }
+       return t(`participants.roleNames.${entry.details.role}`);
+}
+
 const getEntryResultRunner = entry => {
        if (!entry || !entry.details || !entry.details.runner) {
                return '';
 const getEntryResultRunner = entry => {
        if (!entry || !entry.details || !entry.details.runner) {
                return '';
@@ -71,6 +78,16 @@ const getEntryDescription = (entry, t) => {
                                        username: getEntryDetailsUsername(entry),
                                },
                        );
                                        username: getEntryDetailsUsername(entry),
                                },
                        );
+               case 'crew.add':
+               case 'crew.remove':
+                       return t(
+                               `protocol.description.${entry.type}`,
+                               {
+                                       ...entry,
+                                       role: getEntryDetailsRole(entry, t),
+                                       username: getEntryDetailsUsername(entry),
+                               },
+                       );
                case 'group.assign':
                case 'group.swap':
                        return t(
                case 'group.assign':
                case 'group.swap':
                        return t(
@@ -141,6 +158,9 @@ const getEntryDescription = (entry, t) => {
 
 const getEntryIcon = entry => {
        switch (entry.type) {
 
 const getEntryIcon = entry => {
        switch (entry.type) {
+               case 'crew.add':
+               case 'crew.remove':
+                       return <Icon.CREW />;
                case 'result.report':
                        return <Icon.RESULT />;
                case 'result.verify':
                case 'result.report':
                        return <Icon.RESULT />;
                case 'result.verify':
index 4b832c76a3232b60a4b653f3e7243ab2280411ad..5ce1a64a7a3e95b0a5de354e67df0a30bbe3a603 100644 (file)
@@ -41,6 +41,7 @@ const Protocol = ({ id }) => {
                <>
                        <Button
                                onClick={() => setShowDialog(true)}
                <>
                        <Button
                                onClick={() => setShowDialog(true)}
+                               size="sm"
                                title={t('button.protocol')}
                                variant="outline-info"
                        >
                                title={t('button.protocol')}
                                variant="outline-info"
                        >
index 8bbd98ec12556daf57572178e50781c615896a84..5467ed05e56c1970791e634d76e46ba3ebfddfd0 100644 (file)
@@ -38,6 +38,7 @@ const ApplyButton = ({ tournament }) => {
                <Button
                        disabled={!mayApply(user, tournament)}
                        onClick={() => apply(tournament)}
                <Button
                        disabled={!mayApply(user, tournament)}
                        onClick={() => apply(tournament)}
+                       size="sm"
                        variant="primary"
                >
                        <Icon.APPLY title="" />
                        variant="primary"
                >
                        <Icon.APPLY title="" />
diff --git a/resources/js/components/tournament/Crew.jsx b/resources/js/components/tournament/Crew.jsx
new file mode 100644 (file)
index 0000000..3841142
--- /dev/null
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserSelect from '../common/UserSelect';
+import UserBox from '../users/Box';
+import { compareUsername } from '../../helpers/User';
+
+const sortCrew = (crew) => {
+       const sorted = [...crew];
+       sorted.sort((a, b) => compareUsername(a.user, b.user));
+       return sorted;
+};
+
+const Crew = ({ toggleRole, tournament }) => {
+       const { t } = useTranslation();
+
+       const [newUser, setNewUser] = React.useState('');
+
+       const crews = React.useMemo(() => {
+               const participants = (tournament?.participants || []).filter((p) => p.roles.includes('admin') || p.roles.includes('monitor'));
+               sortCrew(participants);
+               return participants;
+       }, [tournament]);
+
+       return <div>
+               {crews.map((crew) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={crew.id}>
+                               <UserBox user={crew.user} />
+                               <div className="button-bar d-flex align-items-center">
+                                       {['admin', 'monitor'].map((role =>
+                                               <Button
+                                                       key={role}
+                                                       onClick={() => { toggleRole(crew.user_id, role); }}
+                                                       variant={crew.roles.includes(role) ? 'primary' : 'outline-secondary'}
+                                               >
+                                                       {t(`participants.roleNames.${role}`)}
+                                               </Button>
+                                       ))}
+                               </div>
+                       </div>
+               ))}
+               <Form.Group controlId="crew.addCrew">
+                       <Form.Label>{t('events.addCrew')}</Form.Label>
+                               <div className="d-flex align-items-center justify-content-between my-2">
+                               <Form.Control
+                                       as={UserSelect}
+                                       excludeIds={crews.map(c => c.user_id)}
+                                       onChange={(e) => setNewUser(e.target.value)}
+                                       value={newUser}
+                               />
+                               <div className="button-bar d-flex align-items-center">
+                                       {['admin', 'monitor'].map((role =>
+                                               <Button
+                                                       key={role}
+                                                       onClick={() => { toggleRole(newUser, role); setNewUser(''); }}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t(`participants.roleNames.${role}`)}
+                                               </Button>
+                                       ))}
+                               </div>
+                       </div>
+               </Form.Group>
+       </div>;
+};
+
+Crew.propTypes = {
+       toggleRole: PropTypes.func,
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default Crew;
diff --git a/resources/js/components/tournament/CrewDialog.jsx b/resources/js/components/tournament/CrewDialog.jsx
new file mode 100644 (file)
index 0000000..9c07fd4
--- /dev/null
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Crew from './Crew';
+import Loading from '../common/Loading';
+
+const CrewDialog = ({
+       onHide,
+       show,
+       toggleRole,
+       tournament,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal onHide={onHide} show={show} size="md">
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('tournaments.manageCrew')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <React.Suspense fallback={<Loading />}>
+                               <Crew
+                                       tournament={tournament}
+                                       toggleRole={toggleRole}
+                               />
+                       </React.Suspense>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.close')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+CrewDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       toggleRole: PropTypes.func,
+       tournament: PropTypes.shape({
+               id: PropTypes.number,
+       }),
+};
+
+export default CrewDialog;
index e90ab99db9c7b7eaa4c473f6b828d94ad7a4ca94..b764755f907f0e14959d7c4506e30d0dc0e2654d 100644 (file)
@@ -85,6 +85,7 @@ const Detail = ({
                                                        <Button
                                                                className="ms-3"
                                                                onClick={() => actions.editContent(tournament.description)}
                                                        <Button
                                                                className="ms-3"
                                                                onClick={() => actions.editContent(tournament.description)}
+                                                               size="sm"
                                                                title={t('button.edit')}
                                                                variant="outline-secondary"
                                                        >
                                                                title={t('button.edit')}
                                                                variant="outline-secondary"
                                                        >
@@ -96,6 +97,16 @@ const Detail = ({
                                                {mayUpdateTournament(user, tournament) ?
                                                        <SettingsButton tournament={tournament} />
                                                : null}
                                                {mayUpdateTournament(user, tournament) ?
                                                        <SettingsButton tournament={tournament} />
                                                : null}
+                                               {actions.manageCrew ?
+                                                       <Button
+                                                               onClick={() => actions.manageCrew(event)}
+                                                               size="sm"
+                                                               title={t('button.crew')}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.CREW title="" />
+                                                       </Button>
+                                               : null}
                                                {mayExportTournament(user, tournament) ?
                                                        <ExportButton tournament={tournament} />
                                                : null}
                                                {mayExportTournament(user, tournament) ?
                                                        <ExportButton tournament={tournament} />
                                                : null}
@@ -185,6 +196,7 @@ Detail.propTypes = {
        actions: PropTypes.shape({
                addRound: PropTypes.func,
                editContent: PropTypes.func,
        actions: PropTypes.shape({
                addRound: PropTypes.func,
                editContent: PropTypes.func,
+               manageCrew: PropTypes.func,
                moreRounds: PropTypes.func,
                selfAssignGroups: PropTypes.func,
        }).isRequired,
                moreRounds: PropTypes.func,
                selfAssignGroups: PropTypes.func,
        }).isRequired,
index b0c796f93bad1e9b17d6422c1fc36bdcc836d391..ce0cf5eeb0fa39803f552bbdfc1ee358654115f3 100644 (file)
@@ -36,6 +36,7 @@ const ExportButton = ({ tournament }) => {
                <Button
                        disabled={processing}
                        onClick={handleXlsx}
                <Button
                        disabled={processing}
                        onClick={handleXlsx}
+                       size="sm"
                        title={t('button.exportExcel')}
                        variant="outline-secondary"
                >
                        title={t('button.exportExcel')}
                        variant="outline-secondary"
                >
@@ -48,6 +49,7 @@ const ExportButton = ({ tournament }) => {
                <Button
                        disabled={processing}
                        onClick={handleJson}
                <Button
                        disabled={processing}
                        onClick={handleJson}
+                       size="sm"
                        title={t('button.exportJs')}
                        variant="outline-secondary"
                >
                        title={t('button.exportJs')}
                        variant="outline-secondary"
                >
index 2ff1abd8b476d7aa5344e06a15e4d15c7fdde4b1..e1578a74d595cb68428efe5b8c7a3af1ad14544c 100644 (file)
@@ -13,6 +13,7 @@ const SettingsButton = ({ tournament }) => {
        return <>
                <Button
                        onClick={() => setShowDialog(true)}
        return <>
                <Button
                        onClick={() => setShowDialog(true)}
+                       size="sm"
                        title={i18n.t('button.settings')}
                        variant="outline-secondary"
                >
                        title={i18n.t('button.settings')}
                        variant="outline-secondary"
                >
index 2ec88cea74f8fabb623eb86bf15bbf26d53f1bd5..82cc0134478b3e2482aec36f11c1c48974d8c8a7 100644 (file)
@@ -155,6 +155,9 @@ export const hasFinished = (user, round) =>
 export const mayAddRounds = (user, tournament) =>
        !tournament.locked && isTournamentAdmin(user, tournament);
 
 export const mayAddRounds = (user, tournament) =>
        !tournament.locked && isTournamentAdmin(user, tournament);
 
+export const mayAdminTournament = (user, tournament) =>
+       isAdmin(user) || isTournamentAdmin(user, tournament);
+
 export const mayApply = (user, tournament) =>
        user && tournament && tournament.accept_applications &&
                !isRunner(user, tournament) && !isApplicant(user, tournament);
 export const mayApply = (user, tournament) =>
        user && tournament && tournament.accept_applications &&
                !isRunner(user, tournament) && !isApplicant(user, tournament);
index 53f12f484e6e563affde708c72b2a9028d644382..3b17c51d81dafae0988fd0300d8f4e038cab55fa 100644 (file)
@@ -606,6 +606,10 @@ export default {
                                        received: 'Anmeldung von {{username}} erhalten',
                                        rejected: 'Anmeldung von {{username}} abgelehnt',
                                },
                                        received: 'Anmeldung von {{username}} erhalten',
                                        rejected: 'Anmeldung von {{username}} abgelehnt',
                                },
+                               crew: {
+                                       add: '{{username}} als {{role}} hinzugefügt',
+                                       remove: '{{username}} als {{role}} entfernt',
+                               },
                                group: {
                                        assign: 'Gruppen {{picks}} für {{assignee}} zugewiesen',
                                        assign_one: 'Gruppe {{picks}} für {{assignee}} zugewiesen',
                                group: {
                                        assign: 'Gruppen {{picks}} für {{assignee}} zugewiesen',
                                        assign_one: 'Gruppe {{picks}} für {{assignee}} zugewiesen',
@@ -958,6 +962,8 @@ export default {
                        locked: 'Turnier sperren',
                        lockError: 'Fehler beim Sperren',
                        lockSuccess: 'Turnier gesperrt',
                        locked: 'Turnier sperren',
                        lockError: 'Fehler beim Sperren',
                        lockSuccess: 'Turnier gesperrt',
+                       manageCrew: 'Crew verwalten',
+                       manageCrewError: 'Fehler beim Speichern',
                        monitors: 'Monitore',
                        noApplications: 'Derzeit keine Anmeldungen',
                        noRecord: 'Turnier wird nicht gewertet',
                        monitors: 'Monitore',
                        noApplications: 'Derzeit keine Anmeldungen',
                        noRecord: 'Turnier wird nicht gewertet',
index 0741973b764617058ee51abfbe6fc4ef6eaa4530..256aea147407ce1d3b174c22f115f1cc50798266 100644 (file)
@@ -606,6 +606,10 @@ export default {
                                        received: 'Application from {{username}} received',
                                        rejected: 'Application from {{username}} rejected',
                                },
                                        received: 'Application from {{username}} received',
                                        rejected: 'Application from {{username}} rejected',
                                },
+                               crew: {
+                                       add: 'Added {{username}} as {{role}}',
+                                       remove: 'Removed {{username}} as {{role}}',
+                               },
                                group: {
                                        assign: 'Assigned groups {{picks}} for {{assignee}}',
                                        assign_one: 'Assigned group {{picks}} for {{assignee}}',
                                group: {
                                        assign: 'Assigned groups {{picks}} for {{assignee}}',
                                        assign_one: 'Assigned group {{picks}} for {{assignee}}',
@@ -959,6 +963,8 @@ export default {
                        locked: 'Lock rounds',
                        lockError: 'Error locking tournament',
                        lockSuccess: 'Tournament locked',
                        locked: 'Lock rounds',
                        lockError: 'Error locking tournament',
                        lockSuccess: 'Tournament locked',
+                       manageCrew: 'Manage crew',
+                       manageCrewError: 'Error saving',
                        monitors: 'Monitors',
                        noApplications: 'No applications at this point',
                        noRecord: 'Tournament set to not be recorded',
                        monitors: 'Monitors',
                        noApplications: 'No applications at this point',
                        noRecord: 'Tournament set to not be recorded',
index 87c7a488fa09aedb931dcf6e7f32c60b8e4ce84a..9a25350c88ca3e24d66cb375e3bebe0bdf0708b7 100644 (file)
@@ -11,8 +11,10 @@ import ErrorBoundary from '../components/common/ErrorBoundary';
 import ErrorMessage from '../components/common/ErrorMessage';
 import Loading from '../components/common/Loading';
 import Dialog from '../components/techniques/Dialog';
 import ErrorMessage from '../components/common/ErrorMessage';
 import Loading from '../components/common/Loading';
 import Dialog from '../components/techniques/Dialog';
+import CrewDialog from '../components/tournament/CrewDialog';
 import Detail from '../components/tournament/Detail';
 import {
 import Detail from '../components/tournament/Detail';
 import {
+       mayAdminTournament,
        mayEditContent,
        mayModifyResults,
        mayUnverifyResults,
        mayEditContent,
        mayModifyResults,
        mayUnverifyResults,
@@ -45,6 +47,7 @@ export const Component = () => {
 
        const [editContent, setEditContent] = React.useState(null);
        const [showContentDialog, setShowContentDialog] = React.useState(false);
 
        const [editContent, setEditContent] = React.useState(null);
        const [showContentDialog, setShowContentDialog] = React.useState(false);
+       const [showCrewDialog, setShowCrewDialog] = React.useState(false);
 
        useEffect(() => {
                const ctrl = new AbortController();
 
        useEffect(() => {
                const ctrl = new AbortController();
@@ -152,6 +155,18 @@ export const Component = () => {
                }));
        }, [id, tournament]);
 
                }));
        }, [id, tournament]);
 
+       const toggleRole = React.useCallback(async (user_id, role) => {
+               try {
+                       const response = await axios.post(`/api/tournaments/${tournament.id}/crew`, {
+                               toggle_role: role,
+                               user_id: user_id,
+                       });
+                       setTournament(t => ({ ...t, participants: response.data.participants }));
+               } catch (error) {
+                       toastr.error(t('tournaments.manageCrewError', { error }));
+               }
+       }, [t, tournament?.id]);
+
        const saveContent = React.useCallback(async values => {
                try {
                        const response = await axios.put(`/api/content/${values.id}`, {
        const saveContent = React.useCallback(async values => {
                try {
                        const response = await axios.put(`/api/content/${values.id}`, {
@@ -232,6 +247,9 @@ export const Component = () => {
                        setEditContent(content);
                        setShowContentDialog(true);
                } : null,
                        setEditContent(content);
                        setShowContentDialog(true);
                } : null,
+               manageCrew: mayAdminTournament(user, event) ? () => {
+                       setShowCrewDialog(true);
+               } : null,
                modifyResult: mayModifyResults(user, tournament) ? modifyResult : null,
                moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
                selfAssignGroups,
                modifyResult: mayModifyResults(user, tournament) ? modifyResult : null,
                moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
                selfAssignGroups,
@@ -309,6 +327,12 @@ export const Component = () => {
                        actions={actions}
                        tournament={tournament}
                />
                        actions={actions}
                        tournament={tournament}
                />
+               <CrewDialog
+                       onHide={() => { setShowCrewDialog(false); }}
+                       show={showCrewDialog}
+                       toggleRole={toggleRole}
+                       tournament={tournament}
+               />
                <Dialog
                        content={editContent}
                        language={i18n.language}
                <Dialog
                        content={editContent}
                        language={i18n.language}
index 1cdb80b56ff29ff3df4939f22bc0f6ffe2f172b0..4d3d35d62140dcf46604a25de377b8d1d9bc836a 100644 (file)
@@ -112,6 +112,7 @@ Route::get('tournaments/{tournament}', 'App\Http\Controllers\TournamentControlle
 Route::get('tournaments/{tournament}/more-rounds', 'App\Http\Controllers\TournamentController@moreRounds');
 Route::post('tournaments/{tournament}/apply', 'App\Http\Controllers\TournamentController@apply');
 Route::post('tournaments/{tournament}/close', 'App\Http\Controllers\TournamentController@close');
 Route::get('tournaments/{tournament}/more-rounds', 'App\Http\Controllers\TournamentController@moreRounds');
 Route::post('tournaments/{tournament}/apply', 'App\Http\Controllers\TournamentController@apply');
 Route::post('tournaments/{tournament}/close', 'App\Http\Controllers\TournamentController@close');
+Route::post('tournaments/{tournament}/crew', 'App\Http\Controllers\TournamentController@manageCrew');
 Route::post('tournaments/{tournament}/discord', 'App\Http\Controllers\TournamentController@discord');
 Route::post('tournaments/{tournament}/discord-settings', 'App\Http\Controllers\TournamentController@discordSettings');
 Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock');
 Route::post('tournaments/{tournament}/discord', 'App\Http\Controllers\TournamentController@discord');
 Route::post('tournaments/{tournament}/discord-settings', 'App\Http\Controllers\TournamentController@discordSettings');
 Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock');