◆ Portail Antalaha

Annuaire › Code ouvert

Réservations de sorties en pirogue (Alpine.js)

tutorial · Voir le site ↗
Connectez-vous pour copier ce site

Code publié par son auteur, en lecture seule. « Copier dans mon espace » crée un nouveau site à vous (non publié) que vous pouvez modifier librement.

index.html · 25.0 Ko

<!DOCTYPE html>
<!--
  ==========================================================================
  index.html — Tableau de bord des réservations de sorties en pirogue/bateau
  Tutoriel Alpine.js, niveau AVANCÉ
  --------------------------------------------------------------------------
  Une petite entreprise d'Antalaha gère ses réservations : onglets, ajout
  avec validation, liste filtrable/triable, modification en ligne,
  suppression avec confirmation, statistiques.

  ARCHITECTURE (lisez dans cet ordre) :
    js/outils.js       fonctions utilitaires (dates, Ariary, stockage sûr)
    js/store.js        Alpine.store : les données PARTAGÉES + règles métier
    js/extensions.js   magics $ariary / $dateFr et directive x-avatar
    js/composants.js   Alpine.data : composants ; Alpine.bind : x-bind réutilisables
    index.html         (ce fichier) : uniquement la structure et les attributs x-…

  L'idée : le HTML décrit CE QU'ON VOIT, le JavaScript décrit COMMENT ÇA
  MARCHE. Les attributs ci-dessous appellent des noms définis dans js/.
  ==========================================================================
-->
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Sorties en pirogue — Réservations</title>
  <link rel="stylesheet" href="css/style.css">

  <!--
    ORDRE DES SCRIPTS — LE POINT LE PLUS IMPORTANT DE CE PROJET.
    Nos fichiers s'enregistrent dans l'événement « alpine:init », qu'Alpine
    émet une seule fois, au démarrage. Il faut donc que nos écouteurs
    soient déjà en place : nos scripts AVANT alpine.min.js.
    Tous sont « defer » : exécutés dans l'ordre d'écriture après la lecture
    du HTML. outils.js d'abord car les autres s'en servent.
  -->
  <script src="js/outils.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/extensions.js" defer></script>
  <script src="js/composants.js" defer></script>
  <script src="/libs/alpine.min.js" defer></script>
</head>
<body>

<!--
  x-data="onglets" : composant RACINE défini par Alpine.data('onglets', …)
  dans js/composants.js. (Sans parenthèses ni paramètres, on peut écrire
  juste le nom.)

  @reservation-ajoutee="apresAjout()" : écoute l'événement personnalisé
  envoyé par le formulaire avec $dispatch('reservation-ajoutee'). Les
  événements remontent de l'enfant vers ses parents ; ce <div> les reçoit.
-->
<div x-data="onglets" @reservation-ajoutee="apresAjout()">

  <header class="entete">
    <div class="entete-contenu">
      <!-- Icône de pirogue insérée en SVG « en ligne » : elle prend la couleur du texte -->
      <svg class="ic grande" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 15h18l-3 4H6zM12 15V4l5 8h-5M7 21h10"/></svg>
      <div>
        <h1>Sorties en pirogue d'Antalaha</h1>
        <p>Tableau de bord des réservations</p>
      </div>
    </div>
  </header>

  <!-- Avertissement si localStorage est bloqué (voir outils.js / store.js).
       $store.reservations.memoireSeule : lecture d'une valeur du store. -->
  <p class="avertissement" role="status" x-show="$store.reservations.memoireSeule" x-cloak>
    Sauvegarde durable indisponible ici : vos réservations sont gardées en mémoire et seront perdues au rechargement.
  </p>

  <!-- ===================== ONGLETS ===================== -->
  <!--
    x-bind="boutonOnglet" : applique d'un coup TOUT l'objet défini avec
    Alpine.bind('boutonOnglet', …) : role, aria-selected, tabindex, classe
    « actif », clic et flèches du clavier. Les trois boutons partagent donc
    exactement le même comportement, seul data-onglet change.
  -->
  <div class="onglets" role="tablist" aria-label="Sections">
    <button x-bind="boutonOnglet" data-onglet="tableau" id="onglet-tableau" aria-controls="panneau-tableau">Tableau de bord</button>
    <button x-bind="boutonOnglet" data-onglet="liste" id="onglet-liste" aria-controls="panneau-liste">
      Réservations <span class="compte" x-text="$store.reservations.liste.length"></span>
    </button>
    <button x-bind="boutonOnglet" data-onglet="nouvelle" id="onglet-nouvelle" aria-controls="panneau-nouvelle">+ Nouvelle</button>
  </div>

  <main class="contenu">

    <!-- ================================================================
         PANNEAU 1 : TABLEAU DE BORD (statistiques)
         ================================================================ -->
    <!--
      x-show : affiche ce panneau seulement pour l'onglet « tableau ».
      (On utilise x-show plutôt que x-if pour GARDER le contenu des autres
      panneaux, en particulier ce qui est saisi dans le formulaire.)

      x-effect : comme x-data + une fonction qui se relance toute seule.
      Dès qu'une donnée lue dans l'expression change, elle est ré-exécutée.
      Ici : le titre de l'onglet du navigateur affiche toujours le nombre
      de réservations en attente. L'expression lit $store.reservations.enAttente,
      Alpine sait donc qu'il faut la relancer quand ce nombre change.
    -->
    <section id="panneau-tableau" role="tabpanel" aria-labelledby="onglet-tableau"
             x-show="onglet === 'tableau'" x-cloak
             x-effect="document.title = '(' + $store.reservations.enAttente + ' en attente) Sorties en pirogue'">
      <h2>Vue d'ensemble</h2>

      <!-- Les cartes de chiffres. Tout vient des getters du store (voir store.js). -->
      <div class="stats">
        <div class="stat">
          <span class="stat-valeur" x-text="$store.reservations.enAttente"></span>
          <span class="stat-nom">En attente de confirmation</span>
        </div>
        <div class="stat">
          <span class="stat-valeur" x-text="$store.reservations.confirmees"></span>
          <span class="stat-nom">Confirmées</span>
        </div>
        <div class="stat">
          <span class="stat-valeur" x-text="$store.reservations.personnesAVenir"></span>
          <span class="stat-nom">Personnes attendues (sorties à venir)</span>
        </div>
        <div class="stat">
          <!-- $ariary : notre magic perso défini dans js/extensions.js -->
          <span class="stat-valeur" x-text="$ariary($store.reservations.chiffreAffaires)"></span>
          <span class="stat-nom">Chiffre d'affaires confirmé</span>
        </div>
      </div>

      <p class="info" x-show="$store.reservations.chiffreEnAttente > 0" x-cloak>
        Encore <strong x-text="$ariary($store.reservations.chiffreEnAttente)"></strong> en attente de confirmation.
      </p>

      <div class="bloc">
        <h3>Prochaine sortie confirmée</h3>
        <!--
          x-if : crée le bloc SEULEMENT s'il existe une prochaine sortie.
          « <template x-if> » : un seul élément enfant obligatoire.
          x-if sert aussi à ne pas évaluer des expressions sur une valeur
          absente (prochaine.client planterait si prochaine est undefined).
        -->
        <template x-if="$store.reservations.prochaine">
          <p>
            <strong x-text="$dateFr($store.reservations.prochaine.date)"></strong>
            à <span x-text="$store.reservations.prochaine.heure"></span> —
            <span x-text="$store.reservations.prochaine.client"></span> :
            <span x-text="$store.reservations.sortie($store.reservations.prochaine.sortie).nom"></span>,
            <span x-text="$store.reservations.prochaine.personnes"></span> personne(s).
          </p>
        </template>
        <template x-if="!$store.reservations.prochaine">
          <p class="discret">Aucune sortie confirmée à venir.</p>
        </template>
      </div>

      <div class="bloc">
        <h3>Réservations par type de sortie</h3>
        <ul class="barres">
          <!-- x-for + :key sur un <template> ; « repartition » est un getter du store -->
          <template x-for="ligne in $store.reservations.repartition" :key="ligne.id">
            <li>
              <span class="barre-nom" x-text="ligne.nom"></span>
              <!-- :style avec un objet : la largeur de la barre vient du calcul -->
              <span class="barre-piste" aria-hidden="true"><span class="barre" :style="{ width: ligne.pourcent + '%' }"></span></span>
              <strong class="barre-nombre" x-text="ligne.nombre"></strong>
            </li>
          </template>
        </ul>
      </div>

      <p>
        <!-- Action destructive : on passe AUSSI par le dialogue de confirmation.
             $dispatch envoie les infos ; surConfirmation est la fonction à exécuter si on confirme. -->
        <button type="button" class="secondaire"
                @click="$dispatch('demander-confirmation', {
                  titre: 'Recharger les exemples ?',
                  message: 'Toutes vos réservations actuelles seront remplacées par les exemples de départ.',
                  bouton: 'Recharger',
                  surConfirmation: () => { $store.reservations.reinitialiser(); $store.ui.notifier('Exemples rechargés.', 'info'); }
                })">
          Recharger les réservations d'exemple
        </button>
      </p>
    </section>

    <!-- ================================================================
         PANNEAU 2 : LISTE DES RÉSERVATIONS
         ================================================================ -->
    <!--
      x-data="listeReservations" : un composant ENFANT, défini par Alpine.data.
      Il est imbriqué dans le composant « onglets » : il garde ses propres
      données (recherche, tri…) mais peut aussi lire celles du parent.
    -->
    <section id="panneau-liste" role="tabpanel" aria-labelledby="onglet-liste"
             x-show="onglet === 'liste'" x-cloak x-data="listeReservations">
      <!-- tabindex="-1" : le titre peut recevoir le focus par programme
           (utilisé après une suppression, voir dialogueConfirmation). -->
      <h2 id="titre-liste" tabindex="-1">Réservations</h2>

      <div class="filtres">
        <div>
          <label for="recherche">Rechercher (nom, téléphone, note)</label>
          <!-- .debounce.200ms : attend 200 ms sans frappe avant de mettre à jour,
               pour ne pas recalculer la liste à chaque lettre. -->
          <input id="recherche" type="search" x-model.debounce.200ms="recherche" autocomplete="off">
        </div>
        <div>
          <label for="f-statut">Statut</label>
          <select id="f-statut" x-model="filtreStatut">
            <option value="tous">Tous</option>
            <!-- Options générées par x-for à partir de la liste des statuts du store -->
            <template x-for="s in $store.reservations.statuts" :key="s.id">
              <option :value="s.id" x-text="s.nom"></option>
            </template>
          </select>
        </div>
        <div>
          <label for="f-sortie">Sortie</label>
          <select id="f-sortie" x-model="filtreSortie">
            <option value="toutes">Toutes</option>
            <template x-for="s in $store.reservations.sorties" :key="s.id">
              <option :value="s.id" x-text="s.nom"></option>
            </template>
          </select>
        </div>
        <div>
          <label for="f-tri">Trier par</label>
          <select id="f-tri" x-model="tri">
            <option value="date-asc">Date (la plus proche d'abord)</option>
            <option value="date-desc">Date (la plus lointaine d'abord)</option>
            <option value="client">Nom du client</option>
            <option value="montant">Montant (le plus élevé d'abord)</option>
          </select>
        </div>
      </div>

      <p class="compteur" aria-live="polite">
        <span x-text="resultats.length"></span> réservation(s) affichée(s)
        <button type="button" class="lien" @click="effacerFiltres()"
                x-show="recherche !== '' || filtreStatut !== 'tous' || filtreSortie !== 'toutes'" x-cloak>
          Effacer les filtres
        </button>
      </p>

      <p class="vide" x-show="resultats.length === 0" x-cloak>Aucune réservation ne correspond.</p>

      <ul class="reservations">
        <!-- Une ligne <li> par réservation. :key="r.id" : identifiant stable et unique. -->
        <template x-for="r in resultats" :key="r.id">
          <li class="reservation" :class="'statut-' + r.statut">

            <div class="res-tete">
              <!-- x-avatar : NOTRE directive (js/extensions.js) : écrit les initiales -->
              <span class="avatar" x-avatar="r.client"></span>
              <div class="res-identite">
                <strong x-text="r.client"></strong>
                <small x-text="r.telephone"></small>
              </div>
              <span class="badge" :class="'badge-' + r.statut" x-text="$store.reservations.nomStatut(r.statut)"></span>
            </div>

            <!-- MODE LECTURE : affiché si CETTE ligne n'est pas en édition -->
            <template x-if="enEdition !== r.id">
              <div>
                <dl class="res-details">
                  <div><dt>Date</dt><dd><span x-text="$dateFr(r.date)"></span> à <span x-text="r.heure"></span>
                    <em class="passee" x-show="estPassee(r)">(passée)</em></dd></div>
                  <div><dt>Sortie</dt><dd x-text="$store.reservations.sortie(r.sortie).nom"></dd></div>
                  <div><dt>Personnes</dt><dd x-text="r.personnes"></dd></div>
                  <div><dt>Prix</dt><dd x-text="$ariary(r.prix)"></dd></div>
                  <!-- x-show avec une chaîne : vide = faux, donc masqué -->
                  <div x-show="r.note"><dt>Note</dt><dd x-text="r.note"></dd></div>
                </dl>
                <div class="actions">
                  <button type="button" class="petit" x-show="r.statut === 'attente'" @click="changerStatut(r, 'confirmee')">Confirmer</button>
                  <button type="button" class="petit secondaire" x-show="r.statut !== 'annulee'" @click="changerStatut(r, 'annulee')">Annuler la sortie</button>
                  <button type="button" class="petit secondaire" x-show="r.statut === 'annulee'" @click="changerStatut(r, 'attente')">Rouvrir</button>
                  <button type="button" class="petit secondaire" @click="modifier(r)" :aria-label="'Modifier la réservation de ' + r.client">Modifier</button>
                  <button type="button" class="petit danger" @click="demanderSuppression(r)" :aria-label="'Supprimer la réservation de ' + r.client">Supprimer</button>
                </div>
              </div>
            </template>

            <!-- MODE ÉDITION : un petit formulaire dans la ligne -->
            <template x-if="enEdition === r.id">
              <!-- @submit.prevent : .prevent évite le rechargement de la page -->
              <form class="edition" @submit.prevent="enregistrerEdition()" novalidate>
                <div class="grille-form">
                  <div>
                    <label for="ed-date">Date</label>
                    <!-- x-ref="editDate" : permet de lui donner le focus (voir modifier() dans composants.js) -->
                    <input id="ed-date" type="date" x-ref="editDate" x-model="brouillon.date"
                           :class="{ invalide: erreursEdition.date }" :aria-invalid="!!erreursEdition.date">
                    <p class="erreur" role="alert" x-show="erreursEdition.date" x-text="erreursEdition.date"></p>
                  </div>
                  <div>
                    <label for="ed-heure">Heure</label>
                    <input id="ed-heure" type="time" x-model="brouillon.heure"
                           :class="{ invalide: erreursEdition.heure }" :aria-invalid="!!erreursEdition.heure">
                    <p class="erreur" role="alert" x-show="erreursEdition.heure" x-text="erreursEdition.heure"></p>
                  </div>
                  <div>
                    <label for="ed-sortie">Sortie</label>
                    <select id="ed-sortie" x-model="brouillon.sortie">
                      <template x-for="s in $store.reservations.sorties" :key="s.id">
                        <option :value="s.id" :selected="s.id === brouillon.sortie" x-text="s.nom"></option>
                      </template>
                    </select>
                  </div>
                  <div>
                    <label for="ed-pers">Personnes (max. <span x-text="maxBrouillon"></span>)</label>
                    <input id="ed-pers" type="number" min="1" :max="maxBrouillon" x-model.number="brouillon.personnes"
                           :class="{ invalide: erreursEdition.personnes }" :aria-invalid="!!erreursEdition.personnes">
                    <p class="erreur" role="alert" x-show="erreursEdition.personnes" x-text="erreursEdition.personnes"></p>
                  </div>
                  <div>
                    <label for="ed-statut">Statut</label>
                    <select id="ed-statut" x-model="brouillon.statut">
                      <template x-for="s in $store.reservations.statuts" :key="s.id">
                        <option :value="s.id" :selected="s.id === brouillon.statut" x-text="s.nom"></option>
                      </template>
                    </select>
                  </div>
                </div>
                <p class="discret">Nouveau prix : <strong x-text="$ariary($store.reservations.prix(brouillon.sortie, brouillon.personnes))"></strong></p>
                <div class="actions">
                  <button type="submit" class="petit">Enregistrer</button>
                  <button type="button" class="petit secondaire" @click="annulerEdition()">Annuler</button>
                </div>
              </form>
            </template>

          </li>
        </template>
      </ul>
    </section>

    <!-- ================================================================
         PANNEAU 3 : FORMULAIRE D'AJOUT
         ================================================================ -->
    <section id="panneau-nouvelle" role="tabpanel" aria-labelledby="onglet-nouvelle"
             x-show="onglet === 'nouvelle'" x-cloak x-data="formulaireReservation">
      <h2>Nouvelle réservation</h2>

      <!--
        novalidate : on désactive les bulles de validation du navigateur,
        car on affiche NOS messages (plus clairs, en français).
        @submit.prevent : appelle soumettre() sans recharger la page.

        Chaque champ utilise x-bind="champValide" (objet réutilisable défini
        dans composants.js) : il ajoute le « blur », la classe d'erreur et
        les attributs aria. data-champ dit de quel champ il s'agit.
        x-ref="client" etc. permet de donner le focus au premier champ en erreur.
      -->
      <form @submit.prevent="soumettre()" novalidate>
        <div class="grille-form">

          <div>
            <label for="f-client">Nom du client</label>
            <input id="f-client" type="text" autocomplete="name"
                   x-model="valeurs.client" x-ref="client" x-bind="champValide" data-champ="client">
            <p id="erreur-client" class="erreur" role="alert" x-show="afficherErreur('client')" x-text="afficherErreur('client')"></p>
          </div>

          <div>
            <label for="f-telephone">Téléphone</label>
            <input id="f-telephone" type="tel" autocomplete="tel" placeholder="034 12 345 67"
                   x-model="valeurs.telephone" x-ref="telephone" x-bind="champValide" data-champ="telephone">
            <p id="erreur-telephone" class="erreur" role="alert" x-show="afficherErreur('telephone')" x-text="afficherErreur('telephone')"></p>
          </div>

          <div>
            <label for="f-date">Date</label>
            <input id="f-date" type="date" :min="Outils.aujourdhui()"
                   x-model="valeurs.date" x-ref="date" x-bind="champValide" data-champ="date">
            <p id="erreur-date" class="erreur" role="alert" x-show="afficherErreur('date')" x-text="afficherErreur('date')"></p>
          </div>

          <div>
            <label for="f-heure">Heure de départ</label>
            <input id="f-heure" type="time"
                   x-model="valeurs.heure" x-ref="heure" x-bind="champValide" data-champ="heure">
            <p id="erreur-heure" class="erreur" role="alert" x-show="afficherErreur('heure')" x-text="afficherErreur('heure')"></p>
          </div>

          <div class="large">
            <label for="f-sortie-n">Sortie</label>
            <select id="f-sortie-n" x-model="valeurs.sortie" x-ref="sortie" x-bind="champValide" data-champ="sortie">
              <template x-for="s in $store.reservations.sorties" :key="s.id">
                <option :value="s.id" :selected="s.id === valeurs.sortie"
                        x-text="s.nom + ' (' + s.duree + ', ' + $ariary(s.tarif) + ' par personne)'"></option>
              </template>
            </select>
            <p id="erreur-sortie" class="erreur" role="alert" x-show="afficherErreur('sortie')" x-text="afficherErreur('sortie')"></p>
          </div>

          <div>
            <!-- maxPersonnes est un getter du composant : il dépend de la sortie choisie -->
            <label for="f-pers">Nombre de personnes (max. <span x-text="maxPersonnes"></span>)</label>
            <input id="f-pers" type="number" min="1" :max="maxPersonnes" inputmode="numeric"
                   x-model.number="valeurs.personnes" x-ref="personnes" x-bind="champValide" data-champ="personnes">
            <p id="erreur-personnes" class="erreur" role="alert" x-show="afficherErreur('personnes')" x-text="afficherErreur('personnes')"></p>
          </div>

          <div class="large">
            <label for="f-note">Note (facultatif)</label>
            <textarea id="f-note" rows="2" x-model="valeurs.note" placeholder="Allergies, enfants, demandes particulières…"></textarea>
          </div>
        </div>

        <p class="prix-estime">Prix estimé : <strong x-text="$ariary(prixEstime)"></strong></p>

        <div class="actions">
          <button type="submit">Enregistrer la réservation</button>
          <button type="button" class="secondaire" @click="reinitialiser()">Vider le formulaire</button>
        </div>
      </form>
    </section>

  </main>

  <footer class="pied">
    Données et prix fictifs, pour apprendre Alpine.js. Sauvegarde dans le navigateur quand c'est possible.
  </footer>
</div>

<!-- ======================================================================
     DIALOGUE DE CONFIRMATION « MAISON »
     On ne peut pas compter sur confirm() : il est bloqué dans certains
     bacs à sable et ne se stylise pas. Notre composant est placé HORS du
     composant « onglets » : il communique avec lui uniquement par
     des événements (@demander-confirmation.window).
     ====================================================================== -->
<!--
  @demander-confirmation.window="ouvrir($event.detail)" :
    - .window : on écoute sur toute la fenêtre, car l'émetteur (la liste)
      n'est pas un enfant de ce composant ;
    - $event.detail : les données envoyées avec $dispatch (titre, message…).
  @keydown.escape.window : la touche Échap ferme le dialogue (s'il est ouvert).
-->
<div x-data="dialogueConfirmation"
     @demander-confirmation.window="ouvrir($event.detail)"
     @keydown.escape.window="ouvert && fermer()">
  <div class="voile" x-show="ouvert" x-cloak x-transition.opacity @click="fermer()"></div>

  <!-- role="alertdialog" + aria-modal : indique une fenêtre modale aux lecteurs d'écran.
       x-transition (sans modificateur) = fondu + léger zoom par défaut.
       @keydown.tab : notre « piège à focus » garde Tab à l'intérieur. -->
  <div class="dialogue" role="alertdialog" aria-modal="true"
       aria-labelledby="dialogue-titre" aria-describedby="dialogue-message"
       x-show="ouvert" x-cloak x-transition x-ref="boite" @keydown.tab="pieger($event)">
    <h2 id="dialogue-titre" x-text="titre"></h2>
    <p id="dialogue-message" x-text="message"></p>
    <div class="actions">
      <!-- x-ref="annuler" : reçoit le focus à l'ouverture -->
      <button type="button" class="secondaire" x-ref="annuler" @click="fermer()">Annuler</button>
      <button type="button" class="danger" @click="confirmer()" x-text="bouton"></button>
    </div>
  </div>
</div>

<!-- ======================================================================
     NOTIFICATIONS (toasts), lues depuis le store « ui »
     x-data (sans valeur) : crée un composant vide, nécessaire pour que les
     directives x-… de ce bloc soient actives.
     aria-live="polite" : les lecteurs d'écran annoncent les nouveaux messages.
     ====================================================================== -->
<div class="toasts" x-data aria-live="polite">
  <template x-for="t in $store.ui.toasts" :key="t.id">
    <div class="toast" :class="'toast-' + t.type" role="status">
      <span x-text="t.texte"></span>
      <button type="button" class="toast-fermer" @click="$store.ui.retirer(t.id)" aria-label="Fermer le message">×</button>
    </div>
  </template>
</div>

</body>
</html>