return $round->toJson();
}
+ public function lock(Request $request, Round $round) {
+ $this->authorize('lock', $round);
+
+ $round->locked = true;
+ $round->update();
+
+ Protocol::roundLocked(
+ $round->tournament,
+ $round,
+ $request->user(),
+ );
+
+ RoundChanged::dispatch($round);
+
+ $round->load('results');
+
+ return $round->toJson();
+ }
+
+ public function unlock(Request $request, Round $round) {
+ $this->authorize('unlock', $round);
+
+ $round->locked = false;
+ $round->update();
+
+ Protocol::roundUnlocked(
+ $round->tournament,
+ $round,
+ $request->user(),
+ );
+
+ RoundChanged::dispatch($round);
+
+ $round->load('results');
+
+ return $round->toJson();
+ }
+
}
ProtocolAdded::dispatch($protocol);
}
+ public static function roundUnlocked(Tournament $tournament, Round $round, User $user = null) {
+ $protocol = static::create([
+ 'tournament_id' => $tournament->id,
+ 'user_id' => $user ? $user->id : null,
+ 'type' => 'round.unlock',
+ 'details' => [
+ 'tournament' => static::tournamentMemo($tournament),
+ 'round' => static::roundMemo($round),
+ ],
+ ]);
+ ProtocolAdded::dispatch($protocol);
+ }
+
public static function tournamentCreated(Tournament $tournament, User $user) {
$protocol = static::create([
'tournament_id' => $tournament->id,
protected static function roundMemo(Round $round) {
return [
'id' => $round->id,
+ 'number' => $round->number,
'seed' => $round->seed,
];
}
{
return $user->role === 'admin' || ($user->isParticipant($round->tournament) && !$round->locked);
}
+
+ /**
+ * Determine whether the user can lock this round.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Round $round
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function lock(User $user, Round $round)
+ {
+ return $user->role === 'admin' || $user->isTournamentAdmin($round->tournament);
+ }
+
+ /**
+ * Determine whether the user can unlock this round.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Round $round
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function unlock(User $user, Round $round)
+ {
+ return $this->lock($user, $round);
+ }
+
}
Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
+Icon.LOCKED = makePreset('LockedIcon', 'lock');
Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
Icon.PENDING = makePreset('PendingIcon', 'clock');
Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal');
Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']);
Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award');
+Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open');
export default Icon;
: dateStr;
};
+const getEntryRoundNumber = entry =>
+ (entry && entry.details && entry.details.round && entry.details.round.number) || '?';
+
const getEntryResultTime = entry => {
if (!entry || !entry.details || !entry.details.result) return 'ERROR';
const result = entry.details.result;
}
case 'round.create':
case 'round.lock':
+ case 'round.unlock':
+ return i18n.t(
+ `protocol.description.${entry.type}`,
+ {
+ ...entry,
+ number: getEntryRoundNumber(entry),
+ },
+ );
case 'tournament.lock':
return i18n.t(
`protocol.description.${entry.type}`,
import React from 'react';
import { withTranslation } from 'react-i18next';
+import LockButton from './LockButton';
import SeedButton from './SeedButton';
import SeedCode from './SeedCode';
import List from '../results/List';
/>
</p>
: null}
+ <LockButton round={round} tournament={tournament} />
</div>
<List round={round} tournament={tournament} />
</li>;
--- /dev/null
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import LockDialog from './LockDialog';
+import Icon from '../common/Icon';
+import { mayLockRound } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const LockButton = ({
+ round,
+ tournament,
+ user,
+}) => {
+ const [showDialog, setShowDialog] = useState(false);
+
+ if (!mayLockRound(user, tournament, round)) {
+ if (round.locked) {
+ return <Icon.LOCKED title={i18n.t('rounds.locked')} />;
+ } else {
+ return <Icon.UNLOCKED title={i18n.t('rounds.unlocked')} />;
+ }
+ }
+
+ return <>
+ <LockDialog
+ onHide={() => setShowDialog(false)}
+ round={round}
+ show={showDialog}
+ />
+ <Button
+ onClick={() => setShowDialog(true)}
+ size="sm"
+ title={round.locked ? i18n.t('rounds.locked') : i18n.t('rounds.unlocked') }
+ variant="outline-secondary"
+ >
+ {round.locked ?
+ <Icon.LOCKED title="" />
+ :
+ <Icon.UNLOCKED title="" />
+ }
+ </Button>
+ </>;
+};
+
+LockButton.propTypes = {
+ round: PropTypes.shape({
+ locked: PropTypes.bool,
+ }),
+ tournament: PropTypes.shape({
+ }),
+ user: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(withUser(LockButton));
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import { isComplete } from '../../helpers/Round';
+import i18n from '../../i18n';
+
+const LockDialog = ({
+ onHide,
+ round,
+ show,
+ tournament,
+}) =>
+<Modal className="lock-dialog" onHide={onHide} show={show}>
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <p>{i18n.t(round.locked
+ ? 'rounds.unlockDescription'
+ : 'rounds.lockDescription')}
+ </p>
+ {!round.locked && !isComplete(tournament, round) ?
+ <Alert variant="warning">
+ {i18n.t('rounds.lockIncompleteWarning')}
+ </Alert>
+ : null}
+ </Modal.Body>
+ <Modal.Footer>
+ {onHide ?
+ <Button onClick={onHide} variant="secondary">
+ {i18n.t('button.cancel')}
+ </Button>
+ : null}
+ <Button
+ onClick={async () => {
+ if (round.locked) {
+ try {
+ await axios.post(`/api/rounds/${round.id}/unlock`);
+ toastr.success(i18n.t('rounds.unlockSuccess'));
+ onHide();
+ } catch (e) {
+ toastr.error(i18n.t('rounds.unlockError'));
+ }
+ } else {
+ try {
+ await axios.post(`/api/rounds/${round.id}/lock`);
+ toastr.success(i18n.t('rounds.lockSuccess'));
+ onHide();
+ } catch (e) {
+ toastr.error(i18n.t('rounds.lockError'));
+ }
+ }
+ }}
+ variant="primary"
+ >
+ {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+ </Button>
+ </Modal.Footer>
+</Modal>;
+
+LockDialog.propTypes = {
+ onHide: PropTypes.func,
+ round: PropTypes.shape({
+ id: PropTypes.number,
+ locked: PropTypes.bool,
+ }),
+ show: PropTypes.bool,
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(LockDialog);
SeedDialog.propTypes = {
onHide: PropTypes.func,
+ participant: PropTypes.shape({
+ }),
round: PropTypes.shape({
}),
show: PropTypes.bool,
mayViewProtocol,
} from '../../helpers/permissions';
import {
- getRunners,
getTournamentAdmins,
hasRunners,
hasTournamentAdmins,
</Col>
</Row>
<Row>
- <Col lg={8} xl={9}>
- <div className="d-flex align-items-center justify-content-between">
- <h2>{i18n.t('rounds.heading')}</h2>
- {addRound && mayAddRounds(user, tournament) ?
- <Button onClick={addRound}>
- {i18n.t('rounds.new')}
- </Button>
- : null}
- </div>
- {tournament.rounds ?
- <Rounds rounds={tournament.rounds} tournament={tournament} />
- : null}
- </Col>
- <Col lg={4} xl={3}>
+ <Col lg={{ order: 2, span: 4 }} xl={{ order: 2, span: 3 }}>
<div className="d-flex align-items-center justify-content-between">
<h2>{i18n.t('tournaments.scoreboard')}</h2>
</div>
</>
: null}
</Col>
+ <Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
+ <div className="d-flex align-items-center justify-content-between">
+ <h2>{i18n.t('rounds.heading')}</h2>
+ {addRound && mayAddRounds(user, tournament) ?
+ <Button onClick={addRound}>
+ {i18n.t('rounds.new')}
+ </Button>
+ : null}
+ </div>
+ {tournament.rounds ?
+ <Rounds rounds={tournament.rounds} tournament={tournament} />
+ : null}
+ </Col>
</Row>
</Container>;
+import Participant from './Participant';
+import Tournament from './Tournament';
+
export const isComplete = (tournament, round) => {
if (!tournament || !tournament.participants) return false;
if (!round || !round.results) return false;
- return tournament.participants.length === round.results.length &&
- round.results.filter(r => !r.has_finished).length === 0;
+ const runners = Tournament.getRunners(tournament);
+ for (let i = 0; i < runners.length; ++i) {
+ const result = Participant.findResult(runners[i], round);
+ if (!result || !result.has_finished) return false;
+ }
+ return true;
};
export const patchResult = (round, result) => {
const b_score = b && b.score ? b.score : 0;
if (a_score < b_score) return -1;
if (b_score < a_score) return 1;
- return Participant.compareUsername(a.participant, b.participant);
+ return Participant.compareUsername(a.participant, b.participant) * -1;
};
export const findParticipant = (tournament, user) => {
calculateScores,
compareScore,
findParticipant,
+ getRunners,
+ getTournamentAdmins,
patchResult,
patchRound,
patchUser,
export const mayAddRounds = (user, tournament) =>
isAdmin(user) || (!tournament.locked && isParticipant(user, tournament));
+export const mayLockRound = (user, tournament) =>
+ isAdmin(user) || (!tournament.locked && isTournamentAdmin(user, tournament));
+
export const maySetSeed = (user, tournament) =>
isAdmin(user) || isParticipant(user, tournament);
FinishedIcon: 'Abgeschlossen',
FirstPlaceIcon: 'Erster Platz',
ForfeitIcon: 'Aufgegeben',
+ LockedIcon: 'Gesperrt',
LogoutIcon: 'Logout',
PendingIcon: 'Ausstehend',
SecondPlaceIcon: 'Zweiter Platz',
- ThirdPlaceIcon: 'Dritter Platz',
StreamIcon: 'Stream',
+ ThirdPlaceIcon: 'Dritter Platz',
+ UnlockedIcon: 'Offen',
zelda: {
'big-key': 'Big Key',
'blue-boomerang': 'Boomerang',
report: 'Ergebnis von <0>{{time}}</0> eingetragen',
},
round: {
- create: 'Runde hinzugefügt',
- lock: 'Runde festgesetzt',
+ create: 'Runde #{{number}} hinzugefügt',
+ lock: 'Runde #{{number}} gesperrt',
+ unlock: 'Runde #{{number}} entsperrt',
},
tournament: {
- lock: 'Turnier festgesetzt',
+ lock: 'Turnier gesperrt',
},
unknown: 'Unbekannter Protokolleintrag vom Typ {{type}}.',
},
heading: 'Runden',
new: 'Neue Runde',
noSeed: 'Noch kein Seed',
+ lock: 'Runde sperren',
+ lockDescription: 'Wenn die Runde gesperrt wird, können Runner keine Änderungen an ihrem Ergebnis mehr vornehmen.',
+ locked: 'Die Runde ist für weitere Änderungen am Ergebnis gesperrt.',
+ lockError: 'Fehler beim Sperren',
+ lockIncompleteWarning: 'Achtung: Noch nicht alle Runner haben ihr Ergebnis für diese Runde eingereicht!',
+ lockSuccess: 'Runde gesperrt',
seed: 'Seed',
setSeed: 'Seed eintragen',
setSeedError: 'Seed konnte nicht eintragen werden',
setSeedSuccess: 'Seed eingetragen',
+ unlock: 'Runde entsperren',
+ unlockDescription: 'Die Runde wird wieder freigegeben und Runner können wieder Änderungen an ihrem Ergebnis vornehmen.',
+ unlocked: 'Die Runde ist offen für Änderungen am Ergebnis.',
+ unlockError: 'Fehler beim Entsperren',
+ unlockSuccess: 'Runde entsperrt',
},
tournaments: {
admins: 'Organisation',
FinishedIcon: 'Finished',
FirstPlaceIcon: 'First Place',
ForfeitIcon: 'Forfeit',
+ LockedIcon: 'Locked',
LogoutIcon: 'Logout',
PendingIcon: 'Pending',
SecondPlaceIcon: 'Second Place',
- ThirdPlaceIcon: 'Third Place',
StreamIcon: 'Stream',
+ ThirdPlaceIcon: 'Third Place',
+ UnlockedIcon: 'Unlocked',
zelda: {
'big-key': 'Big Key',
'blue-boomerang': 'Boomerang',
report: 'Result of {{time}} reported',
},
round: {
- create: 'Round added',
- lock: 'Round locked',
+ create: 'Round #{{number}} added',
+ lock: 'Round #{{number}} locked',
+ unlock: 'Round #{{number}} unlocked',
},
tournament: {
lock: 'Tournament locked',
heading: 'Rounds',
new: 'New round',
noSeed: 'No seed set',
+ lock: 'Lock round',
+ lockDescription: 'When a round is locked, runners cannot submit or change results.',
+ locked: 'Results for this round have been locked.',
+ lockError: 'Error locking round',
+ lockIncompleteWarning: 'Warning: Not all runners have submitted their results for this round yet!',
+ lockSuccess: 'Round locked',
seed: 'Seed',
setSeed: 'Set seed',
setSeedError: 'Seed could not be set',
setSeedSuccess: 'Seed set',
+ unlock: 'Unock round',
+ unlockDescription: 'The round is unlocked and runers are free to submit or change their results again.',
+ unlocked: 'Results for this round are subject to change.',
+ unlockError: 'Error unlocking round',
+ unlockSuccess: 'Round unlocked',
},
tournaments: {
admins: 'Admins',
Route::post('results', 'App\Http\Controllers\ResultController@create');
Route::post('rounds', 'App\Http\Controllers\RoundController@create');
+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');
Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');