Browse Source

prise en charge des checkins

garthh 3 weeks ago
parent
commit
7bbe40ff6d

+ 79 - 0
assets/controllers/checkin_controller.js

@@ -0,0 +1,79 @@
+
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+    connect() {
+        // Écoute tous les clics sur .open-modal -> détournement vers #lastStep + MAJ des options du formulaire
+      document.querySelectorAll('.participant-box').forEach(element => {
+          element.addEventListener('click', this.handleClick.bind(this))
+      })
+    }
+
+    handleClick(event) {
+        event.preventDefault()
+        const participantId = event.currentTarget.dataset.id
+        const box = event.currentTarget;
+        const icon = box.querySelector('.icon');
+
+        if (!participantId) return
+
+        // Etape 1
+        fetch(`/checkin/participant/${participantId}`, {
+          method: 'POST',
+          headers: {
+            'Accept': 'application/json'
+          }
+        })
+          .then(response => {
+            if (!response.ok) {
+              throw new Error(`Erreur API: ${response.status}`);
+            }
+            return response.json();
+          })
+          .then(data => {
+            
+            // Etape 2
+            console.log('ID du particpant cliqué :', participantId);
+            console.log('ÉTAT DU SLOT :', data.status);
+
+            if (data.status === "CHECKED") {
+              box.classList.add('has-background-primary');
+              box.classList.remove('has-background-danger');
+              icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-circle" viewBox="0 0 16 16">'+
+                '<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0"/>'+
+                '<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0z"/>'+
+                '</svg>';
+
+            } else if (data.status === "UNCHECKED") {
+              box.classList.remove('has-background-primary');
+              box.classList.add('has-background-danger');
+              icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-circle" viewBox="0 0 16 16">'+
+                '<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>'+
+                '</svg>';
+            } else {
+              console.warn("Statut de réponse sans action :", data.status);
+            }
+
+          })
+          .catch(error => {
+            console.error('Erreur lors de la récupération des slots :', error);
+          });
+
+
+
+        
+
+
+
+
+
+    }
+
+    disconnect() {
+    this.element.querySelectorAll('.planning-cell-free').forEach(element => {
+        element.removeEventListener('click', this.handleClick)
+    })
+}
+
+
+}

+ 1 - 1
config/packages/security.yaml

@@ -54,7 +54,7 @@ security:
         - { path: ^/profile, roles: ROLE_USER }
         - { path: ^/manage, roles: ROLE_MANAGER }
         - { path: ^/prepare, roles: ROLE_STAFF }
-        - { path: ^/check, roles: ROLE_STAFF }
+        - { path: ^/checkin, roles: ROLE_STAFF }
 
 when@test:
     security:

+ 31 - 0
migrations/Version20250808202547.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\Migrations\AbstractMigration;
+
+/**
+ * Auto-generated Migration: Please modify to your needs!
+ */
+final class Version20250808202547 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return '';
+    }
+
+    public function up(Schema $schema): void
+    {
+        // this up() migration is auto-generated, please modify it to your needs
+        $this->addSql('ALTER TABLE participation ADD checkin TINYINT(1) DEFAULT NULL');
+    }
+
+    public function down(Schema $schema): void
+    {
+        // this down() migration is auto-generated, please modify it to your needs
+        $this->addSql('ALTER TABLE participation DROP checkin');
+    }
+}

+ 1 - 1
src/Controller/Admin/EventConfig/GameController.php

@@ -48,7 +48,7 @@ final class GameController extends AbstractController
     #[Route('/admin/event/{id}/configure/game/toggle/', name: 'app_admin_event_config_game_toggle', requirements: ['id' => Requirement::UUID_V7], methods: ['POST'])]
     public function toggle(?Event $event, Request $request, GameRepository $repository, EntityManagerInterface $entityManager): JsonResponse
     {
-        // Récupération du MJ
+        // Récupération du jeu
         $data = json_decode($request->getContent(), true);
         $gameID = $data['gameId'] ?? null;
         if (!$gameID) {

+ 81 - 0
src/Controller/CheckinController.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Controller;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\Routing\Requirement\Requirement;
+use Symfony\Component\Routing\Attribute\Route;
+use Doctrine\ORM\EntityManagerInterface;
+
+use App\Security\Voter\PartyAccessVoter;
+use App\Security\Voter\EventAccessVoter;
+
+use App\Entity\Event;
+use App\Entity\Party;
+use App\Entity\Gamemaster;
+use App\Entity\Participation;
+use App\Entity\EventRepository;
+
+final class CheckinController extends AbstractController
+{
+    #[Route('/checkin/participant/{id}', name: 'app_check', requirements: ['id' => Requirement::UUID_V7], methods: ['POST'])]
+    public function participant(?Participation $participation, Request $request, EntityManagerInterface $manager): JsonResponse
+    {
+        // On récupère la participation
+        if (!$participation) {
+            return $this->json(['error' => 'Participation not found'], 404);
+        }
+
+        // ON check la partie
+        $party = $participation->getParty();
+        if (!$party) {
+            return $this->json(['error' => 'Party not found'], 404);
+        }
+
+        // Voteur sur la partie : ADMIN, GESTIONNAIRE ou STAFF/si MJ de la partie
+        $this->denyAccessUnlessGranted(PartyAccessVoter::ACCESS_PARTY, $party);
+        // TODO: retourner une erreur 503
+
+        $returnCode = "ERROR";
+
+        if ($participation->isCheckin()) {
+            $participation->setCheckin(false);
+            $manager->persist($participation);
+            $manager->flush();
+            $returnCode = "UNCHECKED";
+        } else {
+            $participation->setCheckin(true);
+            $manager->persist($participation);
+            $manager->flush();
+            $returnCode = "CHECKED";
+        }
+    
+        return $this->json(["status" => $returnCode]);
+    }
+
+    // Gérer l'affichage du contenu de la modale pour les checkins
+    #[Route('/checkin/party/{id}', name: 'app_checkin_party', requirements: ['id' => Requirement::UUID_V7], methods: ['GET'])]
+    public function party(?Party $party): Response
+    {
+        // Est-ce que c'est bien une partie ?
+        if (!$party) {
+            $this->addFlash('danger', 'Aucune partie avec cet identifiant.');
+            return $this->redirectToRoute('app_main');
+        }
+
+        // Voteur sur la partie : ADMIN, GESTIONNAIRE ou STAFF/si MJ de la partie
+        $this->denyAccessUnlessGranted(PartyAccessVoter::ACCESS_PARTY, $party);
+        
+        // Donc, ça marche
+        return $this->render('checkin/_modal.checkin.html.twig', [
+            'party' => $party,
+            'participations' => $party->getParticipations()
+        ]);
+
+    }
+
+
+}

+ 2 - 0
src/Controller/ParticipationController.php

@@ -5,6 +5,7 @@ namespace App\Controller;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Routing\Requirement\Requirement;
 use Symfony\Component\Routing\Attribute\Route;
 use Doctrine\ORM\EntityManagerInterface;
@@ -22,6 +23,7 @@ use App\Form\ParticipationType;
 
 final class ParticipationController extends AbstractController
 {
+
     #[Route('/cancel/{id}', name: 'app_participation_cancel', requirements: ['id' => Requirement::UUID_V7], methods: ['GET', 'POST'])]
     public function cancel(?Participation $participation, Request $request, EntityManagerInterface $manager): Response
     {

+ 1 - 0
src/Controller/ProfileController.php

@@ -205,6 +205,7 @@ final class ProfileController extends AbstractController
         // Retrouver le mail de l'utilisateur
         $email = $user->getEmail();
         $participations = $repository->findAllByEmail($email);
+        dump($participations);
         
         return $this->render('profile/participations.html.twig', [
             'participations' => $participations,

+ 17 - 0
src/Entity/Participation.php

@@ -38,6 +38,9 @@ class Participation
     #[ORM\Column(nullable: true)]
     private ?bool $consentImage = null;
 
+    #[ORM\Column(nullable: true)]
+    private ?bool $checkin = null;
+
     public function getId(): ?Uuid
     {
         return $this->id;
@@ -121,4 +124,18 @@ class Participation
 
         return $this;
     }
+
+    public function isCheckin(): ?bool
+    {
+        $checkin = $this->checkin;
+
+        return $checkin;
+    }
+
+    public function setCheckin(bool $checkin): static
+    {
+        $this->checkin = $checkin;
+
+        return $this;
+    }
 }

+ 15 - 0
src/Repository/ParticipationRepository.php

@@ -5,6 +5,7 @@ namespace App\Repository;
 use App\Entity\Participation;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
+use Symfony\Component\Uid\UuidV7;
 
 /**
  * @extends ServiceEntityRepository<Participation>
@@ -25,6 +26,20 @@ class ParticipationRepository extends ServiceEntityRepository
             ->getResult();
     }
 
+    public function findByStrID(string $id): ?Participation
+    {
+        if (!UuidV7::isValid($id)) {
+            return null;
+        }
+
+        try {
+            $uuid = UuidV7::fromString($id);
+        } catch (\Throwable $e) {
+            return null;
+        }
+
+        return $this->find($uuid);
+    }
     //    /**
     //     * @return Participation[] Returns an array of Participation objects
     //     */

+ 48 - 0
src/Security/Voter/PartyAccessVoter.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Security\Voter;
+
+use App\Entity\Party;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+class PartyAccessVoter extends Voter
+{
+    public const ACCESS_PARTY = 'ACCESS_PARTY';
+
+    protected function supports(string $attribute, $subject): bool
+    {
+        return $attribute === self::ACCESS_PARTY && $subject instanceof Party;
+    }
+
+    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+    {
+        $user = $token->getUser();
+
+        if (!$user instanceof UserInterface) {
+            return false;
+        }
+
+        $roles = $user->getRoles();
+
+        // Accès immédiat pour ADMIN ou MANAGER
+        if (in_array('ROLE_ADMIN', $roles, true) || in_array('ROLE_MANAGER', $roles, true)) {
+            return true;
+        }
+
+        // Vérifie les droits pour ROLE_STAFF
+        if (in_array('ROLE_STAFF', $roles, true)) {
+            $gamemaster = $user->getLinkToGamemaster();
+
+            if (!$gamemaster) {
+                return false;
+            }
+            // Accès si c'est le MJ de la partie
+            return ($subject->getGamemaster() == $gamemaster);
+        }
+
+        // ❌ Aucun autre rôle n'est autorisé
+        return false;
+    }
+}

+ 5 - 0
templates/admin/event/index.html.twig

@@ -40,6 +40,11 @@
                     <td>
                         <a class="button" href="{{ path('app_admin_event_edit', {id: event.id}) }}">Éditer</a>
                         <a class="button is-primary" href="{{ path('app_admin_event_config', {id: event.id}) }}">Configurer</a>
+                        {% if event.published %}
+                        <a class="button" href="{{ path('app_manage_planning', {id: event.id}) }}">Gérer</a>
+                        {% else %}
+                        <button class="button" disabled>Gérer</button>
+                        {% endif %}
                         <a class="button is-danger" data-id="{{ path('app_admin_event_delete', {'id': event.id})}}" href="#" {{ stimulus_controller('admin_confirm') }}>Supprimer</a>
                     </td>
                 </tr>

+ 24 - 0
templates/checkin/_modal.checkin.html.twig

@@ -0,0 +1,24 @@
+{% extends 'modal.html.twig' %}
+
+{% block title %}{{ party.game.name }}{% endblock %}
+
+{% block content %}
+
+<div class="content" {{ stimulus_controller('checkin') }}>
+  {% for participant in participations %}
+  <div data-id="{{participant.id}}" class="participant-box box{% if participant.checkin == null %}{% elseif participant.checkin %} has-background-primary{% else %} has-background-danger{% endif %}">
+    <p class="subtitle is-3">
+      {% if participant.checkin %}<span class="icon"><twig:ux:icon name="bi:check2-circle"/></span>{% else %}<span class="icon"><twig:ux:icon name="bi:circle"/></span>{% endif %}
+      &nbsp;{{ participant.participantName }}</p>
+  </div>
+
+  {% endfor %}
+</div>
+
+
+{% endblock %}
+
+
+
+
+

+ 5 - 1
templates/manage/booking.html.twig

@@ -91,7 +91,7 @@
             {% for participation in partie.getParticipations %}
                 <tr>
                     <td>{{ participation.participantName }}</td>
-                    <th>{{ participation.party.startOn|date('d/m/y @ H:i', app_timezone) }}</th>
+                    <th>{{ participation.party.startOn|date('d/m/y \\à H:i', app_timezone) }}</th>
                     <td>{{ participation.party.slots.first.space.name }}</td>
                     <td>{{ participation.party.game.name }}</td>
                     <td>{{ participation.party.gamemaster.preferedName }}</td>
@@ -100,7 +100,11 @@
                     
 
                     <td>
+                      {% if participation.checkin %}
+                      <button class="button is-danger" disabled>Annuler</button>
+                      {% else %}
                       <a href="#" data-id="{{ path('app_participation_cancel_direct', {id: participation.id}) }}" class="button is-danger" {{ stimulus_controller('admin_confirm') }}>Annuler</a>
+                      {% endif %}
                     </td>
                 </tr>
             {% endfor %}

+ 7 - 1
templates/manage/list.html.twig

@@ -89,13 +89,19 @@
             <tbody>
             {% for party in event.getParties %}
                 <tr>
-                    <th>{{ party.startOn|date('d/m/y @ H:i', app_timezone) }} à {{ party.endOn|date('H:i', app_timezone) }}</th>
+                    <th>{{ party.startOn|date('d/m/y \\d\\e H:i', app_timezone) }} à {{ party.endOn|date('H:i', app_timezone) }}</th>
                     <td>{{ party.slots.first.space.name }}</td>
                     <td>{{ party.game.name }}</td>
                     <td>{{ party.gamemaster.preferedName }}</td>
                     <td>{{ party.getSeatsOccuped }} / {{ party.getMaxParticipants }}</td>
                     <td>
+                      {% if party.getSeatsOccuped < party.getMaxParticipants %}
+                        <a href="{{ path('app_participation', {id: party.slots.first.id}) }}" class="button is-primary open-modal">Inscrire</a>
+                      {% else %}
+                        <button class="button is-primary" disabled>Inscrire</button>
+                      {% endif %}
                       <a href="{{ path('app_party_modify', {id: party.slots.first.id}) }}" class="button is-primary open-modal">Éditer</a>
+                      <a href="{{ path('app_checkin_party', {id: party.id}) }}" class="button open-modal">Checkins</a> 
                     </td>
                 </tr>
             {% endfor %}

+ 3 - 1
templates/profile/gamemastering.html.twig

@@ -4,6 +4,8 @@
 
 {% block content %}
 
+{{ component('Modal')}}
+
     <nav class="breadcrumb has-arrow-separator" aria-label="breadcrumbs">
       <ul>
         <li><a href="{{ path('app_main') }}">Accueil</a></li>
@@ -40,7 +42,7 @@
                     <td>{{ participation.startOn|date('d/m/y H:i', app_timezone) }}</td>
                     <td>{{ participation.getSeatsOccuped }} / {{ participation.getMaxParticipants }}</td>
                     <td>
-                      
+                      <a href="{{ path('app_checkin_party', {id: participation.id}) }}" class="button open-modal">Checkins</a> 
                     </td>
                 </tr>
             {% endfor %}

+ 4 - 0
templates/profile/participations.html.twig

@@ -40,7 +40,11 @@
                     <td>{{ participation.party.game.name }}</td>
                     <td>{{ participation.party.startOn|date('d/m/y H:i', app_timezone) }}</td>
                     <td>
+                    {% if participation.checkin %}
+                      <button class="button-danger" disabled>Annuler</button>
+                      {% else %}
                       <a href="#" data-id="{{ path('app_participation_cancel', {id: participation.id}) }}" class="button is-danger" {{ stimulus_controller('admin_confirm') }}>Annuler</a>
+                      {% endif %}
                     </td>
                 </tr>
             {% endfor %}