namespace App\Http\Controllers;
use App\Events\ApplicationAdded;
+use App\Events\ParticipantChanged;
use App\Events\TournamentChanged;
use App\Models\Application;
use App\Models\GroupAssignment;
'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))
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);
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();
+ }
+
}
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,
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.
*
return <>
<Button
onClick={() => setShowDialog(true)}
+ size="sm"
title={t('tournaments.applications')}
variant="primary"
>
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 '';
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(
const getEntryIcon = entry => {
switch (entry.type) {
+ case 'crew.add':
+ case 'crew.remove':
+ return <Icon.CREW />;
case 'result.report':
return <Icon.RESULT />;
case 'result.verify':
<>
<Button
onClick={() => setShowDialog(true)}
+ size="sm"
title={t('button.protocol')}
variant="outline-info"
>
<Button
disabled={!mayApply(user, tournament)}
onClick={() => apply(tournament)}
+ size="sm"
variant="primary"
>
<Icon.APPLY title="" />
--- /dev/null
+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;
--- /dev/null
+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;
<Button
className="ms-3"
onClick={() => actions.editContent(tournament.description)}
+ size="sm"
title={t('button.edit')}
variant="outline-secondary"
>
{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}
actions: PropTypes.shape({
addRound: PropTypes.func,
editContent: PropTypes.func,
+ manageCrew: PropTypes.func,
moreRounds: PropTypes.func,
selfAssignGroups: PropTypes.func,
}).isRequired,
<Button
disabled={processing}
onClick={handleXlsx}
+ size="sm"
title={t('button.exportExcel')}
variant="outline-secondary"
>
<Button
disabled={processing}
onClick={handleJson}
+ size="sm"
title={t('button.exportJs')}
variant="outline-secondary"
>
return <>
<Button
onClick={() => setShowDialog(true)}
+ size="sm"
title={i18n.t('button.settings')}
variant="outline-secondary"
>
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);
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',
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',
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}}',
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',
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 {
+ mayAdminTournament,
mayEditContent,
mayModifyResults,
mayUnverifyResults,
const [editContent, setEditContent] = React.useState(null);
const [showContentDialog, setShowContentDialog] = React.useState(false);
+ const [showCrewDialog, setShowCrewDialog] = React.useState(false);
useEffect(() => {
const ctrl = new AbortController();
}));
}, [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}`, {
setEditContent(content);
setShowContentDialog(true);
} : null,
+ manageCrew: mayAdminTournament(user, event) ? () => {
+ setShowCrewDialog(true);
+ } : null,
modifyResult: mayModifyResults(user, tournament) ? modifyResult : null,
moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
selfAssignGroups,
actions={actions}
tournament={tournament}
/>
+ <CrewDialog
+ onHide={() => { setShowCrewDialog(false); }}
+ show={showCrewDialog}
+ toggleRole={toggleRole}
+ tournament={tournament}
+ />
<Dialog
content={editContent}
language={i18n.language}
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');