Annuaire › Code ouvert
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>