// ============================================================ // Ecrans de l'application // ============================================================ const { useAsyncData, useToast, SectorBadge, StatutBadge, ConfianceBadge, EditableField, EditableTextarea, RangeCell, PrimaryButton, SecondaryButton, GhostButton, Page, LoadingState, ErrorState, EmptyState, Modal, } = window.UI; // ============================================================ // Liste des recettes // ============================================================ function ScreenListeRecettes({ goRoute }) { const { data, error, loading, reload } = useAsyncData(api.listRecettes, []); const [search, setSearch] = React.useState(""); const [showNew, setShowNew] = React.useState(false); if (loading) return ; if (error) return ; const filtered = (data || []).filter((r) => !search || r.nom.toLowerCase().includes(search.toLowerCase()) ); // Trier : incomplets d'abord, puis ordre alpha filtered.sort((a, b) => { if (a.statut !== b.statut) return a.statut === "incomplet" ? -1 : 1; return a.nom.localeCompare(b.nom); }); const groupes = { pain: filtered.filter((r) => r.secteur === "pain"), viennoiserie: filtered.filter((r) => r.secteur === "viennoiserie"), }; return (

Recettes

Référentiel des pâtes mères et dérivées de la boulangerie.

setShowNew(true)}> + Nouvelle recette
setSearch(e.target.value)} className="w-full px-4 py-2.5 rounded-full border border-[var(--line)] bg-white/60 focus:bg-white focus:outline-none focus:border-[var(--pain-300)]" />
{["pain", "viennoiserie"].map((sec) => { const list = groupes[sec]; if (!list.length) return null; return (

{sec === "pain" ? "Pain" : "Viennoiserie"}

{list.length} recette{list.length > 1 ? "s" : ""}
{list.map((r) => goRoute({ name: "recette", id: r.id })} />)}
); })} {!filtered.length && ( setShowNew(true)}>+ Nouvelle recette } /> )} {showNew && ( setShowNew(false)} onCreated={(r) => { setShowNew(false); goRoute({ name: "recette", id: r.id }); }} /> )}
); } function RecetteCard({ recette, onClick }) { const isPain = recette.secteur === "pain"; return ( ); } function NewRecetteModal({ onClose, onCreated }) { const [nom, setNom] = React.useState(""); const [secteur, setSecteur] = React.useState("pain"); const [type, setType] = React.useState("mere"); const [parenteId, setParenteId] = React.useState(""); const [parents, setParents] = React.useState([]); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const { showToast } = useToast(); React.useEffect(() => { api.listRecettes().then((rs) => setParents(rs.filter((r) => r.type_recette === "mere"))).catch(() => {}); }, []); const submit = async () => { if (!nom.trim()) return setErr("Nom requis"); setBusy(true); setErr(null); try { const r = await api.createRecette({ nom: nom.trim(), secteur, type_recette: type, recette_parente_id: type === "derivee" ? (parenteId || null) : null, }); showToast("Recette créée"); onCreated(r); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; return (
setNom(e.target.value)} className="w-full mt-1 px-3 py-2 rounded-lg border border-[var(--line)] bg-white" />
{type === "derivee" && (
)} {err &&
{err}
}
Annuler Créer
); } // ============================================================ // Edition d'une recette // ============================================================ function ScreenEditRecette({ recetteId, goRoute }) { const { data, error, loading, reload, setData } = useAsyncData(() => api.getRecette(recetteId), [recetteId]); const [petrins, setPetrins] = React.useState([]); const { showToast } = useToast(); React.useEffect(() => { api.listPetrins().then(setPetrins).catch(() => {}); }, []); if (loading) return ; if (error) return ; if (!data) return null; const isPain = data.secteur === "pain"; const sectorClass = isPain ? "sector-pain" : "sector-vien"; const updateRecette = async (patch) => { try { const updated = await api.updateRecette(recetteId, patch); setData(updated); showToast("Sauvegardé"); } catch (e) { showToast(e.message, "err"); throw e; } }; const deleteRecette = async () => { if (!confirm(`Supprimer la recette "${data.nom}" ? Les barèmes et ingrédients seront perdus.`)) return; try { await api.deleteRecette(recetteId); showToast("Recette supprimée"); goRoute({ name: "recettes" }); } catch (e) { showToast(e.message, "err"); } }; return ( {/* Fil d'Ariane */}
/ {data.nom}
{/* En-tete */}
updateRecette({ nom: v })} className="font-serif text-4xl sm:text-5xl w-full" /> {data.type_recette === "derivee" && data.recette_parente_id && (
dérivée de{" "}
)}
updateRecette({ cycle_production_jours: v })} numeric inputMode="numeric" placeholder="—" className="w-12 text-right" /> jours
updateRecette({ cycle_production_notes: v })} placeholder="ex : généralement 2, parfois 3" className="text-xs italic text-[var(--ink-soft)] w-full mt-1" />
updateRecette({ methode_blocage: v })} placeholder="façonnage congelé, pâte en bac, aucun…" className="w-full" /> {data.type_recette}
{/* Baremes */}
{/* Notes process — savoir-faire libre */}
updateRecette({ notes_process: v })} placeholder="Bassinage, frasage, vitesses, rabats, trempage céréales, températures, repères du métier…" rows={8} className="font-serif italic text-base text-[var(--ink)] bg-transparent" />
{/* Articles produits */} {data.articles && data.articles.length > 0 && (
1 ? "s" : ""}`}>
{data.articles.map((a) => ( ))}
)} {/* Recettes derivees */} {data.recettes_derivees && data.recettes_derivees.length > 0 && (
{data.recettes_derivees.map((id) => ( ))}
)} {/* Footer actions */}
Modifié le {new Date(data.modifie_le).toLocaleString("fr-FR")}
); } function Field({ label, children }) { return (
{label}
{children}
); } function Section({ title, subtitle, children }) { return (

{title}

{subtitle && {subtitle}}
{children}
); } // ============================================================ // Tableau editable barèmes + ingredients // ============================================================ function BaremeTable({ recetteId, baremes, petrins, onChange }) { const { showToast } = useToast(); // Toutes les colonnes ingredient distinctes (par nom) const ingredientNames = React.useMemo(() => { const set = new Set(); baremes.forEach((b) => b.ingredients.forEach((i) => set.add(i.nom))); return Array.from(set); }, [baremes]); const addBareme = async () => { const lastBac = baremes.length ? Math.max(...baremes.map((b) => b.bac_num)) : 0; const farine = prompt("Taille du bac (kg de farine) :", "5"); if (!farine) return; try { await api.createBareme(recetteId, { bac_num: lastBac + 1, farine_kg: Number(farine.replace(",", ".")), ingredients: ingredientNames.map((nom, idx) => ({ nom, unite: nom.toLowerCase().includes("eau") ? "L" : "g", ordre: idx })), }); showToast("Bac ajouté"); onChange(); } catch (e) { showToast(e.message, "err"); } }; const deleteBareme = async (bareme) => { if (!confirm(`Supprimer le bac ${bareme.bac_num} ?`)) return; try { await api.deleteBareme(bareme.id); showToast("Bac supprimé"); onChange(); } catch (e) { showToast(e.message, "err"); } }; const addIngredient = async () => { const nom = prompt("Nom de l'ingrédient :", ""); if (!nom || !nom.trim()) return; const unite = prompt("Unité (g, kg, L) :", "g") || "g"; try { // ajouter sur tous les baremes for (const b of baremes) { await api.createIngredient(b.id, { nom: nom.trim(), unite, ordre: b.ingredients.length, }); } showToast("Ingrédient ajouté"); onChange(); } catch (e) { showToast(e.message, "err"); } }; const updateIngredient = async (ing, patch) => { try { await api.updateIngredient(ing.id, patch); showToast("Sauvegardé"); onChange(); } catch (e) { showToast(e.message, "err"); throw e; } }; const updateBareme = async (bareme, patch) => { try { await api.updateBareme(bareme.id, patch); showToast("Sauvegardé"); onChange(); } catch (e) { showToast(e.message, "err"); throw e; } }; if (!baremes.length) { return (
Aucun barème encore.
+ Ajouter un premier bac
); } return (
{ingredientNames.map((nom) => ( ))} {baremes.map((b) => ( {ingredientNames.map((nom) => { const ing = b.ingredients.find((i) => i.nom === nom); if (!ing) { return ( ); } return ( ); })} ))}
Bac Farine Pétrin{nom}
Bac {b.bac_num}
updateBareme(b, { farine_kg: v })} numeric inputMode="decimal" className="w-16 text-right" /> kg
updateIngredient(ing, { quantite_min: v, quantite_max: ing.quantite_max ?? v })} onSaveMax={(v) => updateIngredient(ing, { quantite_max: v })} /> deleteBareme(b)}>×
+ Bac + Ingrédient
); } // ============================================================ // Faconnages // ============================================================ function ScreenFaconnages() { const { data, error, loading, reload, setData } = useAsyncData(api.listFaconnages, []); const { showToast } = useToast(); if (loading) return ; if (error) return ; const update = async (id, patch) => { try { const r = await api.updateFaconnage(id, patch); setData((d) => d.map((x) => x.id === id ? r : x)); showToast("Sauvegardé"); } catch (e) { showToast(e.message, "err"); throw e; } }; const create = async () => { const nom = prompt("Nom du façonnage :", ""); if (!nom) return; try { await api.createFaconnage({ nom: nom.trim() }); reload(); showToast("Créé"); } catch (e) { showToast(e.message, "err"); } }; const remove = async (id) => { if (!confirm("Supprimer ce façonnage ?")) return; try { await api.deleteFaconnage(id); reload(); showToast("Supprimé"); } catch (e) { showToast(e.message, "err"); } }; return (

Façonnages

Forme finale donnée à un pâton — poids unitaire et équivalence baguettes.

+ Nouveau
{data.map((f) => ( ))} {!data.length && ( )}
Nom Poids pâton (g) Équivalence baguettes Notes
update(f.id, { nom: v })} className="font-serif text-lg" />
{f.id}
update(f.id, { poids_paton_g: v })} numeric inputMode="numeric" placeholder="—" className="w-20 text-right" /> update(f.id, { equivalence_baguettes: v })} numeric inputMode="decimal" placeholder="—" className="w-20 text-right" /> update(f.id, { notes: v })} placeholder="…" className="w-full" /> remove(f.id)}>×
); } // ============================================================ // Incorporations // ============================================================ function ScreenIncorporations() { const { data, error, loading, reload, setData } = useAsyncData(api.listIncorporations, []); const { showToast } = useToast(); if (loading) return ; if (error) return ; const update = async (id, patch) => { try { const r = await api.updateIncorporation(id, patch); setData((d) => d.map((x) => x.id === id ? r : x)); showToast("Sauvegardé"); } catch (e) { showToast(e.message, "err"); throw e; } }; const create = async () => { const nom = prompt("Nom de l'incorporation :", ""); if (!nom) return; try { await api.createIncorporation({ nom: nom.trim() }); reload(); showToast("Créé"); } catch (e) { showToast(e.message, "err"); } }; const remove = async (id) => { if (!confirm("Supprimer cette incorporation ?")) return; try { await api.deleteIncorporation(id); reload(); showToast("Supprimé"); } catch (e) { showToast(e.message, "err"); } }; return (

Incorporations

Ajouts en cours de pétrissage — graines, fruits secs, levains spécifiques.

+ Nouvelle
{data.map((i) => ( ))} {!data.length && ( )}
Nom Dosage (g) Bac réf. (kg farine) Méthode
update(i.id, { nom: v })} className="font-serif text-lg" />
{i.id}
update(i.id, { dosage_par_bac_g: v })} numeric inputMode="decimal" placeholder="—" className="w-24 text-right" /> update(i.id, { bac_reference_kg: v })} numeric inputMode="decimal" placeholder="—" className="w-20 text-right" /> update(i.id, { methode_application: v })} placeholder="…" className="w-full" /> remove(i.id)}>×
); } // ============================================================ // Mapping articles // ============================================================ function ScreenArticles() { const { data, error, loading, reload, setData } = useAsyncData(api.listArticles, []); const [recettes, setRecettes] = React.useState([]); const [faconnages, setFaconnages] = React.useState([]); const [incorporations, setIncorporations] = React.useState([]); const [filterFamille, setFilterFamille] = React.useState(""); const [filterRecette, setFilterRecette] = React.useState(""); const [filterConfiance, setFilterConfiance] = React.useState(""); const [search, setSearch] = React.useState(""); const { showToast } = useToast(); React.useEffect(() => { api.listRecettes().then(setRecettes).catch(() => {}); api.listFaconnages().then(setFaconnages).catch(() => {}); api.listIncorporations().then(setIncorporations).catch(() => {}); }, []); if (loading) return ; if (error) return ; const updateMapping = async (code, patch) => { try { const updated = await api.updateMapping(code, patch); setData((d) => d.map((a) => a.code === code ? updated : a)); showToast("Sauvegardé"); } catch (e) { showToast(e.message, "err"); throw e; } }; const familles = Array.from(new Set(data.map((a) => a.famille))).sort(); const filtered = data.filter((a) => (!filterFamille || a.famille === filterFamille) && (!filterRecette || a.recette_id === filterRecette) && (!filterConfiance || a.confiance === filterConfiance) && (!search || a.designation.toLowerCase().includes(search.toLowerCase()) || a.code.includes(search)) ); return (

Articles

Mapping des {data.length} articles Kwisatz vers leur recette mère et leur façonnage.

setSearch(e.target.value)} placeholder="Rechercher article ou code…" className="px-3 py-2 rounded-full border border-[var(--line)] bg-white/60 focus:bg-white focus:outline-none text-sm" />
{filtered.map((a) => ( ))} {!filtered.length && ( )}
Code Désignation Famille Recette Façonnage Incorporation Variante Confiance
{a.code} {a.designation} {a.famille} updateMapping(a.code, { variante: v })} placeholder="—" className="w-24" />
); } window.Screens = { ScreenListeRecettes, ScreenEditRecette, ScreenFaconnages, ScreenIncorporations, ScreenArticles, };