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.
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();