◆ 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/store.js · 11.6 Ko

/* ==========================================================================
   js/store.js — L'ÉTAT PARTAGÉ de l'application (Alpine.store)
   --------------------------------------------------------------------------
   Un « store » Alpine est un objet réactif GLOBAL, accessible depuis tous
   les composants de la page, avec $store.nom dans le HTML ou
   this.$store.nom dans le JavaScript.

   On s'en sert quand plusieurs composants (formulaire, liste, statistiques…)
   doivent lire ou modifier les MÊMES données. Sans store, il faudrait faire
   passer l'information de composant en composant.

   Ici, deux stores :
     - « ui »           : les notifications (petits messages temporaires) ;
     - « reservations » : la liste des réservations, les règles de calcul,
                          la validation et la sauvegarde.

   ORDRE DE CHARGEMENT — le piège classique :
   Un store doit être déclaré dans l'événement « alpine:init ». Cet événement
   est émis par Alpine AU DÉMARRAGE, juste avant de lire la page. Notre
   script doit donc être chargé AVANT alpine.min.js, sinon l'événement est
   déjà passé et le store n'existera jamais. Voir index.html.
   ========================================================================== */

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

  /* ------------------------------------------------------------------
     STORE « ui » : notifications
     ------------------------------------------------------------------ */
  Alpine.store('ui', {
    toasts: [],   // liste de { id, texte, type } affichée par le composant notifications

    /**
     * Affiche un message pendant 4 secondes.
     * @param {string} texte  le message
     * @param {string} type   'succes' | 'erreur' | 'info'
     */
    notifier(texte, type = 'info') {
      const id = Outils.nouvelId();
      this.toasts.push({ id, texte, type });
      // setTimeout : exécute la fonction après 4000 ms.
      setTimeout(() => this.retirer(id), 4000);
    },

    /** Retire un message de la liste. */
    retirer(id) {
      this.toasts = this.toasts.filter(t => t.id !== id);
    }
  });

  /* ------------------------------------------------------------------
     STORE « reservations »
     ------------------------------------------------------------------ */
  const CLE = 'reservations-antalaha-v1';   // nom de la sauvegarde

  Alpine.store('reservations', {

    /* ---------- DONNÉES DE RÉFÉRENCE (prix fictifs) ---------- */
    sorties: [
      { id: 'pirogue-baie',     nom: 'Pirogue — tour de la baie',       tarif: 25000,  duree: '2 h', max: 6 },
      { id: 'pirogue-mangrove', nom: 'Pirogue — mangroves et oiseaux',  tarif: 35000,  duree: '3 h', max: 6 },
      { id: 'bateau-peche',     nom: 'Bateau — pêche au large',         tarif: 120000, duree: '5 h', max: 8 },
      { id: 'bateau-plage',     nom: 'Bateau — journée plage déserte',  tarif: 90000,  duree: '7 h', max: 12 }
    ],
    statuts: [
      { id: 'attente',   nom: 'En attente' },
      { id: 'confirmee', nom: 'Confirmée' },
      { id: 'annulee',   nom: 'Annulée' }
    ],

    /* ---------- ÉTAT ---------- */
    liste: [],            // les réservations
    memoireSeule: false,  // true si on ne peut pas sauvegarder durablement

    /* ---------- DÉMARRAGE ----------
       init() d'un store est appelée automatiquement juste après son
       enregistrement. */
    init() {
      this.charger();

      /* Alpine.effect(fonction) : exécute la fonction tout de suite, puis
         la RÉEXÉCUTE chaque fois qu'une donnée réactive lue à l'intérieur
         change. C'est l'équivalent JavaScript de la directive x-effect.
         Ici : JSON.stringify(this.liste) lit TOUTES les réservations ; donc
         dès qu'on ajoute, modifie ou supprime quoi que ce soit, on
         sauvegarde. Aucune méthode n'a besoin d'y penser. */
      Alpine.effect(() => {
        Outils.ecrire(CLE, JSON.stringify(this.liste));
        this.memoireSeule = Outils.enMemoire;
      });
    },

    /* ---------- PERSISTANCE ---------- */

    /** Relit la sauvegarde ; sinon crée des réservations d'exemple. */
    charger() {
      let donnees = null;
      try {
        const brut = Outils.lire(CLE);
        if (brut !== null) donnees = JSON.parse(brut);
      } catch (e) {
        donnees = null;   // JSON abîmé : on repart des exemples
      }
      if (Array.isArray(donnees)) {
        // Ne garde que les réservations qui passent la vérification.
        this.liste = donnees.filter(r => this.estValide(r));
      } else {
        this.liste = this.exemples();
      }
      this.memoireSeule = Outils.enMemoire;
    },

    /** Réservations d'exemple, avec des dates proches d'aujourd'hui. */
    exemples() {
      const fait = (jours, heure, client, tel, sortie, pers, statut, note) =>
        this.creer({ client, telephone: tel, date: Outils.dansJours(jours), heure, sortie, personnes: pers, statut, note });
      return [
        fait(0,  '07:00', 'Hery Rakotoarisoa',  '034 12 345 67', 'bateau-peche',     4, 'confirmee', 'Apportent leur glacière.'),
        fait(1,  '08:30', 'Voahangy Rabe',       '032 98 765 43', 'pirogue-mangrove', 3, 'confirmee', ''),
        fait(2,  '14:00', 'Famille Dupont',      '+33 6 12 34 56 78', 'bateau-plage', 8, 'attente',   'Deux enfants de 6 ans : gilets.'),
        fait(3,  '06:30', 'Jean Andrianina',     '033 22 111 00', 'pirogue-baie',     2, 'attente',   ''),
        fait(5,  '09:00', 'Lalaina Ravelo',      '034 55 666 77', 'pirogue-baie',     5, 'confirmee', 'Anniversaire.'),
        fait(8,  '07:30', 'Association Vanille', '032 44 555 66', 'bateau-plage',    12, 'confirmee', 'Sortie de fin de récolte.'),
        fait(-2, '08:00', 'Tojo Randria',        '033 77 888 99', 'pirogue-mangrove', 2, 'confirmee', ''),
        fait(4,  '10:00', 'Mialy Razafy',        '034 00 123 45', 'bateau-peche',     3, 'annulee',   'Annulé : mauvais temps prévu.')
      ];
    },

    /* ---------- OUTILS ---------- */

    /** Retrouve la sortie (type d'excursion) à partir de son id. */
    sortie(id) {
      return this.sorties.find(s => s.id === id);
    },

    /** Nom lisible d'un statut. */
    nomStatut(id) {
      const s = this.statuts.find(x => x.id === id);
      return s ? s.nom : id;
    },

    /** Prix = tarif par personne × nombre de personnes. */
    prix(sortieId, personnes) {
      const s = this.sortie(sortieId);
      return s ? s.tarif * (Number(personnes) || 0) : 0;
    },

    /** Vérifie la forme d'une réservation lue dans la sauvegarde. */
    estValide(r) {
      return !!r && typeof r.id === 'string' && !!this.sortie(r.sortie) &&
        Outils.dateValide(r.date) && Number.isInteger(r.personnes) && r.personnes > 0 &&
        this.statuts.some(s => s.id === r.statut) && typeof r.client === 'string';
    },

    /** Fabrique une réservation complète (id, prix) à partir de champs. */
    creer(d) {
      return {
        id: Outils.nouvelId(),
        client: String(d.client).trim(),
        telephone: String(d.telephone).trim(),
        date: d.date,
        heure: d.heure,
        sortie: d.sortie,
        personnes: Number(d.personnes),
        statut: d.statut || 'attente',
        note: String(d.note || '').trim(),
        prix: this.prix(d.sortie, d.personnes)
      };
    },

    /**
     * VALIDATION partagée par le formulaire d'ajout ET par la modification
     * en ligne : une seule règle, écrite une seule fois.
     * Retourne un objet { champ: 'message' } ; objet VIDE = tout est correct.
     * @param {object} d        les valeurs à vérifier
     * @param {object} options  { nouvelle: true } => la date ne peut pas être passée
     */
    valider(d, options = {}) {
      const erreurs = {};
      const client = String(d.client || '').trim();
      if (client.length < 2) erreurs.client = 'Indiquez le nom du client (2 lettres minimum).';

      const tel = String(d.telephone || '').trim();
      if (!/^\+?[0-9 ]{8,16}$/.test(tel)) erreurs.telephone = 'Numéro invalide (ex. : 034 12 345 67).';

      if (!Outils.dateValide(d.date)) erreurs.date = 'Choisissez une date valide.';
      else if (options.nouvelle && d.date < Outils.aujourdhui()) erreurs.date = 'La date ne peut pas être dans le passé.';

      if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(d.heure || ''))) erreurs.heure = 'Choisissez une heure de départ.';

      const sortie = this.sortie(d.sortie);
      if (!sortie) erreurs.sortie = 'Choisissez une sortie.';

      const n = Number(d.personnes);
      if (!Number.isInteger(n) || n < 1) erreurs.personnes = 'Au moins 1 personne.';
      else if (sortie && n > sortie.max) erreurs.personnes = 'Maximum ' + sortie.max + ' personnes pour cette sortie.';

      return erreurs;
    },

    /* ---------- ACTIONS (créer / modifier / supprimer) ---------- */

    /** Ajoute une réservation à la liste et la retourne. */
    ajouter(donnees) {
      const r = this.creer(donnees);
      this.liste.push(r);
      return r;
    },

    /** Modifie certains champs d'une réservation (le prix est recalculé). */
    modifier(id, changements) {
      const r = this.liste.find(x => x.id === id);
      if (!r) return;
      Object.assign(r, changements);          // copie les champs modifiés dans r
      r.personnes = Number(r.personnes);
      r.prix = this.prix(r.sortie, r.personnes);
    },

    /** Supprime définitivement une réservation. */
    supprimer(id) {
      this.liste = this.liste.filter(r => r.id !== id);
    },

    /** Remet les exemples de départ (efface tout le reste). */
    reinitialiser() {
      this.liste = this.exemples();
    },

    /* ---------- STATISTIQUES : getters calculés ---------- */

    /** Nombre de réservations pour un statut donné. */
    compter(statut) {
      return this.liste.filter(r => r.statut === statut).length;
    },
    get enAttente() { return this.compter('attente'); },
    get confirmees() { return this.compter('confirmee'); },
    get annulees() { return this.compter('annulee'); },

    /** Réservations non annulées dont la date n'est pas passée. */
    get aVenir() {
      const auj = Outils.aujourdhui();
      return this.liste.filter(r => r.statut !== 'annulee' && r.date >= auj);
    },

    /** Nombre de personnes attendues dans les sorties à venir. */
    get personnesAVenir() {
      return this.aVenir.reduce((somme, r) => somme + r.personnes, 0);
    },

    /** Chiffre d'affaires : somme des réservations CONFIRMÉES. */
    get chiffreAffaires() {
      return this.liste.filter(r => r.statut === 'confirmee').reduce((s, r) => s + r.prix, 0);
    },

    /** Montant encore « à confirmer » (en attente). */
    get chiffreEnAttente() {
      return this.liste.filter(r => r.statut === 'attente').reduce((s, r) => s + r.prix, 0);
    },

    /** La prochaine sortie confirmée (ou undefined). */
    get prochaine() {
      return this.aVenir
        .filter(r => r.statut === 'confirmee')
        .sort((a, b) => (a.date + a.heure).localeCompare(b.date + b.heure))[0];
    },

    /** Répartition des réservations non annulées par type de sortie. */
    get repartition() {
      const actives = this.liste.filter(r => r.statut !== 'annulee');
      const maxi = Math.max(1, ...this.sorties.map(s => actives.filter(r => r.sortie === s.id).length));
      return this.sorties.map(s => {
        const nombre = actives.filter(r => r.sortie === s.id).length;
        return { id: s.id, nom: s.nom, nombre, pourcent: Math.round(nombre / maxi * 100) };
      });
    }
  });
});