Gestion de stock de tee-shirts avec Retool + Firebase (Firestore)

Objectif : construire une app d’admin en Retool connectée à Firestore pour gérer un catalogue de tee-shirts et des promotions (globales, par marque, ou par produit), avec une UI plaisante (header + pages).
Livrable : app Retool fonctionnelle + mini-rapport (captures et explications) + barème en fin de document.

0) Pré-requis

1) Modèle de données Firestore

Collections et champs

teeshirts (collection)

promos (collection)

Exemples de documents

teeshirts/

{
  "nom": "Classic Tee",
  "marque": "AlphaWear",
  "taille": "M",
  "description": "Coton bio 180g",
  "couleurs": ["noir", "blanc"],
  "stock": 25,
  "prix": 19.9
}

promos/

{
  "nom": "Rentrée",
  "reduction_percent": 15,
  "date_debut": {"_seconds": 1735603200, "_nanoseconds": 0},
  "date_fin":   {"_seconds": 1736208000, "_nanoseconds": 0},
  "scope": { "type": "marque", "marque": "AlphaWear", "produit_id": null }
}

2) (Option) Règles de sécurité Firestore (dev simple)

Pour le TP, vous pouvez ouvrir en lecture/écriture (à éviter en prod) ou utiliser un service account côté Retool.
Dev rapide non sécurisé :

// Firestore Security Rules (dev uniquement !)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true; // NE PAS UTILISER EN PROD
    }
  }
}

3) Connexion Firebase dans Retool

  1. Resources > Create new > type Firestore.
  2. Renseigner la config Firebase (Project ID, service account si Admin SDK) ou utiliser la ressource Firestore native de Retool.
  3. Tester la connexion.

Alternative (REST) : ressource REST API vers https://firestore.googleapis.com/v1/projects/<PROJECT_ID>/databases/(default)/documents/ (plus verbeux).
Recommandé : ressource Firestore native (plus simple pour CRUD).

4) Architecture de l’app Retool (Header + Pages)

Header global (dans un “App Frame” ou répliqué)

Pages / Vues

  1. Catalogue
    • Filtres (colonne gauche) :
      • Input recherche par nom
      • Select marque
      • Select taille
      • MultiSelect couleurs
    • Table teeshirts (colonne droite) : colonnes clés + actions par ligne (Éditer / Supprimer)
    • Actions globales :
      • Bouton “+ Produit” (même que header)
    • Modal Éditer Produit (form autorempli)
  2. Promotions
    • Table promos : nom, réduction, période, scope
    • Boutons: “+ Promotion”, Éditer, Supprimer
    • Modal Create/Edit Promo avec champs + validation scope
  3. Tableau de bord
    • Cards (KPI) : nb produits, stock total, nb promos actives
    • Graphiques (option) : stock par marque, répartition tailles
    • Liste “Promos actives aujourd’hui”

5) Requêtes Retool (Firestore) — Catalogue

Noms des queries entre [].

Lire la liste des tee-shirts

// Transformer d’une Table (si vous ramenez tout et filtrez côté client) :
const rows = data; // data = docs Firestore
const q = {{ searchInput.value?.toLowerCase() || "" }};
const marque = {{ selectMarque.value || "" }};
const taille = {{ selectTaille.value || "" }};
const couleurs = {{ multiselectCouleurs.value || [] }};

return rows.filter(r => {
  const okQ = !q || (r.nom || "").toLowerCase().includes(q);
  const okM = !marque || r.marque === marque;
  const okT = !taille || r.taille === taille;
  const okC = !couleurs.length || (r.couleurs || []).some(c => couleurs.includes(c));
  return okQ && okM && okT && okC;
});

Créer un tee-shirt

{
  nom: {{ formTee.nom.value }},
  marque: {{ formTee.marque.value }},
  taille: {{ formTee.taille.value }},
  description: {{ formTee.description.value }},
  couleurs: {{ formTee.couleurs.value }}, // array
  stock: {{ Number(formTee.stock.value) || 0 }},
  prix: {{ Number(formTee.prix.value) || null }}
}

Mettre à jour un tee-shirt

Supprimer un tee-shirt

6) Requêtes Retool — Promotions

Lire promos

Créer / Éditer une promo

{
  nom: {{ formPromo.nom.value }},
  reduction_percent: {{ Number(formPromo.reduction.value) }},
  date_debut: {{ formPromo.dateDebut.value }}, // Retool Date -> Firestore Timestamp auto
  date_fin: {{ formPromo.dateFin.value }},
  scope: {
    type: {{ selectScope.value }},
    marque: {{ selectScope.value === "marque" ? selectMarquePromo.value : null }},
    produit_id: {{ selectScope.value === "produit" ? selectProduitPromo.value?.id : null }}
  }
}
// formPromo.submitDisabled
const t = {{ selectScope.value }};
if (t === "marque" && !{{ selectMarquePromo.value }}) return true;
if (t === "produit" && !{{ selectProduitPromo.value }}) return true;
const r = Number({{ formPromo.reduction.value }});
if (isNaN(r) || r < 0 || r > 100) return true;
const d1 = new Date({{ formPromo.dateDebut.value }});
const d2 = new Date({{ formPromo.dateFin.value }});
if (!(d1 < d2)) return true;
return false;

Supprimer une promo

7) Application des promotions (calcul affiché)

On calcule un prix affiché (sans modifier le doc) dans la Table/Detail produit.

Query utilitaire — Promos actives aujourd’hui

// Transformer pour ne garder que les promos actives aujourd’hui
const now = new Date();
return data.filter(p => {
  const d1 = p.date_debut ? new Date(p.date_debut) : null;
  const d2 = p.date_fin ? new Date(p.date_fin) : null;
  const inWindow = (!d1 || d1 <= now) && (!d2 || now <= d2);
  return inWindow;
});

Fonction JS (Temporary State ou JS Query) — meilleur prix

// getBestPrice(tee, activePromos)
function getBestPrice(tee, promos) {
  if (!tee?.prix) return null;
  let best = { price: tee.prix, applied: null };

  promos.forEach(p => {
    const type = p.scope?.type;
    let match = false;
    if (type === "global") match = true;
    if (type === "marque" && p.scope?.marque === tee.marque) match = true;
    if (type === "produit" && p.scope?.produit_id === tee.id) match = true;

    if (match) {
      const discounted = Number((tee.prix * (1 - (p.reduction_percent || 0)/100)).toFixed(2));
      if (discounted < best.price) best = { price: discounted, applied: p };
    }
  });

  return best; // { price, applied }
}

Utilisation dans la Table teeshirts

const bp = getBestPrice(currentRow, {{ getActivePromos.data }});
return bp?.price ?? currentRow.prix ?? "-";
const bp = getBestPrice(currentRow, {{ getActivePromos.data }});
return bp?.applied ? `${bp.applied.nom} (-${bp.applied.reduction_percent}%)` : "—";

Simplification si trop dur : ne gérer que scope.type = "global" (ou global + marque), ignorer “produit”.

8) UI/UX recommandations (Retool)

9) Queries Dashboard (exemples)

KPIs (JS à partir de getTees.data et getActivePromos.data)

// kpiNbProduits
return {{ getTees.data.length }};

// kpiStockTotal
return {{ getTees.data.reduce((s, r) => s + (Number(r.stock)||0), 0) }};

// kpiPromosActives
return {{ getActivePromos.data.length }};

Dataset “stock par marque”

const map = {};
({{ getTees.data }} || []).forEach(t => {
  map[t.marque] = (map[t.marque] || 0) + (Number(t.stock)||0);
});
return Object.entries(map).map(([marque, stock]) => ({ marque, stock }));

10) Données de test (seed rapide)

Produits

[
  {"nom":"Classic Tee","marque":"AlphaWear","taille":"M","description":"Coton bio 180g","couleurs":["noir","blanc"],"stock":25,"prix":19.9},
  {"nom":"Street Tee","marque":"UrbanWave","taille":"L","description":"Oversize","couleurs":["gris"],"stock":12,"prix":24.5},
  {"nom":"Sport Tee","marque":"FitMax","taille":"S","description":"Respirant","couleurs":["bleu","noir"],"stock":30,"prix":29.0}
]

Promotions

[
  {"nom":"Global -10%","reduction_percent":10,"date_debut":"2025-09-01T00:00:00Z","date_fin":"2025-10-31T23:59:59Z","scope":{"type":"global","marque":null,"produit_id":null}},
  {"nom":"Alpha -15%","reduction_percent":15,"date_debut":"2025-09-10T00:00:00Z","date_fin":"2025-09-30T23:59:59Z","scope":{"type":"marque","marque":"AlphaWear","produit_id":null}}
]

Vous pouvez coller ces objets via des Query JSON dans Retool et boucler pour créer les docs, ou ajouter à la main.

11) Multi-pages dans Retool

// Au chargement, sélectionner l’onglet depuis l’URL
const p = utils.getUrlParam("page") || "catalogue";
tabsMain.setValue(p);

13) Chemin “simplifié” si c’est trop dur

15) Barème ( /20 )