◆ 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.

js/composants.js · 14.0 Ko

/* ==========================================================================
   js/composants.js — Les COMPOSANTS réutilisables (Alpine.data) et les
   objets x-bind réutilisables (Alpine.bind)
   --------------------------------------------------------------------------
   Alpine.data('nom', () => ({ ... })) enregistre un composant sous un nom.
   Dans le HTML on écrit ensuite  x-data="nom"  (ou  x-data="nom()"  pour
   passer des paramètres). Avantage sur un gros objet écrit dans l'attribut
   x-data : le HTML reste propre et le code est réutilisable et testable.

   Alpine.bind('nom', () => ({ ... })) enregistre un groupe d'attributs
   Alpine (x-..., @..., :...) qu'on applique d'un coup avec x-bind="nom".

   Composants définis ici :
     onglets                 la navigation par onglets (racine de la page)
     formulaireReservation   ajout avec validation
     listeReservations       liste filtrable, triable, modification en ligne
     dialogueConfirmation    boîte de dialogue « maison » (remplace confirm())
   (les statistiques n'ont pas besoin de composant : elles lisent $store)

   ORDRE DE CHARGEMENT : avant alpine.min.js, après outils.js et store.js.
   ========================================================================== */

document.addEventListener('alpine:init', () => {

  /* ====================================================================
     x-bind RÉUTILISABLES
     ==================================================================== */

  /* « boutonOnglet » : tout ce qu'il faut pour qu'un <button> soit un onglet
     accessible. Il se sert de l'attribut data-onglet="..." du bouton pour
     savoir QUEL onglet il représente (this.$el = l'élément courant).
     On le règle ainsi UNE fois pour les trois onglets. */
  Alpine.bind('boutonOnglet', () => ({
    type: 'button',          // attribut HTML statique
    role: 'tab',
    // Les clés qui commencent par ':' ou '@' sont dynamiques : la valeur
    // est une fonction, évaluée dans le composant parent (this = ses données).
    [':aria-selected']() { return this.onglet === this.$el.dataset.onglet; },
    [':tabindex']() { return this.onglet === this.$el.dataset.onglet ? 0 : -1; },
    [':class']() { return { actif: this.onglet === this.$el.dataset.onglet }; },
    ['@click']() { this.aller(this.$el.dataset.onglet); },
    // Flèches gauche/droite : on passe à l'onglet voisin (convention ARIA).
    ['@keydown.right.prevent']() { this.suivant(1); },
    ['@keydown.left.prevent']() { this.suivant(-1); }
  }));

  /* « champValide » : comportement commun des champs de formulaire.
     Le champ s'identifie avec data-champ="client", "telephone", etc.
     Le composant qui l'utilise doit fournir : touche (objet) et
     afficherErreur(nomDuChamp). */
  Alpine.bind('champValide', () => ({
    // Quand on quitte le champ, on le marque « touché » : l'erreur peut alors s'afficher.
    ['@blur']() { this.touche[this.$el.dataset.champ] = true; },
    [':aria-invalid']() { return this.afficherErreur(this.$el.dataset.champ) !== ''; },
    [':aria-describedby']() { return 'erreur-' + this.$el.dataset.champ; },
    [':class']() { return { invalide: this.afficherErreur(this.$el.dataset.champ) !== '' }; }
  }));

  /* ====================================================================
     COMPOSANT « onglets » : racine de la page
     ==================================================================== */
  Alpine.data('onglets', () => ({
    onglet: 'tableau',                          // onglet affiché
    ordre: ['tableau', 'liste', 'nouvelle'],    // ordre pour les flèches du clavier

    /** Affiche un onglet (et, au clavier, déplace aussi le focus dessus). */
    aller(id, focaliser = false) {
      this.onglet = id;
      if (focaliser) {
        // $nextTick : on attend que le DOM soit mis à jour (tabindex changé)
        // avant de donner le focus. $root = l'élément qui porte x-data.
        this.$nextTick(() => {
          const bouton = this.$root.querySelector('[data-onglet="' + id + '"]');
          if (bouton) bouton.focus();
        });
      }
    },

    /** Onglet suivant (+1) ou précédent (-1), en boucle. */
    suivant(delta) {
      const n = this.ordre.length;
      const i = this.ordre.indexOf(this.onglet);
      this.aller(this.ordre[(i + delta + n) % n], true);
    },

    /** Appelée quand le formulaire annonce « reservation-ajoutee ». */
    apresAjout() {
      this.aller('liste');
    }
  }));

  /* ====================================================================
     COMPOSANT « formulaireReservation »
     ==================================================================== */
  /** Valeurs de départ d'un formulaire vide. */
  const formulaireVide = () => ({
    client: '', telephone: '', date: Outils.aujourdhui(), heure: '08:00',
    sortie: 'pirogue-baie', personnes: 2, note: ''
  });

  Alpine.data('formulaireReservation', () => ({
    valeurs: formulaireVide(),   // ce que l'utilisateur saisit
    touche: {},                  // champs déjà « visités » (pour montrer l'erreur au bon moment)
    envoye: false,               // a-t-on déjà essayé d'envoyer ?

    init() {
      /* $watch : réagit au changement d'UNE propriété. Si on change de
         sortie et que le nombre de personnes dépasse le maximum, on le
         ramène au maximum (une pirogue ne porte pas 12 personnes). */
      this.$watch('valeurs.sortie', () => {
        if (this.valeurs.personnes > this.maxPersonnes) this.valeurs.personnes = this.maxPersonnes;
      });
    },

    /** La sortie choisie (objet), ou undefined. */
    get sortieChoisie() {
      return this.$store.reservations.sortie(this.valeurs.sortie);
    },

    /** Nombre maximum de personnes pour la sortie choisie. */
    get maxPersonnes() {
      return this.sortieChoisie ? this.sortieChoisie.max : 1;
    },

    /** Toutes les erreurs actuelles (règles dans le store : valider()). */
    get erreurs() {
      return this.$store.reservations.valider(this.valeurs, { nouvelle: true });
    },

    /** Prix estimé affiché sous le formulaire. */
    get prixEstime() {
      return this.$store.reservations.prix(this.valeurs.sortie, this.valeurs.personnes);
    },

    /** Message d'erreur à AFFICHER pour un champ ('' = rien à montrer). */
    afficherErreur(champ) {
      // On n'affiche l'erreur que si le champ a été visité ou si on a tenté d'envoyer.
      return (this.touche[champ] || this.envoye) ? (this.erreurs[champ] || '') : '';
    },

    /** Soumission : valide, ajoute au store, prévient les autres composants. */
    soumettre() {
      this.envoye = true;
      const premier = Object.keys(this.erreurs)[0];   // premier champ en erreur
      if (premier) {
        // $refs[premier] : élément portant x-ref="client", "date"…
        this.$nextTick(() => { if (this.$refs[premier]) this.$refs[premier].focus(); });
        this.$store.ui.notifier('Corrigez les champs signalés en rouge.', 'erreur');
        return;
      }
      const r = this.$store.reservations.ajouter(this.valeurs);
      this.$store.ui.notifier('Réservation de ' + r.client + ' ajoutée.', 'succes');
      /* $dispatch : émet un événement personnalisé qui REMONTE les parents.
         Le composant « onglets » l'écoute (@reservation-ajoutee) et affiche
         la liste. Le formulaire ne connaît pas les onglets : ils sont
         découplés, et c'est tout l'intérêt des événements. */
      this.$dispatch('reservation-ajoutee', { id: r.id });
      this.reinitialiser();
    },

    /** Vide le formulaire. */
    reinitialiser() {
      this.valeurs = formulaireVide();
      this.touche = {};
      this.envoye = false;
    }
  }));

  /* ====================================================================
     COMPOSANT « listeReservations »
     ==================================================================== */
  Alpine.data('listeReservations', () => ({
    recherche: '',
    filtreStatut: 'tous',
    filtreSortie: 'toutes',
    tri: 'date-asc',
    enEdition: null,        // id de la réservation en cours de modification (ou null)
    brouillon: {},          // copie de travail pendant la modification
    erreursEdition: {},

    /** Réservations filtrées puis triées. */
    get resultats() {
      const norm = (t) => String(t).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
      const q = norm(this.recherche.trim());

      const liste = this.$store.reservations.liste.filter(r =>
        (this.filtreStatut === 'tous' || r.statut === this.filtreStatut) &&
        (this.filtreSortie === 'toutes' || r.sortie === this.filtreSortie) &&
        (q === '' || norm(r.client + ' ' + r.telephone + ' ' + r.note).includes(q))
      );

      // Comparateurs de tri : retournent un nombre négatif, nul ou positif.
      const comparateurs = {
        'date-asc':  (a, b) => (a.date + a.heure).localeCompare(b.date + b.heure),
        'date-desc': (a, b) => (b.date + b.heure).localeCompare(a.date + a.heure),
        'client':    (a, b) => a.client.localeCompare(b.client, 'fr'),
        'montant':   (a, b) => b.prix - a.prix
      };
      return liste.sort(comparateurs[this.tri] || comparateurs['date-asc']);
    },

    /** Vrai si la sortie a déjà eu lieu. */
    estPassee(r) {
      return r.date < Outils.aujourdhui();
    },

    /** Efface tous les filtres. */
    effacerFiltres() {
      this.recherche = '';
      this.filtreStatut = 'tous';
      this.filtreSortie = 'toutes';
    },

    /** Changement rapide de statut (boutons Confirmer / Annuler). */
    changerStatut(r, statut) {
      this.$store.reservations.modifier(r.id, { statut });
      this.$store.ui.notifier('Statut de ' + r.client + ' : ' + this.$store.reservations.nomStatut(statut).toLowerCase() + '.', 'info');
    },

    /* ----- Modification en ligne ----- */

    /** Passe une ligne en mode édition. */
    modifier(r) {
      this.enEdition = r.id;
      this.brouillon = { ...r };      // « ... » copie l'objet : on ne touche pas à l'original
      this.erreursEdition = {};
      // On attend que le formulaire de la ligne existe, puis on met le focus.
      this.$nextTick(() => { if (this.$refs.editDate) this.$refs.editDate.focus(); });
    },

    /** Quitte le mode édition sans rien enregistrer. */
    annulerEdition() {
      this.enEdition = null;
      this.brouillon = {};
      this.erreursEdition = {};
    },

    /** Valide puis enregistre la modification. */
    enregistrerEdition() {
      const erreurs = this.$store.reservations.valider(this.brouillon);
      this.erreursEdition = erreurs;
      if (Object.keys(erreurs).length > 0) return;     // on reste en édition

      this.$store.reservations.modifier(this.enEdition, {
        date: this.brouillon.date, heure: this.brouillon.heure, sortie: this.brouillon.sortie,
        personnes: this.brouillon.personnes, statut: this.brouillon.statut
      });
      this.$store.ui.notifier('Réservation modifiée.', 'succes');
      this.annulerEdition();
    },

    /** Maximum de personnes pour la sortie du brouillon. */
    get maxBrouillon() {
      const s = this.$store.reservations.sortie(this.brouillon.sortie);
      return s ? s.max : 1;
    },

    /* ----- Suppression avec confirmation ----- */

    /** Ne supprime PAS tout de suite : demande confirmation au dialogue maison. */
    demanderSuppression(r) {
      this.$dispatch('demander-confirmation', {
        titre: 'Supprimer cette réservation ?',
        message: 'La réservation de « ' + r.client + ' » du ' + Outils.dateLisible(r.date) + ' sera supprimée définitivement.',
        bouton: 'Supprimer',
        // On transmet une FONCTION : le dialogue l'appellera si l'utilisateur confirme.
        surConfirmation: () => {
          this.$store.reservations.supprimer(r.id);
          this.$store.ui.notifier('Réservation supprimée.', 'info');
        }
      });
    }
  }));

  /* ====================================================================
     COMPOSANT « dialogueConfirmation » : boîte de dialogue maison
     (les fenêtres natives alert()/confirm() sont bloquées ou laides)
     ==================================================================== */
  Alpine.data('dialogueConfirmation', () => ({
    ouvert: false,
    titre: '',
    message: '',
    bouton: 'Confirmer',
    action: null,          // fonction à appeler si on confirme
    precedent: null,       // élément qui avait le focus avant l'ouverture

    /** Ouvre le dialogue avec les infos reçues dans l'événement. */
    ouvrir(detail) {
      this.titre = detail.titre || 'Confirmer ?';
      this.message = detail.message || '';
      this.bouton = detail.bouton || 'Confirmer';
      this.action = detail.surConfirmation || null;
      this.precedent = document.activeElement;       // on s'en souvient pour rendre le focus
      this.ouvert = true;
      // Par prudence, le focus initial va sur « Annuler » (action la moins risquée).
      this.$nextTick(() => this.$refs.annuler.focus());
    },

    /** L'utilisateur confirme. */
    confirmer() {
      const action = this.action;
      this.fermer();
      if (typeof action === 'function') action();
    },

    /** Ferme et rend le focus. */
    fermer() {
      this.ouvert = false;
      this.action = null;
      const cible = this.precedent && this.precedent.isConnected
        ? this.precedent                                   // le bouton existe encore
        : document.getElementById('titre-liste');          // sinon (ligne supprimée) : le titre de la liste
      if (cible && cible.focus) cible.focus();
      this.precedent = null;
    },

    /** « Piège à focus » : Tab reste à l'intérieur du dialogue. */
    pieger(evenement) {
      const boutons = Array.from(this.$refs.boite.querySelectorAll('button'));
      const premier = boutons[0];
      const dernier = boutons[boutons.length - 1];
      if (evenement.shiftKey && document.activeElement === premier) {
        evenement.preventDefault();
        dernier.focus();
      } else if (!evenement.shiftKey && document.activeElement === dernier) {
        evenement.preventDefault();
        premier.focus();
      }
    }
  }));
});