return $result->toJson();
}
+ public function modify(Request $request, Result $result) {
+ $this->authorize('modify', $result);
+
+ $validatedData = $request->validate([
+ 'disqualified' => 'boolean',
+ 'forfeit' => 'boolean',
+ 'time' => 'numeric',
+ ]);
+
+ if (isset($validatedData['disqualified'])) $result->disqualified = $validatedData['disqualified'];
+ if (isset($validatedData['forfeit'])) $result->forfeit = $validatedData['forfeit'];
+ if (isset($validatedData['time'])) $result->time = $validatedData['time'];
+ $result->save();
+
+ Protocol::resultModified(
+ $result->round->tournament,
+ $result,
+ $request->user(),
+ );
+
+ ResultChanged::dispatch($result);
+
+ return $result->toJson();
+ }
+
public function unverify(Request $request, Result $result) {
$this->authorize('unverify', $result);
ProtocolAdded::dispatch($protocol);
}
+ public static function resultModified(Tournament $tournament, Result $result, User $user) {
+ $protocol = static::create([
+ 'tournament_id' => $tournament->id,
+ 'user_id' => $user->id,
+ 'type' => 'result.modify',
+ 'details' => [
+ 'tournament' => static::tournamentMemo($tournament),
+ 'result' => static::resultMemo($result),
+ 'runner' => static::userMemo($result->user),
+ 'round' => static::roundMemo($result->round),
+ ],
+ ]);
+ ProtocolAdded::dispatch($protocol);
+ }
+
public static function resultUnverified(Tournament $tournament, Result $result, User $user) {
$protocol = static::create([
'tournament_id' => $tournament->id,
return [
'id' => $result->id,
'comment' => $result->comment,
+ 'disqualified' => $result->disqualified,
'forfeit' => $result->forfeit,
'time' => $result->time,
];
return [
'id' => $tournament->id,
'accept_applications' => $tournament->accept_applications,
+ 'group_size' => $tournament->group_size,
'locked' => $tournament->locked,
'no_record' => $tournament->no_record,
'title' => $tournament->title,
+ 'type' => $tournament->type,
];
}
protected $casts = [
+ 'disqualified' => 'boolean',
'forfeit' => 'boolean',
'got_seed_at' => 'datetime',
'time' => 'double',
return false;
}
+ /**
+ * Determine whether the user can modify the result administratively.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Result $result
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function modify(User $user, Result $result)
+ {
+ return !$result->verified_at &&
+ $user->isTournamentCrew($result->round->tournament) &&
+ $user->id != $result->user_id;
+ }
+
/**
* Determine whether the user can unverify the result.
*
*/
public function unverify(User $user, Result $result)
{
- return $user->isTournamentCrew($result->round->tournament);
+ return $user->isTournamentCrew($result->round->tournament) && $user->id != $result->user_id;
}
/**
*/
public function verify(User $user, Result $result)
{
- return $user->isTournamentCrew($result->round->tournament);
+ return $user->isTournamentCrew($result->round->tournament) && $user->id != $result->user_id;
}
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('results', function (Blueprint $table) {
+ $table->boolean('disqualified')->default(false)->after('forfeit');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('results', function (Blueprint $table) {
+ $table->dropColumn('disqualified');
+ });
+ }
+};
Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
+Icon.DISQUALIFIED = makePreset('DisqualifiedIcon', 'square-xmark');
Icon.DOWNLOAD = makePreset('DownloadIcon', 'download');
Icon.EDIT = makePreset('EditIcon', 'edit');
Icon.ERROR = makePreset('ErrorIcon', 'triangle-exclamation');
Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
-Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
+Icon.FORFEIT = makePreset('ForfeitIcon', 'square-minus');
Icon.HASH = makePreset('HashIcon', 'hashtag');
Icon.INFO = makePreset('Info', 'circle-info');
Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
import Icon from '../common/Icon';
import Spoiler from '../common/Spoiler';
import { formatTime } from '../../helpers/Result';
+import { formatNumberAlways } from '../../helpers/Round';
import { getUserName } from '../../helpers/User';
const getEntryDate = entry => {
};
const getEntryRoundNumber = entry =>
- (entry && entry.details && entry.details.round && entry.details.round.number) || '?';
+ (entry && entry.details && entry.details.round && formatNumberAlways(entry.details.tournament, entry.details.round)) || '?';
const getEntryResultComment = entry => {
if (!entry || !entry.details || !entry.details.result || !entry.details.result.comment) {
const getEntryResultTime = entry => {
if (!entry || !entry.details || !entry.details.result) return 'ERROR';
const result = entry.details.result;
+ if (result.disqualified) return 'DQ XXX';
if (result.forfeit) return 'DNF XX';
return formatTime(result);
};
<Spoiler>{{time}}</Spoiler>,
</Trans>;
}
+ case 'result.modify':
case 'result.unverify':
case 'result.verify': {
const number = getEntryRoundNumber(entry);
case 'result.unverify':
case 'round.delete':
return <Icon.REMOVE />;
+ case 'result.modify':
case 'round.edit':
case 'round.seed':
return <Icon.EDIT />;
import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import ModifyForm from './ModifyForm';
import Verification from './Verification';
import Box from '../users/Box';
import { formatTime, getTime } from '../../helpers/Result';
import { formatNumberAlways } from '../../helpers/Round';
-import { maySeeResult, mayVerifyResult } from '../../helpers/permissions';
+import { mayModifyResult, maySeeResult, mayVerifyResult } from '../../helpers/permissions';
import { findResult } from '../../helpers/User';
import { useUser } from '../../hooks/user';
() => findResult(user, round),
[round, user],
);
+ const mayModify = React.useMemo(
+ () => mayModifyResult(authUser, tournament, round, result),
+ [authUser, result, round, tournament],
+ );
const maySee = React.useMemo(
() => maySeeResult(authUser, tournament, round, result),
[authUser, result, round, tournament],
<Verification actions={actions} result={result} round={round} tournament={tournament} />
</Modal.Body>
) : null}
+ {mayModify && actions.modifyResult ? (
+ <Modal.Body className="modification">
+ <div className="h5">{t('results.modify')}</div>
+ <ModifyForm onSubmit={(values) => actions.modifyResult(result, values)} result={result} />
+ </Modal.Body>
+ ) : null}
<Modal.Footer>
<Button onClick={onHide} variant="secondary">
{t('button.close')}
DetailDialog.propTypes = {
actions: PropTypes.shape({
+ modifyResult: PropTypes.func,
verifyResult: PropTypes.func,
}),
onHide: PropTypes.func,
--- /dev/null
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import LargeCheck from '../common/LargeCheck';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import { formatTime, parseTime } from '../../helpers/Result';
+import yup from '../../schema/yup';
+
+const ModifyForm = ({
+ errors,
+ handleBlur,
+ handleChange,
+ handleSubmit,
+ touched,
+ values,
+}) => {
+ const { t } = useTranslation();
+
+ return <Form noValidate onSubmit={handleSubmit}>
+ <Row>
+ <Form.Group as={Col} sm={8} controlId="modify.time">
+ <Form.Label>{t('results.reportTime')}</Form.Label>
+ <Form.Control
+ isInvalid={!!(touched.time && errors.time)}
+ name="time"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ placeholder={values.forfeit ? 'DNF' : '1:22:59'}
+ type="text"
+ value={values.time || ''}
+ />
+ {touched.time && errors.time ?
+ <Form.Control.Feedback type="invalid">
+ {t(errors.time)}
+ </Form.Control.Feedback>
+ :
+ <Form.Text muted>
+ {parseTime(values.time) ?
+ t(
+ 'results.reportPreview',
+ { time: formatTime({ time: parseTime(values.time) })},
+ )
+ : null}
+ </Form.Text>
+ }
+ </Form.Group>
+ <Form.Group as={Col} sm={2} controlId="modify.forfeit">
+ <Form.Label title={t('results.forfeit')}>
+ {t('results.forfeitShort')}
+ </Form.Label>
+ <Form.Control
+ as={LargeCheck}
+ isInvalid={!!(touched.forfeit && errors.forfeit)}
+ name="forfeit"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ value={!!values.forfeit}
+ />
+ </Form.Group>
+ <Form.Group as={Col} sm={2} controlId="modify.disqualified">
+ <Form.Label title={t('results.disqualified')}>
+ {t('results.disqualifiedShort')}
+ </Form.Label>
+ <Form.Control
+ as={LargeCheck}
+ isInvalid={!!(touched.disqualified && errors.disqualified)}
+ name="disqualified"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ value={!!values.disqualified}
+ />
+ </Form.Group>
+ </Row>
+ <Button className="mt-3" type="submit" variant="primary">
+ {t('button.save')}
+ </Button>
+ </Form>;
+};
+
+ModifyForm.propTypes = {
+ errors: PropTypes.shape({
+ disqualified: PropTypes.string,
+ forfeit: PropTypes.string,
+ time: PropTypes.string,
+ }),
+ handleBlur: PropTypes.func,
+ handleChange: PropTypes.func,
+ handleSubmit: PropTypes.func,
+ touched: PropTypes.shape({
+ disqualified: PropTypes.bool,
+ forfeit: PropTypes.bool,
+ time: PropTypes.bool,
+ }),
+ values: PropTypes.shape({
+ disqualified: PropTypes.bool,
+ forfeit: PropTypes.bool,
+ time: PropTypes.string,
+ }),
+};
+
+export default withFormik({
+ displayName: 'ModifyForm',
+ enableReinitialize: true,
+ handleSubmit: async (values, actions) => {
+ const { disqualified, forfeit, time } = values;
+ const { setErrors } = actions;
+ const { onSubmit } = actions.props;
+ try {
+ await onSubmit({
+ disqualified,
+ forfeit,
+ time: parseTime(time) || 0,
+ });
+ } catch (e) {
+ if (e.response && e.response.data && e.response.data.errors) {
+ setErrors(laravelErrorsToFormik(e.response.data.errors));
+ }
+ }
+ },
+ mapPropsToValues: ({ result }) => {
+ return {
+ disqualified: result ? !!result.disqualified : false,
+ forfeit: result ? !!result.forfeit : false,
+ time: result && result.time ? formatTime(result) : '',
+ };
+ },
+ validationSchema: yup.object().shape({
+ disqualified: yup.boolean().required(),
+ forfeit: yup.boolean().required(),
+ time: yup.string().time().when('forfeit', {
+ is: false,
+ then: () => yup.string().required().time(),
+ }),
+ }),
+})(ModifyForm);
</div>
{mayUnverify ?
<Button
+ disabled={unverifying}
onClick={handleUnverifyClick}
title={t('results.unverify')}
variant="outline-danger"
>
- <Icon.REMOVE title="" />
+ {unverifying ?
+ <Icon.LOADING />
+ :
+ <Icon.REMOVE title="" />
+ }
</Button>
: null}
</div>;
if (result.placement === 3 && maySee) {
return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
}
+ if (result.disqualified) {
+ return <Icon.DISQUALIFIED className="text-danger" size="lg" />;
+ }
if (result.verified_at) {
return <Icon.VERIFIED className="text-info" size="lg" />;
}
if (!result || !maySee) {
return null;
}
- if (result.time) {
- return formatTime(result);
+ if (result.disqualified) {
+ return 'DQ';
}
if (result.forfeit) {
return 'DNF';
}
+ if (result.time) {
+ return formatTime(result);
+ }
return '?';
};
return maySeeResults(user, tournament, round);
};
+export const mayModifyResults = (user, tournament, round) => {
+ return isTournamentCrew(user, tournament);
+};
+
+export const mayModifyResult = (user, tournament, round, result) => {
+ return mayModifyResults(user, tournament) && user && result && !result.verified_at && user.id !== result.user_id;
+};
+
export const mayVerifyResults = (user, tournament, round) => {
return isTournamentCrew(user, tournament);
};
},
result: {
comment: 'Ergebnis von Runde {{number}} kommentiert: <1>{{comment}}</1>',
+ modify: 'Ergebnis in Runde {{number}} von {{runner}} angepasst (<2>{{time}}</2>)',
report: 'Ergebnis von <1>{{time}}</1> bei Runde {{number}} eingetragen',
unverify: 'Verifikation von Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}</2>) zurückgezogen',
verify: 'Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}</2>) verifiziert',
createdAtFormat: '{{ date, L LT }}',
comment: 'Kommentar',
details: 'Details',
+ disqualified: 'Disqualifiziert',
+ disqualifiedShort: 'DQ',
edit: 'Ergebnis ändern',
editComment: 'Kommentar ändern',
forfeit: 'Aufgegeben',
+ forfeitShort: 'DNF',
gotSeedAt: 'Seed geladen',
gotSeedAtFormat: '{{ date, L LT }}',
list: 'Liste',
+ modify: 'Anpassen',
+ modifyError: 'Fehler beim Speichern',
+ modifySuccess: 'Ergebnis angepasst',
pending: 'Ausstehend',
placement: 'Platzierung',
points_one: '{{ count }} Punkt',
},
result: {
comment: 'Result of round {{number}} commented: <1>{{comment}}</1>',
+ modify: 'Modified round {{number}} result of {{runner}} (<2>{{time}}</2>)',
report: 'Result of <1>{{time}}</1> reported for round {{number}}',
unverify: 'Revoked verification for round {{number}} result of {{runner}} (<2>{{time}}</2>)',
verify: 'Verified round {{number}} result of {{runner}} (<2>{{time}}</2>)',
createdAt: 'Entry from',
createdAtFormat: '{{ date, L LT }}',
details: 'Details',
+ disqualified: 'Disqualified',
+ disqualifiedShort: 'DQ',
edit: 'Change result',
editComment: 'Edit comment',
forfeit: 'Forfeit',
+ forfeitShort: 'DNF',
gotSeedAt: 'Got seed at',
gotSeedAtFormat: '{{ date, L LT }}',
list: 'List',
+ modify: 'Modify',
+ modifyError: 'Error saving modifications',
+ modifySuccess: 'Result modified',
pending: 'Pending',
placement: 'Placement',
points_one: '{{ count }} point',
import Detail from '../components/tournament/Detail';
import {
mayEditContent,
+ mayModifyResults,
mayUnverifyResults,
mayVerifyResults,
} from '../helpers/permissions';
}
}, [id, t]);
+ const modifyResult = React.useCallback(async (result, values) => {
+ try {
+ const response = await axios.post(`/api/results/${result.id}/modify`, values);
+ toastr.success(t('results.modifySuccess'));
+ setTournament(tournament => patchResult(tournament, response.data));
+ } catch (e) {
+ toastr.error(t('results.modifyError', e));
+ throw e;
+ }
+ }, []);
+
const unverifyResult = React.useCallback(async (result) => {
try {
const response = await axios.post(`/api/results/${result.id}/unverify`);
} catch (e) {
toastr.error(t('results.unverifyError', e));
}
- });
+ }, []);
const verifyResult = React.useCallback(async (result) => {
try {
} catch (e) {
toastr.error(t('results.verifyError', e));
}
- });
+ }, []);
const actions = React.useMemo(() => ({
addRound,
setEditContent(content);
setShowContentDialog(true);
} : null,
+ modifyResult: mayModifyResults(user, tournament) ? modifyResult : null,
moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
selfAssignGroups,
unverifyResult: mayUnverifyResults(user, tournament) ? unverifyResult : null,
verifyResult: mayVerifyResults(user, tournament) ? verifyResult : null,
- }), [addRound, moreRounds, selfAssignGroups, tournament, user, verifyResult]);
+ }), [addRound, modifyResult, moreRounds, selfAssignGroups, tournament, unverifyResult, user, verifyResult]);
useEffect(() => {
const cb = (e) => {
.result-dialog {
+ .modification,
.verification {
border-top: thin solid $light;
}
Route::get('protocol/{tournament}/{round}', 'App\Http\Controllers\ProtocolController@forRound');
Route::post('results', 'App\Http\Controllers\ResultController@create');
+Route::post('results/{result}/modify', 'App\Http\Controllers\ResultController@modify');
Route::post('results/{result}/verify', 'App\Http\Controllers\ResultController@verify');
Route::post('results/{result}/unverify', 'App\Http\Controllers\ResultController@unverify');