◆ Portail Antalaha

Annuaire › Code ouvert

Carnet de caisse du marché d'Antalaha

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/app.js · 16.6 Ko

/* ============================================================
   Fichier : js/app.js
   Rôle    : logique complète du carnet de caisse.
   Dépend de : js/stockage.js (objet global « Stockage »),
               index.html (les id cités dans la section 3).
   ------------------------------------------------------------
   Idée centrale : UNE source de vérité.
   Les ventes vivent dans un seul tableau (« ventes »). Chaque fois
   qu'il change, on appelle toutAfficher() qui redessine TOUT l'écran
   à partir de ce tableau. On ne modifie jamais l'affichage « à la
   main » en cherchant quoi changer : c'est plus simple et sans oubli.

   Plan :
     1. Données et constantes
     2. Fonctions de calcul (map / filter / reduce)
     3. Accès aux éléments de la page
     4. Fonctions d'affichage
     5. Actions (ajouter, supprimer, vider, copier)
     6. Événements (dont la délégation)
     7. Démarrage
   ============================================================ */

"use strict";

/* ============================================================
   1. DONNÉES ET CONSTANTES
   ============================================================ */

/** Clé de sauvegarde dans le stockage. */
const CLE_CARNET = "caisse-marche-ventes";

/**
 * Le carnet : un tableau d'objets « vente ».
 * Une vente ressemble à : { id: 3, produit: "Vanille", quantite: 2, prix: 15000 }
 *  - id       : numéro unique (sert à retrouver la vente à supprimer)
 *  - produit  : nom du produit (texte)
 *  - quantite : nombre d'unités vendues (entier > 0)
 *  - prix     : prix d'UNE unité en Ariary (entier >= 0)
 */
let ventes = [];

/** Prochain numéro d'identifiant à attribuer. */
let prochainId = 1;

/** Formateur de nombres à la française (créé une fois, réutilisé : plus efficace). */
const formatNombre = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 });

/* ============================================================
   2. FONCTIONS DE CALCUL
   ============================================================ */

/**
 * Formate un montant en Ariary.
 * @param {number} valeur - le montant
 * @returns {string} par exemple « 15 000 Ar »
 */
function formaterAriary(valeur) {
  return formatNombre.format(valeur) + " Ar";
}

/**
 * Calcule le montant d'une vente (quantité × prix).
 * @param {{quantite:number, prix:number}} vente - la vente
 * @returns {number} le montant
 */
function montantVente(vente) {
  return vente.quantite * vente.prix;
}

/**
 * Calcule le total de toutes les ventes.
 * reduce() « réduit » un tableau à une seule valeur : ici, une somme.
 * Il part de 0 (2e argument) puis ajoute chaque montant à l'accumulateur.
 * @param {Array} liste - les ventes
 * @returns {number} la somme des montants
 */
function totalDesVentes(liste) {
  return liste.reduce(function (somme, vente) {
    return somme + montantVente(vente);
  }, 0);
}

/**
 * Regroupe les ventes par produit (sans tenir compte des majuscules).
 * On construit d'abord un objet { "vanille": {...}, "riz": {...} } avec reduce,
 * puis on le transforme en tableau trié avec Object.values().sort().
 * @param {Array} liste - les ventes
 * @returns {Array<{produit:string, quantite:number, montant:number}>} un élément par produit
 */
function regrouperParProduit(liste) {
  const groupes = liste.reduce(function (acc, vente) {
    const cle = vente.produit.toLowerCase(); // « Riz » et « riz » = même produit
    if (!acc[cle]) {
      // Première fois qu'on voit ce produit : on crée son groupe
      acc[cle] = { produit: vente.produit, quantite: 0, montant: 0 };
    }
    acc[cle].quantite += vente.quantite;
    acc[cle].montant += montantVente(vente);
    return acc; // IMPORTANT : reduce doit toujours renvoyer l'accumulateur
  }, {});

  // Du plus gros montant au plus petit
  return Object.values(groupes).sort(function (a, b) {
    return b.montant - a.montant;
  });
}

/**
 * Lit un entier positif dans un texte (espaces tolérés : « 2 500 »).
 * @param {string} texte - le texte saisi
 * @returns {number|null} l'entier, ou null si invalide
 */
function lireEntier(texte) {
  const propre = texte.replace(/[\s  ]/g, "");
  if (!/^\d+$/.test(propre)) { return null; }
  return Number(propre);
}

/**
 * Construit le texte d'export du carnet (à copier-coller).
 * @returns {string} le texte, sur plusieurs lignes
 */
function construireExport() {
  if (ventes.length === 0) { return "Carnet vide."; }

  const lignes = [];
  lignes.push("Carnet de caisse – " + new Date().toLocaleDateString("fr-FR"));
  lignes.push("");
  // map() transforme chaque vente en une ligne de texte
  ventes.map(function (v) {
    return "- " + v.produit + " : " + v.quantite + " x " + formaterAriary(v.prix) +
           " = " + formaterAriary(montantVente(v));
  }).forEach(function (ligne) { lignes.push(ligne); });

  lignes.push("");
  lignes.push("Par produit :");
  regrouperParProduit(ventes).forEach(function (g) {
    lignes.push("* " + g.produit + " : " + g.quantite + " unité(s), " + formaterAriary(g.montant));
  });
  lignes.push("");
  lignes.push("TOTAL DU JOUR : " + formaterAriary(totalDesVentes(ventes)));
  return lignes.join("\n"); // join assemble les lignes avec un retour à la ligne
}

/* ============================================================
   3. ACCÈS AUX ÉLÉMENTS DE LA PAGE
   ============================================================ */

const elFormulaire   = document.getElementById("formulaire-vente");
const elProduit      = document.getElementById("produit");
const elQuantite     = document.getElementById("quantite");
const elPrix         = document.getElementById("prix");
const elErreur       = document.getElementById("erreur-vente");
const elCorpsVentes  = document.getElementById("corps-ventes");
const elMessageVide  = document.getElementById("message-vide");
const elTableauVentes = document.getElementById("tableau-ventes");
const elTotal        = document.getElementById("total-jour");
const elNbVentes     = document.getElementById("nb-ventes");
const elCorpsGroupes = document.getElementById("corps-groupes");
const elExport       = document.getElementById("export-texte");
const elBoutonCopier = document.getElementById("bouton-copier");
const elBoutonVider  = document.getElementById("bouton-vider");
const elStatutCopie  = document.getElementById("statut-copie");
const elStatutStock  = document.getElementById("statut-stockage");
const elDate         = document.getElementById("date-jour");

/* ============================================================
   4. FONCTIONS D'AFFICHAGE
   ============================================================ */

/**
 * Crée une cellule de tableau <td> avec du texte.
 * @param {string} texte        - le contenu
 * @param {string} [classe]     - classe CSS facultative (ex. « nombre »)
 * @returns {HTMLTableCellElement} la cellule
 */
function creerCellule(texte, classe) {
  const td = document.createElement("td");
  td.textContent = texte; // textContent : jamais interprété comme du HTML (sécurité)
  if (classe) { td.className = classe; }
  return td;
}

/**
 * Redessine le tableau des ventes.
 * @returns {void}
 */
function afficherVentes() {
  elCorpsVentes.textContent = ""; // on vide tout
  ventes.forEach(function (vente) {
    const tr = document.createElement("tr");
    tr.appendChild(creerCellule(vente.produit));
    tr.appendChild(creerCellule(String(vente.quantite), "nombre"));
    tr.appendChild(creerCellule(formaterAriary(vente.prix), "nombre"));
    tr.appendChild(creerCellule(formaterAriary(montantVente(vente)), "nombre"));

    // Cellule du bouton « Supprimer ».
    const tdAction = document.createElement("td");
    const bouton = document.createElement("button");
    bouton.type = "button";
    bouton.className = "bouton bouton--danger bouton--petit";
    bouton.textContent = "Supprimer";
    bouton.dataset.action = "supprimer";   // -> attribut HTML data-action="supprimer"
    bouton.dataset.id = String(vente.id);  // -> data-id="3" : relie le bouton à sa vente
    bouton.setAttribute("aria-label", "Supprimer la vente de " + vente.produit);
    tdAction.appendChild(bouton);
    tr.appendChild(tdAction);

    elCorpsVentes.appendChild(tr);
  });

  // Tableau caché s'il est vide, message affiché à la place
  const vide = ventes.length === 0;
  elTableauVentes.hidden = vide;
  elMessageVide.hidden = !vide;
}

/**
 * Met à jour le total du jour et le nombre de ventes.
 * @returns {void}
 */
function afficherTotal() {
  elTotal.textContent = formaterAriary(totalDesVentes(ventes));
  elNbVentes.textContent = ventes.length + (ventes.length > 1 ? " ventes" : " vente");
}

/**
 * Redessine le tableau « Total par produit ».
 * @returns {void}
 */
function afficherGroupes() {
  elCorpsGroupes.textContent = "";
  const groupes = regrouperParProduit(ventes);
  if (groupes.length === 0) {
    const tr = document.createElement("tr");
    const td = creerCellule("Rien à regrouper pour l'instant.");
    td.colSpan = 3; // la cellule s'étend sur les 3 colonnes
    tr.appendChild(td);
    elCorpsGroupes.appendChild(tr);
    return;
  }
  groupes.forEach(function (g) {
    const tr = document.createElement("tr");
    tr.appendChild(creerCellule(g.produit));
    tr.appendChild(creerCellule(String(g.quantite), "nombre"));
    tr.appendChild(creerCellule(formaterAriary(g.montant), "nombre"));
    elCorpsGroupes.appendChild(tr);
  });
}

/**
 * Indique à l'utilisateur où sont gardées ses données.
 * @returns {void}
 */
function afficherStatutStockage() {
  if (Stockage.mode() === "navigateur") {
    elStatutStock.textContent = "Le carnet est sauvegardé dans ce navigateur.";
  } else {
    elStatutStock.textContent =
      "Sauvegarde indisponible ici : le carnet reste en mémoire et sera perdu si vous fermez la page. " +
      "Pensez à copier l'export texte.";
  }
}

/**
 * Redessine tout l'écran à partir du tableau « ventes ».
 * @returns {void}
 */
function toutAfficher() {
  afficherVentes();
  afficherTotal();
  afficherGroupes();
  elExport.value = construireExport();
  afficherStatutStockage();
}

/* ============================================================
   5. ACTIONS
   ============================================================ */

/**
 * Enregistre le carnet (tableau + prochain id) dans le stockage.
 * JSON.stringify transforme le tableau d'objets en texte, car le
 * stockage ne conserve que du texte.
 * @returns {void}
 */
function sauvegarder() {
  Stockage.ecrire(CLE_CARNET, JSON.stringify({ ventes: ventes, prochainId: prochainId }));
}

/**
 * Relit le carnet sauvegardé. Vérifie chaque vente : une donnée
 * abîmée ne doit jamais faire planter la page.
 * @returns {void}
 */
function charger() {
  const texte = Stockage.lire(CLE_CARNET);
  if (!texte) { return; }
  try {
    const donnees = JSON.parse(texte);
    if (!donnees || !Array.isArray(donnees.ventes)) { return; }
    // filter() garde seulement les éléments pour lesquels la fonction renvoie true
    ventes = donnees.ventes.filter(function (v) {
      return v && typeof v.produit === "string" &&
             Number.isInteger(v.quantite) && v.quantite > 0 &&
             Number.isInteger(v.prix) && v.prix >= 0 &&
             Number.isInteger(v.id);
    });
    // Le prochain id doit dépasser tous les id existants
    const maxId = ventes.reduce(function (m, v) { return Math.max(m, v.id); }, 0);
    prochainId = Math.max(Number(donnees.prochainId) || 1, maxId + 1);
  } catch (erreur) {
    ventes = []; // texte illisible : on repart d'un carnet vide
  }
}

/**
 * Vérifie le formulaire et ajoute la vente si tout est correct.
 * @returns {void}
 */
function ajouterVente() {
  const produit  = elProduit.value.trim(); // trim() retire les espaces autour
  const quantite = lireEntier(elQuantite.value);
  const prix     = lireEntier(elPrix.value);

  // On réinitialise les marques d'erreur
  [elProduit, elQuantite, elPrix].forEach(function (c) { c.classList.remove("invalide"); });

  // On prépare la liste des problèmes trouvés
  const problemes = [];
  if (produit === "")                       { problemes.push("le produit"); elProduit.classList.add("invalide"); }
  if (quantite === null || quantite < 1)    { problemes.push("la quantité (entier, au moins 1)"); elQuantite.classList.add("invalide"); }
  if (prix === null)                        { problemes.push("le prix (nombre entier d'Ariary)"); elPrix.classList.add("invalide"); }

  if (problemes.length > 0) {
    elErreur.textContent = "Vérifiez : " + problemes.join(", ") + ".";
    // Met le curseur sur le premier champ fautif
    const premier = [elProduit, elQuantite, elPrix].find(function (c) {
      return c.classList.contains("invalide");
    });
    premier.focus();
    return;
  }

  elErreur.textContent = "";
  ventes.push({ id: prochainId, produit: produit, quantite: quantite, prix: prix });
  prochainId += 1;
  sauvegarder();
  toutAfficher();

  // Prêt pour la vente suivante
  elProduit.value = "";
  elQuantite.value = "1";
  elPrix.value = "";
  elProduit.focus();
}

/**
 * Supprime la vente portant l'identifiant donné.
 * On utilise filter() pour fabriquer un NOUVEAU tableau sans cette vente.
 * @param {number} id - identifiant de la vente à retirer
 * @returns {void}
 */
function supprimerVente(id) {
  ventes = ventes.filter(function (vente) { return vente.id !== id; });
  sauvegarder();
  toutAfficher();
  elProduit.focus(); // le bouton cliqué a disparu : on replace le focus clavier ailleurs
}

/** Vrai quand le bouton « Vider » attend une 2e confirmation. */
let confirmationViderEnCours = false;

/**
 * Vide le carnet, en deux temps (premier clic : demande ; deuxième clic : exécute).
 * On évite confirm() qui est désagréable et parfois bloqué.
 * @returns {void}
 */
function demanderViderCarnet() {
  if (!confirmationViderEnCours) {
    confirmationViderEnCours = true;
    elBoutonVider.textContent = "Confirmer : tout effacer ?";
    // Si l'utilisateur ne confirme pas dans les 4 secondes, on annule.
    setTimeout(function () {
      confirmationViderEnCours = false;
      elBoutonVider.textContent = "Vider le carnet";
    }, 4000);
    return;
  }
  confirmationViderEnCours = false;
  elBoutonVider.textContent = "Vider le carnet";
  ventes = [];
  sauvegarder();
  toutAfficher();
}

/**
 * Copie le texte d'export. Essaie l'API moderne, sinon sélectionne le texte.
 * Piège : dans le bac à sable, l'accès au presse-papiers peut être refusé.
 * @returns {void}
 */
function copierExport() {
  const texte = elExport.value;

  /** Plan B : on sélectionne le texte pour que l'utilisateur fasse Ctrl+C. */
  function planB() {
    elExport.focus();
    elExport.select();
    let reussi = false;
    try { reussi = document.execCommand("copy"); } catch (e) { reussi = false; }
    elStatutCopie.textContent = reussi
      ? "Texte copié."
      : "Texte sélectionné : faites Ctrl+C (ou un appui long sur mobile) pour le copier.";
  }

  if (navigator.clipboard && navigator.clipboard.writeText) {
    // writeText renvoie une « promesse » : .then si ça marche, .catch sinon
    navigator.clipboard.writeText(texte).then(function () {
      elStatutCopie.textContent = "Texte copié.";
    }).catch(planB);
  } else {
    planB();
  }
}

/* ============================================================
   6. ÉVÉNEMENTS
   ============================================================ */

// Envoi du formulaire (bouton ou touche Entrée)
elFormulaire.addEventListener("submit", function (evenement) {
  evenement.preventDefault(); // empêche le rechargement de la page
  ajouterVente();
});

/* DÉLÉGATION D'ÉVÉNEMENTS
   Les boutons « Supprimer » sont créés et détruits à chaque affichage.
   Plutôt que d'attacher un écouteur à chacun (il faudrait le refaire à
   chaque redessin), on place UN SEUL écouteur sur le parent (le <tbody>
   qui existe depuis le début). Un clic sur un enfant « remonte » vers
   ses parents (c'est le « bouillonnement » ou bubbling) : le parent
   peut donc l'intercepter, et evenement.target indique l'élément cliqué. */
elCorpsVentes.addEventListener("click", function (evenement) {
  // closest() remonte depuis l'élément cliqué jusqu'au premier ancêtre qui correspond
  const bouton = evenement.target.closest("button[data-action='supprimer']");
  if (!bouton) { return; } // le clic n'était pas sur un bouton Supprimer
  supprimerVente(Number(bouton.dataset.id)); // dataset renvoie du texte -> on convertit en nombre
});

elBoutonCopier.addEventListener("click", copierExport);
elBoutonVider.addEventListener("click", demanderViderCarnet);

/* ============================================================
   7. DÉMARRAGE
   ============================================================ */

elDate.textContent = new Date().toLocaleDateString("fr-FR", {
  weekday: "long", day: "numeric", month: "long"
});
charger();
toutAfficher();