]> git.localhorst.tv Git - alttp.git/commitdiff
option to delete rounds as long as they have no results yes
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 7 May 2025 18:40:41 +0000 (20:40 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 7 May 2025 18:40:41 +0000 (20:40 +0200)
16 files changed:
app/Http/Controllers/RoundController.php
app/Models/Protocol.php
app/Models/Round.php
app/Policies/RoundPolicy.php
resources/js/components/protocol/Item.js
resources/js/components/rounds/DeleteButton.js [new file with mode: 0644]
resources/js/components/rounds/DeleteDialog.js [new file with mode: 0644]
resources/js/components/rounds/EditButton.js
resources/js/components/rounds/EditDialog.js
resources/js/components/rounds/Item.js
resources/js/helpers/Round.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tournament.js
routes/api.php

index 8ec2b84d010eb7519f4e1da796b2e7717e8d1e01..050a1cf3998e81a6b5000cbddc9b6f522d50b9e0 100644 (file)
@@ -39,6 +39,21 @@ class RoundController extends Controller
                return $round->toJson();
        }
 
+       public function delete(Request $request, Round $round) {
+               $this->authorize('delete', $round);
+               if (count($round->results) > 0) {
+                       return response('Forbidden', 403);
+               }
+               $round->load('tournament');
+               $round->delete();
+               Protocol::roundDeleted(
+                       $round->tournament,
+                       $round,
+                       $request->user(),
+               );
+               return $round->toJson();
+       }
+
        public function update(Request $request, Round $round) {
                $this->authorize('update', $round);
 
index 60b25f2ad8cd6cd21c4003fc747e40c808e1b132..9c902ce6c44ffef6204eab7d298c0abe99fe6fb7 100644 (file)
@@ -93,6 +93,19 @@ class Protocol extends Model
                ProtocolAdded::dispatch($protocol);
        }
 
+       public static function roundDeleted(Tournament $tournament, Round $round, User $user) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user->id,
+                       'type' => 'round.delete',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                               'round' => static::roundMemo($round),
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
        public static function roundEdited(Tournament $tournament, Round $round, User $user) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
index 66cc271d0b010b1f71c131b3952a311450df0948..fb78af161c7211c0c3ce9a237918387379bdfc77 100644 (file)
@@ -2,17 +2,26 @@
 
 namespace App\Models;
 
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
 class Round extends Model
 {
+       use BroadcastsEvents;
        use HasFactory;
 
        public function protocols() {
                return $this->tournament->protocols()->where('details->round->id', '=', $this->id);
        }
 
+       public function broadcastOn($event) {
+               return [
+                       new Channel('Tournament.'.$this->tournament_id),
+               ];
+       }
+
 
        public function isComplete() {
                if (count($this->tournament->participants) == 0) return false;
index 645a03fe6767bc9490cbce2aa548fd7a7ff28895..7f53e0fe56a362a01d1d887b8946d50687e0f0bf 100644 (file)
@@ -65,7 +65,7 @@ class RoundPolicy
         */
        public function delete(User $user, Round $round)
        {
-               return false;
+               return !$round->tournament->locked && $user->isTournamentAdmin($round->tournament) && count($round->results) == 0;
        }
 
        /**
index aebd21180b0d2bcc09f513e08c86155772881c4c..17dd744e706dae0bfc035e1d15b9166908788050 100644 (file)
@@ -67,6 +67,7 @@ const getEntryDescription = (entry, t) => {
                        </Trans>;
                }
                case 'round.create':
+               case 'round.delete':
                case 'round.edit':
                case 'round.lock':
                case 'round.seed':
@@ -99,6 +100,8 @@ const getEntryIcon = entry => {
                        return <Icon.RESULT />;
                case 'round.create':
                        return <Icon.ADD />;
+               case 'round.delete':
+                       return <Icon.REMOVE />;
                case 'round.lock':
                case 'tournament.close':
                case 'tournament.lock':
diff --git a/resources/js/components/rounds/DeleteButton.js b/resources/js/components/rounds/DeleteButton.js
new file mode 100644 (file)
index 0000000..578380d
--- /dev/null
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DeleteDialog from './DeleteDialog';
+import Icon from '../common/Icon';
+
+const DeleteButton = ({
+       round,
+       tournament,
+}) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+
+       return <>
+               <DeleteDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       tournament={tournament}
+               />
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       size="sm"
+                       title={t('rounds.delete')}
+                       variant="outline-danger"
+               >
+                       <Icon.REMOVE title="" />
+               </Button>
+       </>;
+};
+
+DeleteButton.propTypes = {
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default DeleteButton;
diff --git a/resources/js/components/rounds/DeleteDialog.js b/resources/js/components/rounds/DeleteDialog.js
new file mode 100644 (file)
index 0000000..188f082
--- /dev/null
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+const EditDialog = ({
+       onHide,
+       round,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       const handleDelete = React.useCallback(async() => {
+               try {
+                       await axios.delete(`/api/rounds/${round.id}`);
+                       onHide();
+               } catch (e) {
+                       toastr.error(t('rounds.deleteError'));
+               }
+       }, [onHide, round, t]);
+
+       return <Modal className="edit-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('rounds.delete')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <Alert variant="danger">
+                               {t('rounds.deleteConfirmMessage', {
+                                       ...round,
+                                       date: new Date(round.created_at),
+                               })}
+                       </Alert>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.cancel')}
+                       </Button>
+                       <Button onClick={handleDelete} variant="danger">
+                               {t('button.delete')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+EditDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+               created_at: PropTypes.string,
+               id: PropTypes.number,
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default EditDialog;
index edc6fbf801cc440e4178fe1902d290e4ab964539..d32b843f33a43a5fbf590ccaff5e80ec65ef722a 100644 (file)
@@ -1,11 +1,10 @@
 import PropTypes from 'prop-types';
 import React, { useState } from 'react';
 import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import EditDialog from './EditDialog';
 import Icon from '../common/Icon';
-import i18n from '../../i18n';
 
 const EditButton = ({
        round,
@@ -13,6 +12,8 @@ const EditButton = ({
 }) => {
        const [showDialog, setShowDialog] = useState(false);
 
+       const { t } = useTranslation();
+
        return <>
                <EditDialog
                        onHide={() => setShowDialog(false)}
@@ -23,7 +24,7 @@ const EditButton = ({
                <Button
                        onClick={() => setShowDialog(true)}
                        size="sm"
-                       title={i18n.t('rounds.edit')}
+                       title={t('rounds.edit')}
                        variant="outline-secondary"
                >
                        <Icon.EDIT title="" />
@@ -39,4 +40,4 @@ EditButton.propTypes = {
        }),
 };
 
-export default withTranslation()(EditButton);
+export default EditButton;
index 912a42025d1e3fc9af5f64730bb8fd176a69af29..bf72c329a42a580bce82d0ec2ea13011c9bc6c85 100644 (file)
@@ -1,27 +1,29 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import { Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import EditForm from './EditForm';
-import i18n from '../../i18n';
 
 const EditDialog = ({
        onHide,
        round,
        show,
-}) =>
-<Modal className="edit-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('rounds.edit')}
-               </Modal.Title>
-       </Modal.Header>
-       <EditForm
-               onCancel={onHide}
-               round={round}
-       />
-</Modal>;
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="edit-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('rounds.edit')}
+                       </Modal.Title>
+               </Modal.Header>
+               <EditForm
+                       onCancel={onHide}
+                       round={round}
+               />
+       </Modal>;
+};
 
 EditDialog.propTypes = {
        onHide: PropTypes.func,
@@ -32,4 +34,4 @@ EditDialog.propTypes = {
        }),
 };
 
-export default withTranslation()(EditDialog);
+export default EditDialog;
index 160fbbfaeca3b330101081e289be7f5c77d5f382..94f87600fe2f8602da3e0b7d3d1a212d788d42f9 100644 (file)
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 
+import DeleteButton from './DeleteButton';
 import EditButton from './EditButton';
 import LockButton from './LockButton';
 import SeedButton from './SeedButton';
@@ -11,6 +12,7 @@ import RoundProtocol from '../protocol/RoundProtocol';
 import List from '../results/List';
 import ReportButton from '../results/ReportButton';
 import {
+       mayDeleteRound,
        mayEditRound,
        mayReportResult,
        mayViewProtocol,
@@ -91,6 +93,9 @@ const Item = ({
                                        {mayViewProtocol(user, tournament, round) ?
                                                <RoundProtocol roundId={round.id} tournamentId={tournament.id} />
                                        : null}
+                                       {mayDeleteRound(user, tournament, round) ?
+                                               <DeleteButton round={round} tournament={tournament} />
+                                       : null}
                                </div>
                        </div>
                        <List round={round} tournament={tournament} />
index 429d9fa95fdc56a25e3c4cd360af0354ff93bb9f..2096759cf12a7f81b96055761b3a84d1312ce303 100644 (file)
@@ -1,6 +1,10 @@
 import Participant from './Participant';
 import Tournament from './Tournament';
 
+export const hasResults = (round) => {
+       return round && round.results && round.results.length > 0;
+};
+
 export const isComplete = (tournament, round) => {
        if (!tournament || !tournament.participants) return false;
        if (tournament.type === 'open-async') return false;
@@ -29,6 +33,7 @@ export const patchResult = (round, result) => {
 };
 
 export default {
+       hasResults,
        isComplete,
        patchResult,
 };
index c406cd99444ebd5d515c4f060ac83c70e003b9d3..9515417c05858cae07a5f6aaaf439e5bfd521ba1 100644 (file)
@@ -135,6 +135,9 @@ export const mayReportResult = (user, tournament) => {
        return isRunner(user, tournament);
 };
 
+export const mayDeleteRound = (user, tournament, round) =>
+       !tournament.locked && isTournamentAdmin(user, tournament) && !Round.hasResults(round);
+
 export const mayEditRound = (user, tournament) =>
        !tournament.locked && isTournamentAdmin(user, tournament);
 
index 658a2f6f339d120029b1cd48a8b806c6c91606f7..643714cbca51e02b1fa1b1c956e32d99a2391c86 100644 (file)
@@ -72,6 +72,7 @@ export default {
                        chart: 'Diagramm',
                        close: 'Schließen',
                        confirm: 'Bestätigen',
+                       delete: 'Löschen',
                        edit: 'Bearbeiten',
                        filter: 'Filter',
                        generate: 'Generieren',
@@ -412,6 +413,7 @@ export default {
                                },
                                round: {
                                        create: 'Runde #{{number}} hinzugefügt',
+                                       delete: 'Runde #{{number}} gelöscht',
                                        edit: 'Runde #{{number}} bearbeitet',
                                        lock: 'Runde #{{number}} gesperrt',
                                        seed: 'Seed für Runde #{{number}} eingetragen',
@@ -456,6 +458,10 @@ export default {
                rounds: {
                        code: 'Code',
                        date: '{{ date, L }}',
+                       delete: 'Runde löschen',
+                       deleteConfirmMessage: 'Runde #{{ number }} vom {{ date, L }} löschen?',
+                       deleteError: 'Fehler beim Löschen',
+                       deleteSuccess: 'Runde gelöscht',
                        edit: 'Runde bearbeiten',
                        editError: 'Fehler beim Speichern',
                        editSuccess: 'Gespeichert',
index 1740cf64ef31919264ce99f9aac66ecffa744c9d..06ff191475e3ca203f0e0404562835f7ae9d5b99 100644 (file)
@@ -72,6 +72,7 @@ export default {
                        chart: 'Chart',
                        close: 'Close',
                        confirm: 'Confirm',
+                       delete: 'Delete',
                        edit: 'Edit',
                        filter: 'Filter',
                        generate: 'Generate',
@@ -412,6 +413,7 @@ export default {
                                },
                                round: {
                                        create: 'Added round #{{number}}',
+                                       delete: 'Deleted round #{{number}}',
                                        edit: 'Edited round #{{number}}',
                                        lock: 'Round #{{number}} locked',
                                        seed: 'Set seed for round #{{number}}',
@@ -456,6 +458,10 @@ export default {
                rounds: {
                        code: 'Code',
                        date: '{{ date, L }}',
+                       delete: 'Delete round',
+                       deleteConfirmMessage: 'Remove round #{{ number }} from {{ date, L }}?',
+                       deleteError: 'Error deleting round',
+                       deleteSuccess: 'Round deleted',
                        edit: 'Edit round',
                        editError: 'Error saving round',
                        editSuccess: 'Saved successfully',
index ecaab6c97807bf1968b7e1732cf8c7d624a0a85b..0973438e52de860a65f62c4313dc62d9881e3e21 100644 (file)
@@ -89,6 +89,14 @@ export const Component = () => {
                                        setTournament(tournament => patchRound(tournament, e.round));
                                }
                        })
+                       .listen('.RoundDeleted', e => {
+                               if (e.model) {
+                                       setTournament(tournament => ({
+                                               ...tournament,
+                                               rounds: tournament.rounds.filter((r) => r.id !== e.model.id),
+                                       }));
+                               }
+                       })
                        .listen('TournamentChanged', e => {
                                if (e.tournament) {
                                        setTournament(tournament => ({ ...tournament, ...e.tournament }));
index bc4b1fb2c6b2a1b499359be3ed3667f3f875798f..b8f203309f47e38cc8c5b46b506aa230f299211e 100644 (file)
@@ -74,6 +74,7 @@ Route::post('results', 'App\Http\Controllers\ResultController@create');
 
 Route::post('rounds', 'App\Http\Controllers\RoundController@create');
 Route::put('rounds/{round}', 'App\Http\Controllers\RoundController@update');
+Route::delete('rounds/{round}', 'App\Http\Controllers\RoundController@delete');
 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');