Voici une analyse détaillée et structurée de la solution proposée pour ton planning Bulterys, ainsi que des recommandations pour optimiser, sécuriser et appliquer les bonnes pratiques IT.
storage : Gestion de la persistance (localStorage, futur API Airtable).useEdits : Logique d'édition et de persistance isolée du rendu.EditModal : Composant d'édition réutilisable et optimisé.buildMergedIdx : Fusion des données statiques et des modifications locales.storage est conçue pour être remplacée facilement par une API Airtable ou autre backend, sans modifier le reste du code._colorCache évite de recalculer les couleurs pour chaque cellule à chaque rendu, ce qui améliore les performances.makeKey génère des clés uniques et fiables pour chaque cellule, ce qui est crucial pour la persistance et la synchronisation future.Enter.wk, j, r, cl, act, dt, al) sont alignées sur ce qu'Airtable attend.storage montre déjà les champs Airtable (fldcuGXPz7NoHgj9h, etc.), ce qui facilite la migration future.useMemo et useCallback :
Optimisation des rendus et des calculs coûteux.EditModal est enveloppé dans memo pour éviter des re-rendus inutiles.aria-modal et aria-label pour une meilleure accessibilité.cl n'est pas vide, que act est une valeur valide, etc.).
Exemple :
javascriptconst validateOccupation = (occ) => { if (!occ.cl?.trim()) throw new Error("Client requis"); if (!ACT_BADGE[occ.act] && occ.act) throw new Error("Action invalide"); return true; };
localStorage (ex : quota dépassé) et prévoir un fallback (ex : afficher un message à l'utilisateur).react-window pour virtualiser le rendu des lignes et colonnes, ce qui améliore les performances sur les appareils mobiles ou moins puissants.React.memo pour les composants de ligne (Row) si la grille devient très grande.apiService.js) pour gérer les appels à Airtable, avec :
javascriptconst syncQueue = []; const enqueueSync = (action) => syncQueue.push(action); const processQueue = async () => { while (syncQueue.length > 0 && navigator.onLine) { const action = syncQueue.shift(); try { await apiService[action.type](action.payload); } catch (err) { syncQueue.unshift(action); break; } } }; window.addEventListener("online", processQueue);
makeKey, parseKey, daySummary, useEdits).
Exemple avec Jest :
javascripttest("makeKey génère une clé valide", () => { expect(makeKey("GS0060-024", "06/04/2026", 2)).toBe("GS0060-024||06/04/2026||2"); });
Voici un exemple de comment tu pourrais implémenter la couche API pour Airtable :
javascript// apiService.js const API_KEY = "ton_api_key"; const BASE_ID = "ta_base_id"; const TABLE_NAME = "Occupations"; const airtableFetch = async (semaine) => { const url = `https://api.airtable.com/v0/${BASE_ID}/${TABLE_NAME}?filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records } = await response.json(); return records.map(r => ({ wk: r.fields.wk, j: r.fields.j, r: r.fields.r, cl: r.fields.cl, act: r.fields.act, dt: r.fields.dt, ck: r.fields.ck, al: r.fields.al })); }; const airtableSave = async (occs) => { const url = `https://api.airtable.com/v0/${BASE_ID}/${TABLE_NAME}`; const payload = occs.map(occ => ({ fields: { wk: occ.wk, j: occ.j, r: occ.r, cl: occ.cl, act: occ.act, dt: occ.dt, ck: occ.ck, al: occ.al } })); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` }, body: JSON.stringify({ records: payload }) }); if (!response.ok) throw new Error("Erreur sauvegarde Airtable"); return response.json(); }; // Dans storage.js, remplacer load/save par : const loadFromAirtable = async (semaine) => airtableFetch(semaine); const saveToAirtable = async (occs) => airtableSave(occs);
EditModal.Question pour toi : Veux-tu que je te fournisse un exemple complet d'intégration de la couche Airtable dans ton code actuel, ou préfères-tu te concentrer sur une autre partie (ex : tests, optimisation UX) ?
tests, optimisation UX
Voici une proposition d’optimisation UX/textes et un aperçu des améliorations possibles après optimisation, en gardant ton architecture actuelle intacte.
Remplace les labels techniques ou ambigus par des termes plus intuitifs pour les utilisateurs terrain (ex : techniciens, logisticiens).
LIVLivraisonREPRetourPLEINRavitaillementENTEntretienATLAtelierFDLFin de location✎ N modif.Modifications non synchronisées (N)resetAnnuler les modificationsExport JSONExporter les donnéesAlerte (N)Alertes techniques (N)Ajoute des messages plus explicites pour guider l’utilisateur :
Lors de la suppression d’une occupation :
javascript// Remplace le simple "x" par un message clair : if (!window.confirm(`Supprimer cette occupation pour ${ressource} le ${dayLabel} ?`)) return;
Lors du reset d’une semaine :
javascriptif (!window.confirm(`Cette action supprimera toutes les modifications non synchronisées pour la semaine du ${semaine.lb}. Continuer ?`)) return;
Feedback après export :
javascript// Après l'export JSON : alert(`Les données ont été exportées sous le nom "bulterys_edits_${new Date().toISOString().slice(0, 10)}.json".`);
Ajoute des title ou des infobulles pour expliquer les icônes et abréviations :
jsx// Exemple pour les badges d'action : <span style={{ background: badge.bg, color: badge.tx, borderRadius: 1, padding: "0 3px", fontSize: 9, fontWeight: 700 }} title={`Action : ${badge.lb === "LIV" ? "Livraison" : badge.lb === "REP" ? "Retour" : badge.lb}`} > {badge.lb} </span>
Filtres par section :
jsx<input type="text" placeholder="Rechercher (client, ressource, action)..." style={{ ...tb, marginLeft: "auto", width: 200 }} onChange={(e) => setSearchTerm(e.target.value)} />
Onglets de semaine :
📅 ou fond légèrement différent).Drag-and-drop : Permets de glisser-déposer une occupation d’une cellule à une autre (ex : changer de jour ou de ressource).
react-dnd pour implémenter cette fonctionnalité.Suggestions automatiques :
jsx<input list="clients" value={form.cl} onChange={(e) => setForm(f => ({ ...f, cl: e.target.value }))} placeholder="Client / description" autoFocus /> <datalist id="clients"> {Array.from(new Set(OCCUPATIONS.map(o => o.cl))).map((client, i) => ( <option key={i} value={client} /> ))} </datalist>
Animations subtiles :
fade-in) lors de l’ajout/suppression d’une occupation.css@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .occupation-added { animation: fadeIn 0.3s ease-in; }
Couleurs et contrastes :
MAJOR_CLIENTS) sont accessibles (ex : utilise un outil comme WebAIM Contrast Checker).dark mode) pour les utilisateurs en extérieur.Adaptation pour mobile :
css@media (max-width: 768px) { table { width: 100%; } th, td { padding: 2px 4px; font-size: 10px; } }
Gestes tactiles :
Pour les très grandes listes (130+ ressources), utilise react-window pour virtualiser le rendu des lignes et colonnes.
Exemple d’intégration :
jsximport { FixedSizeList as List } from "react-window"; // Dans le rendu de la grille : <List height={500} itemCount={allRows.length} itemSize={30} // Hauteur d'une ligne width="100%" > {({ index, style }) => ( <div style={style}> {renderRow(allRows[index])} </div> )} </List>
React.memo pour les composants Row et Cell si la grille devient très grande.
Exemple :
jsxconst Row = memo(({ row, days, occIdx, setEditCell }) => { // Logique de rendu de la ligne });
Voici à quoi ressemblerait ton planning après ces optimisations (description visuelle et extraits de code) :
jsx<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 12px", background: "#0d1118", borderBottom: "1px solid #1e2a3a", flexWrap: "wrap" }}> <span style={{ color: "#4a90d0", fontWeight: 700, fontSize: 13, letterSpacing: 1 }}>BULTERYS OPS</span> <span style={{ color: "#2a4060", fontSize: 10 }}>|</span> {SEMAINES.map(s => ( <button key={s.wk} style={selWk === s.wk ? tba : tb} onClick={() => setSelWk(s.wk)} title={`Semaine du ${s.lb}`} > {s.lb} {s.cur && "📅"} </button> ))} <input type="text" placeholder="Rechercher..." style={{ ...tb, marginLeft: "auto", width: 200 }} onChange={(e) => setSearchTerm(e.target.value)} /> {editCount > 0 && ( <> <span style={{ background: "#1a3a18", border: "1px solid #2a6030", borderRadius: 2, padding: "2px 7px", fontSize: 9, color: "#60c070" }}> {editCount} modification{editCount > 1 ? "s" : ""} non synchronisée{editCount > 1 ? "s" : ""} </span> <button onClick={handleResetWeek} style={{ ...tb, color: "#805040", borderColor: "#3a2020" }}> Annuler </button> <button onClick={handleExport} style={{ ...tb, color: "#4a90d0", borderColor: "#1e3858" }}> Exporter </button> </> )} {alertes.length > 0 && ( <button onClick={() => setAlertPanel(p => !p)} style={{ marginLeft: "auto", padding: "3px 9px", border: "1px solid #503030", borderRadius: 2, fontSize: 10, cursor: "pointer", background: alertPanel ? "#4a1010" : "#281010", color: "#f08080" }} > {alertPanel ? "Masquer" : "Voir"} les alertes ({alertes.length}) </button> )} </div>
jsx<EditModal cell={editCell} occBase={getOcc(occIdx, editCell.ressource, editCell.jour)} onSave={handleModalSave} onClose={() => setEditCell(null)} />
jsx<div style={{ overflowX: "auto", height: "calc(100vh - 200px)" }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} columnWidth={150} height={height} rowCount={allRows.length} rowHeight={30} width={width} overscanRowCount={5} > {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") { return ( <div style={style}> <div style={{ background: SEC_COLORS[row.sec] || "#1e2a3a", padding: "3px 8px", color: "#c0d0e0", fontSize: 10, fontWeight: 700 }}> {SEC_LABELS[row.sec]} </div> </div> ); } // Logique de rendu des cellules... }} </Grid> )} </AutoSizer> </div>
Animation pour les nouvelles occupations :
css@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } .occupation-new { animation: fadeIn 0.3s ease-out; }
Tooltips pour les abréviations :
jsx<span style={{ background: badge.bg, color: badge.tx, borderRadius: 1, padding: "0 3px", fontSize: 9, fontWeight: 700 }} title={badge.lb === "LIV" ? "Livraison client" : badge.lb === "REP" ? "Retour au dépôt" : "Action personnalisée"} > {badge.lb} </span>
LIV, REP)Livraison, Retour)react-window)aria-labelsSi tu veux, je peux te fournir :
Dis-moi ce que tu préfères ! 😊
Voici un exemple complet d’optimisation avec virtualisation de la grille (pour les performances) et un aperçu visuel de l’interface après optimisation.
Pour optimiser les performances, nous allons utiliser react-window pour virtualiser le rendu des lignes et colonnes. Cela permet de ne rendre que les cellules visibles à l’écran, ce qui est crucial pour une grille avec 130+ ressources et 6+ jours.
react-windowbashnpm install react-window
Voici comment intégrer la virtualisation dans ton composant BulterysPlanning :
jsximport { FixedSizeGrid as Grid } from "react-window"; import AutoSizer from "react-virtualized-auto-sizer";
Remplace la partie <table> de ton code par un composant virtualisé :
jsx// Dans le composant BulterysPlanning, remplace la section de la grille par : <div style={{ height: "calc(100vh - 200px)", width: "100%", overflow: "hidden" }}> <AutoSizer> {({ height, width }) => { const columnCount = days.length + 2; // 2 colonnes pour "Ressource" et "Modèle" const rowCount = allRows.length; const columnWidth = (index) => { if (index === 0) return 162; // Ressource if (index === 1) return 112; // Modèle return 154; // Jours }; const rowHeight = 30; return ( <Grid columnCount={columnCount} columnWidth={(index) => columnWidth(index)} height={height} rowCount={rowCount} rowHeight={rowHeight} width={width} overscanRowCount={5} > {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") { return ( <div style={{ ...style, background: SEC_COLORS[row.sec] || "#1e2a3a", padding: "3px 8px", color: "#c0d0e0", fontSize: 10, fontWeight: 700 }}> {SEC_LABELS[row.sec]} </div> ); } // Logique pour les lignes de données const { res } = row; const alertInfo = ALERTES_TECHNIQUES.find(a => a.r === res.id && a.wk === selWk); if (columnIndex === 0) { // Colonne "Ressource" return ( <div style={{ ...style, padding: "3px 6px", color: alertInfo ? "#f09090" : "#b0bcc8", fontWeight: 500, borderRight: "1px solid #1a2030", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", }} title={alertInfo ? alertInfo.msg : res.id} > {alertInfo && <span style={{ display: "inline-block", width: 5, height: 5, borderRadius: "50%", background: "#e03030", marginRight: 4 }} />} {res.id} </div> ); } else if (columnIndex === 1) { // Colonne "Modèle" return ( <div style={{ ...style, padding: "3px 6px", color: "#4a6078", fontSize: 10, borderRight: "1px solid #1a2030", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", }} title={res.sub || res.modele || ""} > {res.sub || res.modele || ""} {res.kva > 0 && <span style={{ color: "#2a5878", marginLeft: 3 }}>{res.kva}k</span>} </div> ); } else { // Colonnes des jours const dayIndex = columnIndex - 2; const occs = getOcc(occIdx, res.id, dayIndex); const isEdited = edits[makeKey(res.id, selWk, dayIndex)] !== undefined; return ( <div style={{ ...style, borderLeft: "1px solid #1a2030", padding: "2px 3px", verticalAlign: "top", minWidth: 154, cursor: "pointer", outline: isEdited ? "1px solid #2a5080" : undefined, outlineOffset: isEdited ? "-1px" : undefined, }} onClick={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })} title={`Éditer ${res.id} - ${days[dayIndex]}`} > {occs.length === 0 ? ( <div style={{ height: 20 }} /> ) : ( occs.map((occ, oi) => { const cs = getClientStyle(occ.cl, occ.ck); const badge = occ.act && ACT_BADGE[occ.act]; return ( <div key={oi} style={{ background: cs.bg, border: `1px solid ${cs.bd}`, borderRadius: 2, padding: "2px 4px", marginBottom: 1, display: "flex", alignItems: "baseline", gap: 3, flexWrap: "wrap", }} > {badge && ( <span style={{ background: badge.bg, color: badge.tx, borderRadius: 1, padding: "0 3px", fontSize: 9, fontWeight: 700, whiteSpace: "nowrap", }} title={badge.lb === "LIV" ? "Livraison" : badge.lb === "REP" ? "Retour" : badge.lb} > {badge.lb} </span> )} <span style={{ color: cs.tx, fontWeight: 500, fontSize: 10, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 88, }} title={occ.cl} > {occ.cl} </span> {occ.dt && ( <span style={{ color: cs.tx, opacity: 0.65, fontSize: 9, whiteSpace: "nowrap" }}> {occ.dt} </span> )} </div> ); }) )} </div> ); } }} </Grid> ); }} </AutoSizer> </div>
Pour afficher les en-têtes des colonnes (jours de la semaine), ajoute un composant Grid séparé pour les en-têtes :
jsx// Avant le Grid principal, ajoute : <div style={{ height: 40, width: "100%", borderBottom: "1px solid #1a2a3a" }}> <AutoSizer> {({ width }) => { const columnCount = days.length + 2; const columnWidth = (index) => { if (index === 0) return 162; if (index === 1) return 112; return 154; }; return ( <Grid columnCount={columnCount} columnWidth={columnWidth} height={40} rowCount={1} rowHeight={40} width={width} > {({ columnIndex, style }) => { if (columnIndex === 0) { return ( <div style={{ ...style, padding: "5px 6px", textAlign: "left", color: "#4a6880", background: "#0d1118", fontWeight: 500, position: "sticky", top: 0, zIndex: 10 }}> Ressource </div> ); } else if (columnIndex === 1) { return ( <div style={{ ...style, padding: "5px 6px", textAlign: "left", color: "#4a6880", background: "#0d1118", fontWeight: 500, position: "sticky", top: 0, zIndex: 10 }}> Modèle </div> ); } else { const dayIndex = columnIndex - 2; const sum = daySummary(occIdx, dayIndex); const isToday = selWk === "06/04/2026" && dayIndex === 4; return ( <div style={{ ...style, padding: "5px 6px", textAlign: "left", background: "#0d1118", fontWeight: 500, position: "sticky", top: 0, zIndex: 10, color: isToday ? "#80b8e8" : "#5a7a9a", borderTop: isToday ? "2px solid #2a5890" : undefined, borderLeft: "1px solid #1a2a3a", }} > <div style={{ fontSize: 11 }}>{days[dayIndex]}</div> <div style={{ display: "flex", gap: 4, marginTop: 2, fontSize: 9 }}> {sum.liv > 0 && <span style={{ color: "#4488cc" }}>L{sum.liv}</span>} {sum.rep > 0 && <span style={{ color: "#9955bb" }}>R{sum.rep}</span>} {sum.plein > 0 && <span style={{ color: "#c05010" }}>P{sum.plein}</span>} {sum.entretien > 0 && <span style={{ color: "#708020" }}>E{sum.entretien}</span>} {sum.alertes > 0 && <span style={{ color: "#c03030" }}>!{sum.alertes}</span>} </div> </div> ); } }} </Grid> ); }} </AutoSizer> </div>
Barre de navigation :
Filtres par section :
Grille virtualisée :
Modale d’édition :
Légende et statut :
Si tu veux aller plus loin, voici ce que je peux te proposer :
daySummary, makeKey, etc.).Dis-moi ce qui t’intéresse le plus ! 😊

Voici une implémentation complète du drag-and-drop, des tests unitaires pour les fonctions critiques, et une optimisation mobile avec gestes tactiles et design responsive.
Pour permettre le glisser-déposer des occupations entre cellules, nous allons utiliser la bibliothèque react-dnd.
bashnpm install react-dnd react-dnd-html5-backend
react-dndDans ton fichier principal (ex: App.js), enveloppe ton composant BulterysPlanning avec le DndProvider :
jsx// App.js import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import BulterysPlanning from './BulterysPlanning'; function App() { return ( <DndProvider backend={HTML5Backend}> <BulterysPlanning /> </DndProvider> ); }
Dans BulterysPlanning, ajoute les types et logiques pour le drag-and-drop :
jsx// Types pour react-dnd const ItemTypes = { OCCUPATION: 'occupation', };
DraggableOccupationCe composant représentera une occupation "glissable" :
jsximport { useDrag } from 'react-dnd'; const DraggableOccupation = ({ occ, ressource, semaine, jour, dayLabel, onDrop, children }) => { const [{ isDragging }, drag] = useDrag(() => ({ type: ItemTypes.OCCUPATION, item: { occ, ressource, semaine, jour, dayLabel }, end: (item, monitor) => { if (!monitor.didDrop()) return; const dropResult = monitor.getDropResult(); if (dropResult) { onDrop(item, dropResult); } }, collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), })); return ( <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1, cursor: 'move', }} > {children} </div> ); };
DroppableCellCe composant représentera une cellule "déposable" :
jsximport { useDrop } from 'react-dnd'; const DroppableCell = ({ ressource, semaine, jour, dayLabel, occs, onDrop, children }) => { const [{ isOver, canDrop }, drop] = useDrop(() => ({ accept: ItemTypes.OCCUPATION, drop: () => ({ ressource, semaine, jour, dayLabel }), collect: (monitor) => ({ isOver: !!monitor.isOver(), canDrop: !!monitor.canDrop(), }), })); return ( <div ref={drop} style={{ borderLeft: "1px solid #1a2030", padding: "2px 3px", verticalAlign: "top", minWidth: 154, cursor: "pointer", background: isOver ? (canDrop ? "rgba(40, 100, 180, 0.15)" : "rgba(180, 40, 40, 0.15)") : undefined, }} onClick={() => setEditCell({ ressource, semaine, jour, dayLabel })} > {children} </div> ); };
Dans la partie de rendu de la grille (virtualisée ou non), utilise DraggableOccupation et DroppableCell :
jsx// Dans la boucle de rendu des cellules de jours : <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} occs={occs} onDrop={handleDropOccupation} > {occs.length === 0 ? ( <div style={{ height: 20 }} /> ) : ( occs.map((occ, oi) => { const cs = getClientStyle(occ.cl, occ.ck); const badge = occ.act && ACT_BADGE[occ.act]; return ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > <div style={{ background: cs.bg, border: `1px solid ${cs.bd}`, borderRadius: 2, padding: "2px 4px", marginBottom: 1, display: "flex", alignItems: "baseline", gap: 3, flexWrap: "wrap", }} > {badge && ( <span style={{ background: badge.bg, color: badge.tx, borderRadius: 1, padding: "0 3px", fontSize: 9, fontWeight: 700, whiteSpace: "nowrap", }} > {badge.lb} </span> )} <span style={{ color: cs.tx, fontWeight: 500, fontSize: 10, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 88, }} title={occ.cl} > {occ.cl} </span> {occ.dt && ( <span style={{ color: cs.tx, opacity: 0.65, fontSize: 9, whiteSpace: "nowrap" }}> {occ.dt} </span> )} </div> </DraggableOccupation> ); }) )} </DroppableCell>
Ajoute la logique pour gérer le déplacement d’une occupation :
jsx// Dans BulterysPlanning : const handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; // Ne rien faire si la source et la destination sont identiques if (srcRessource === destRessource && srcJour === destJour && srcSemaine === destSemaine) return; // Logique pour déplacer l'occupation setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); // Supprimer de la source const srcOccs = newEdits[srcKey] || getOcc(buildMergedIdx(srcSemaine, prev), srcRessource, srcJour); const filteredSrcOccs = srcOccs.filter(o => !(o.cl === occ.cl && o.act === occ.act && o.dt === occ.dt && o.ck === occ.ck)); // Ajouter à la destination const destOccs = newEdits[destKey] || getOcc(buildMergedIdx(destSemaine, prev), destRessource, destJour); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; // Mettre à jour les edits if (filteredSrcOccs.length > 0) { newEdits[srcKey] = filteredSrcOccs; } else { delete newEdits[srcKey]; } newEdits[destKey] = updatedDestOccs; return newEdits; }); }, [selWk]);
Voici des exemples de tests unitaires pour les fonctions daySummary et makeKey/parseKey, en utilisant Jest.
bashnpm install --save-dev jest @testing-library/react @testing-library/jest-dom
makeKey et parseKeyjavascript// __tests__/keys.test.js const { makeKey, parseKey } = require('../path/to/your/file'); describe('makeKey', () => { test('génère une clé valide', () => { expect(makeKey("GS0060-024", "06/04/2026", 2)).toBe("GS0060-024||06/04/2026||2"); }); test('gère les valeurs vides', () => { expect(makeKey("", "06/04/2026", 2)).toBe("||06/04/2026||2"); expect(makeKey("GS0060-024", "", 2)).toBe("GS0060-024||||2"); }); }); describe('parseKey', () => { test('parse une clé valide', () => { expect(parseKey("GS0060-024||06/04/2026||2")).toEqual({ ressource: "GS0060-024", semaine: "06/04/2026", jour: 2, }); }); test('retourne null pour une clé invalide', () => { expect(parseKey("invalid-key")).toBeNull(); expect(parseKey("GS0060-024||06/04/2026")).toBeNull(); expect(parseKey("GS0060-024||06/04/2026||not-a-number")).toBeNull(); }); });
daySummaryjavascript// __tests__/daySummary.test.js const { daySummary, buildMergedIdx } = require('../path/to/your/file'); const mockOccupations = [ { wk: "06/04/2026", j: 2, r: "GS0060-024", cl: "Fluvius", act: "liv", dt: "10h", al: false }, { wk: "06/04/2026", j: 2, r: "GS0060-025", cl: "Ores", act: "rep", dt: "11h", al: false }, { wk: "06/04/2026", j: 2, r: "GS0060-026", cl: "Arqa", act: "plein", dt: "12h", al: true }, { wk: "06/04/2026", j: 2, r: "GS0060-027", cl: "Smet", act: "entretien", dt: "13h", al: false }, ]; describe('daySummary', () => { test('calcule correctement les résumés par jour', () => { const semaine = "06/04/2026"; const idx = buildMergedIdx(semaine, {}); const summary = daySummary(idx, 2); expect(summary).toEqual({ liv: 1, rep: 1, plein: 1, entretien: 1, alertes: 1, }); }); test('retourne des zéros pour un jour sans occupation', () => { const semaine = "06/04/2026"; const idx = buildMergedIdx(semaine, {}); const summary = daySummary(idx, 5); // Jour sans occupation expect(summary).toEqual({ liv: 0, rep: 0, plein: 0, entretien: 0, alertes: 0, }); }); });
javascript// __tests__/merge.test.js const { buildMergedIdx } = require('../path/to/your/file'); const mockOccupations = [ { wk: "06/04/2026", j: 2, r: "GS0060-024", cl: "Fluvius", act: "liv", dt: "10h", al: false }, ]; const mockEdits = { "GS0060-024||06/04/2026||2": [ { wk: "06/04/2026", j: 2, r: "GS0060-024", cl: "Ores", act: "rep", dt: "11h", al: false }, ], }; describe('buildMergedIdx', () => { test('fusionne correctement les occupations et edits', () => { const semaine = "06/04/2026"; const idx = buildMergedIdx(semaine, mockEdits); const occs = idx["GS0060-024__2"]; expect(occs).toEqual([ { wk: "06/04/2026", j: 2, r: "GS0060-024", cl: "Ores", act: "rep", dt: "11h", al: false }, ]); }); });
Pour optimiser l’expérience mobile, nous allons :
Ajoute un fichier CSS ou des styles inline pour le responsive :
css/* Dans ton fichier CSS ou dans les styles inline */ @media (max-width: 768px) { /* Réduire les tailles de police et les paddings */ .bulterys-planning { font-size: 10px !important; } /* Adapter la largeur des colonnes */ .day-column { min-width: 120px !important; } /* Rendre les boutons plus grands pour le tactile */ button { padding: 8px 12px !important; font-size: 12px !important; } /* Adapter la modale pour mobile */ .edit-modal { width: 95vw !important; max-width: none !important; } }
Pour améliorer l’expérience tactile, utilise des événements touch et des bibliothèques comme react-swipeable pour les gestes de glissement.
bashnpm install react-swipeable
jsximport { useSwipeable } from 'react-swipeable'; // Dans un composant de cellule : const SwipeableCell = ({ children, onSwipeLeft, onSwipeRight }) => { const handlers = useSwipeable({ onSwipedLeft: () => onSwipeLeft(), onSwipedRight: () => onSwipeRight(), trackMouse: true, }); return ( <div {...handlers} style={{ touchAction: 'pan-y' }}> {children} </div> ); };
jsx// Dans la grille, enveloppe les cellules avec SwipeableCell : <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })} onSwipeRight={() => {}} > {/* Contenu de la cellule */} </SwipeableCell>
Pour que le drag-and-drop fonctionne bien sur mobile, utilise la bibliothèque react-dnd-touch-backend à la place de HTML5Backend.
bashnpm install react-dnd-touch-backend
jsx// App.js import { DndProvider } from 'react-dnd'; import { TouchBackend } from 'react-dnd-touch-backend'; import BulterysPlanning from './BulterysPlanning'; function App() { // Détecter si l'utilisateur est sur mobile const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); return ( <DndProvider backend={isMobile ? TouchBackend : HTML5Backend}> <BulterysPlanning /> </DndProvider> ); }
select natifs pour une meilleure expérience tactile.jsx// Dans EditModal, adapter les styles pour mobile : const C = { ov: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.72)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" }, bx: { background: "#141a24", border: "1px solid #2a3a50", borderRadius: 6, width: "95vw", maxWidth: "none", maxHeight: "85vh", overflow: "auto", fontFamily: "'Courier New', monospace", }, // ... (autres styles adaptés pour mobile) ip: { background: "#0a1018", border: "1px solid #1e2a3a", borderRadius: 3, color: "#c0ccd8", fontSize: 14, // Plus grand pour mobile padding: "8px 12px", // Plus de padding pour le tactile outline: "none", width: "100%", // Pleine largeur sur mobile boxSizing: "border-box", }, };
daySummary, makeKey, etc.Prochaine étape : Si tu veux, je peux te fournir un exemple complet d’intégration de l’une de ces fonctionnalités dans ton code existant, ou t’aider à déployer et tester ces changements. Dis-moi ce qui t’intéresse ! 😊


Voici un résumé clair et prêt à copier-coller pour intégrer toutes les optimisations dans ton projet existant, ainsi qu’un guide pour les tester.
bashnpm install react-dnd react-dnd-html5-backend react-dnd-touch-backend react-swipeable react-window @testing-library/react @testing-library/jest-dom
jsximport { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { TouchBackend } from 'react-dnd-touch-backend'; import BulterysPlanning from './BulterysPlanning'; function App() { const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); return ( <DndProvider backend={isMobile ? TouchBackend : HTML5Backend}> <BulterysPlanning /> </DndProvider> ); } export default App;
jsximport { useDrag, useDrop } from 'react-dnd'; const ItemTypes = { OCCUPATION: 'occupation' }; const DraggableOccupation = ({ occ, ressource, semaine, jour, dayLabel, onDrop, children }) => { const [{ isDragging }, drag] = useDrag(() => ({ type: ItemTypes.OCCUPATION, item: { occ, ressource, semaine, jour, dayLabel }, end: (item, monitor) => { if (monitor.didDrop()) onDrop(item, monitor.getDropResult()); }, collect: (monitor) => ({ isDragging: !!monitor.isDragging() }), })); return <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1, cursor: 'move' }}>{children}</div>; }; const DroppableCell = ({ ressource, semaine, jour, dayLabel, onDrop, children }) => { const [{ isOver }, drop] = useDrop(() => ({ accept: ItemTypes.OCCUPATION, drop: () => ({ ressource, semaine, jour, dayLabel }), collect: (monitor) => ({ isOver: !!monitor.isOver() }), })); return ( <div ref={drop} style={{ background: isOver ? 'rgba(40, 100, 180, 0.15)' : undefined }}> {children} </div> ); };
jsxconst handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; if (srcRessource === destRessource && srcJour === destJour) return; setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); const srcOccs = newEdits[srcKey] || getOcc(buildMergedIdx(srcSemaine, prev), srcRessource, srcJour); const filteredSrcOccs = srcOccs.filter(o => o !== occ); const destOccs = newEdits[destKey] || getOcc(buildMergedIdx(destSemaine, prev), destRessource, destJour); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; if (filteredSrcOccs.length > 0) newEdits[srcKey] = filteredSrcOccs; else delete newEdits[srcKey]; newEdits[destKey] = updatedDestOccs; return newEdits; }); }, []);
jsx// Dans la boucle de rendu des cellules : <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation}> {occs.map((occ, oi) => ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation}> {/* Contenu de l'occupation */} </DraggableOccupation> ))} </DroppableCell>
react-windowjsximport { FixedSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; // Remplace la table par : <div style={{ height: 'calc(100vh - 200px)' }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} rowCount={allRows.length} height={height} width={width} columnWidth={150} rowHeight={30}> {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") return <div style={style}>{SEC_LABELS[row.sec]}</div>; // Logique de rendu des cellules... }} </Grid> )} </AutoSizer> </div>
daySummaryjavascript// __tests__/daySummary.test.js const { daySummary, buildMergedIdx } = require('../BulterysPlanning'); describe('daySummary', () => { test('calcule les résumés par jour', () => { const mockOccupations = [ { wk: "06/04/2026", j: 2, r: "GS0060-024", cl: "Fluvius", act: "liv", al: false }, { wk: "06/04/2026", j: 2, r: "GS0060-025", cl: "Ores", act: "rep", al: true }, ]; const idx = buildMergedIdx("06/04/2026", {}); const summary = daySummary(idx, 2); expect(summary).toEqual({ liv: 1, rep: 1, plein: 0, entretien: 0, alertes: 1 }); }); });
css@media (max-width: 768px) { .bulterys-planning { font-size: 10px !important; } .day-column { min-width: 120px !important; } button { padding: 8px 12px !important; font-size: 12px !important; } .edit-modal { width: 95vw !important; } .edit-modal input { font-size: 14px !important; padding: 8px 12px !important; } }
react-swipeablejsximport { useSwipeable } from 'react-swipeable'; const SwipeableCell = ({ onSwipeLeft, children }) => { const handlers = useSwipeable({ onSwipedLeft: onSwipeLeft, trackMouse: true }); return <div {...handlers}>{children}</div>; }; // Utilisation : <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })}> {/* Contenu de la cellule */} </SwipeableCell>
Desktop :
Mobile :
Installer Jest (si ce n’est pas déjà fait) :
bashnpm install --save-dev jest
Lancer les tests :
bashnpx jest
daySummary, makeKey).npm install react-dnd react-dnd-html5-backend react-dnd-touch-backend react-swipeable react-window jestBulterysPlanning avec DndProvider dans App.js.AutoSizer + Grid.__tests__/daySummary.test.js, etc.Voici un exemple complet pour intégrer le drag-and-drop dans une cellule de ta grille (à adapter selon ton code existant) :
jsx// BulterysPlanning.js (extraits clés) import { useDrag, useDrop } from 'react-dnd'; import { FixedSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useSwipeable } from 'react-swipeable'; const ItemTypes = { OCCUPATION: 'occupation' }; const DraggableOccupation = ({ occ, ressource, semaine, jour, dayLabel, onDrop, children }) => { const [{ isDragging }, drag] = useDrag(() => ({ type: ItemTypes.OCCUPATION, item: { occ, ressource, semaine, jour, dayLabel }, end: (item, monitor) => monitor.didDrop() && onDrop(item, monitor.getDropResult()), collect: (monitor) => ({ isDragging: !!monitor.isDragging() }), })); return <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1, cursor: 'move' }}>{children}</div>; }; const DroppableCell = ({ ressource, semaine, jour, dayLabel, onDrop, children }) => { const [{ isOver }, drop] = useDrop(() => ({ accept: ItemTypes.OCCUPATION, drop: () => ({ ressource, semaine, jour, dayLabel }), collect: (monitor) => ({ isOver: !!monitor.isOver() }), })); return <div ref={drop} style={{ background: isOver ? 'rgba(40, 100, 180, 0.15)' : undefined }}>{children}</div>; }; const SwipeableCell = ({ onSwipeLeft, children }) => { const handlers = useSwipeable({ onSwipedLeft: onSwipeLeft, trackMouse: true }); return <div {...handlers}>{children}</div>; }; export default function BulterysPlanning() { // ... (ton code existant) const handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; if (srcRessource === destRessource && srcJour === destJour) return; setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); const srcOccs = newEdits[srcKey] || getOcc(buildMergedIdx(srcSemaine, prev), srcRessource, srcJour); const filteredSrcOccs = srcOccs.filter(o => o !== occ); const destOccs = newEdits[destKey] || getOcc(buildMergedIdx(destSemaine, prev), destRessource, destJour); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; if (filteredSrcOccs.length > 0) newEdits[srcKey] = filteredSrcOccs; else delete newEdits[srcKey]; newEdits[destKey] = updatedDestOccs; return newEdits; }); }, []); // Rendu de la grille avec virtualisation return ( <div className="bulterys-planning"> <div style={{ height: 'calc(100vh - 200px)' }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} rowCount={allRows.length} height={height} width={width} columnWidth={150} rowHeight={30}> {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") return <div style={style}>{SEC_LABELS[row.sec]}</div>; const { res } = row; const dayIndex = columnIndex - 2; if (dayIndex < 0) return <div style={style}>{res.id}</div>; // Colonne "Ressource" const occs = getOcc(occIdx, res.id, dayIndex); return ( <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })}> <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation}> {occs.map((occ, oi) => ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation}> <div style={{ background: getClientStyle(occ.cl, occ.ck).bg, padding: '2px 4px', marginBottom: 1 }}> {occ.act && <span>{ACT_BADGE[occ.act].lb}</span>} <span>{occ.cl}</span> </div> </DraggableOccupation> ))} </DroppableCell> </SwipeableCell> ); }} </Grid> )} </AutoSizer> </div> </div> ); }
npx jest.Si tu as besoin d’aide pour adapter un morceau spécifique ou pour déployer, fais-moi signe ! 😊
Voici une analyse détaillée et constructive de la réaction proposée dans le lien, ainsi que des suggestions pour l’adapter à ton contexte spécifique (Bulterys/React) et des points d’amélioration possibles.
Structure claire et modulaire :
Gestion des données optimisée :
useFetchData (pour récupérer les données) et usePlanningLogic (pour la logique métier) est une bonne idée pour isoler les responsabilités.React.Query pour la gestion des requêtes API est moderne et efficace (cache, rechargement, etc.).Approche progressive :
localStorage comme fallback en cas de déconnexion est pertinente pour un outil terrain.Focus sur l’UX :
React.Query ou localStorage) pour réduire les appels API.javascriptconst fetchAirtableData = async ({ pageSize = 100, view = "Grid view" }) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?maxRecords=${pageSize}&view=${view}`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); const { records, offset } = await response.json(); return { data: records.map(r => r.fields), nextOffset: offset }; };
typescriptinterface OccupationEdit { data: Occupation; timestamp: number; userId: string; // Identifiant de l'utilisateur }
react-swipeable pour ajouter des gestes tactiles :
jsxconst SwipeableCell = ({ onSwipeLeft, children }) => { const handlers = useSwipeable({ onSwipedLeft: onSwipeLeft }); return <div {...handlers}>{children}</div>; };
javascripttest("une ressource ne peut pas avoir deux occupations simultanées", () => { const occs = [ { r: "GS0060-024", j: 2, cl: "Client A", act: "liv" }, { r: "GS0060-024", j: 2, cl: "Client B", act: "rep" }, // Conflit ! ]; expect(() => validateOccupations(occs)).toThrow("Conflit de planning"); });
localStorage + futur AirtableReact.Query + API RESTReact.Query pour Airtable + fallback localStorage.react-dndreact-windowreact-swipeable pour des gestes avancés.daySummary, makeKeySEMAINES, RESSOURCES, OCCUPATIONS) est claire et bien adaptée à ton domaine métier.react-dnd est déjà fonctionnelle et bien intégrée.react-window est optimale pour les performances.Intégration Airtable :
React.Query pour gérer les requêtes, avec un cache agressif.Gestion des Conflits :
timestamp et un userId à chaque modification pour détecter les conflits.Gestes Tactiles :
react-swipeable pour permettre :
Tests Métiers :
UI/UX :
Voici comment adapter ta solution pour une synchronisation robuste avec Airtable :
javascript// hooks/useAirtableSync.js import { useQuery, useMutation, useQueryClient } from 'react-query'; const API_KEY = 'ton_api_key'; const BASE_ID = 'ta_base_id'; const fetchOccupations = async ({ queryKey }) => { const [_, semaine] = queryKey; const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records } = await response.json(); return records.map(r => r.fields); }; const updateOccupation = async (occupation) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations/${occupation.id}`; const response = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` }, body: JSON.stringify({ fields: occupation }), }); if (!response.ok) throw new Error("Échec de la mise à jour"); return response.json(); }; export const useAirtableSync = (semaine) => { const queryClient = useQueryClient(); // Récupérer les données depuis Airtable const { data: occupations, isLoading, error } = useQuery( ['occupations', semaine], fetchOccupations, { staleTime: 1000 * 60 * 5 } // Cache de 5 minutes ); // Mettre à jour une occupation const { mutate: saveOccupation } = useMutation(updateOccupation, { onSuccess: () => queryClient.invalidateQueries(['occupations', semaine]), }); // Synchroniser les modifications locales avec Airtable const syncEdits = async (edits) => { for (const [key, occs] of Object.entries(edits)) { const { ressource, semaine, jour } = parseKey(key); for (const occ of occs) { await saveOccupation({ ...occ, id: `${ressource}-${semaine}-${jour}` }); } } }; return { occupations, isLoading, error, syncEdits }; };
La réaction proposée dans le lien est globalement intelligente et moderne, mais elle nécessite des adaptations pour :
Recommandation finale :
React.Query).react-swipeable).Si tu veux, je peux te fournir un exemple complet pour l’une de ces étapes (ex : intégration de React.Query avec Airtable, ou implémentation des gestes tactiles). Dis-moi ce qui t’intéresse ! 😊
Voici une version améliorée et complète de ton planning Bulterys, intégrant toutes les recommandations précédentes (synchronisation Airtable, gestion des conflits, gestes tactiles, tests métiers) tout en conservant tes optimisations existantes (drag-and-drop, virtualisation).
textsrc/ ├── components/ │ ├── BulterysPlanning.jsx # Composant principal │ ├── EditModal.jsx # Modale d'édition │ ├── DraggableOccupation.jsx # Composant pour le drag-and-drop │ └── DroppableCell.jsx # Composant pour le drop ├── hooks/ │ ├── useAirtableSync.js # Synchronisation avec Airtable │ ├── usePlanningLogic.js # Logique métier │ └── useEdits.js # Gestion des modifications locales ├── utils/ │ ├── dataTransformers.js # Transformation des données Airtable │ ├── validators.js # Validation des règles métiers │ └── testUtils.js # Utilitaires pour les tests ├── __tests__/ │ ├── planningLogic.test.js # Tests pour la logique métier │ └── airtableSync.test.js # Tests pour la synchronisation └── App.js # Point d'entrée
App.js (Point d'Entrée)jsximport { DndProvider } from 'react-dnd'; import { TouchBackend } from 'react-dnd-touch-backend'; import { HTML5Backend } from 'react-dnd-html5-backend'; import BulterysPlanning from './components/BulterysPlanning'; function App() { const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); return ( <DndProvider backend={isMobile ? TouchBackend : HTML5Backend}> <BulterysPlanning /> </DndProvider> ); } export default App;
hooks/useAirtableSync.js (Synchronisation avec Airtable)jsximport { useQuery, useMutation, useQueryClient } from 'react-query'; const API_KEY = 'ton_api_key'; const BASE_ID = 'ta_base_id'; const fetchOccupations = async ({ queryKey }) => { const [_, semaine] = queryKey; const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records } = await response.json(); return records.map(r => ({ ...r.fields, id: r.id, // Conserve l'ID Airtable pour les mises à jour })); }; const updateOccupation = async (occupation) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations/${occupation.id}`; const response = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ fields: occupation }), }); if (!response.ok) throw new Error("Échec de la mise à jour"); return response.json(); }; const createOccupation = async (occupation) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ fields: occupation }), }); if (!response.ok) throw new Error("Échec de la création"); return response.json(); }; export const useAirtableSync = (semaine) => { const queryClient = useQueryClient(); // Récupérer les occupations depuis Airtable const { data: occupations = [], isLoading, error } = useQuery( ['occupations', semaine], fetchOccupations, { staleTime: 1000 * 60 * 5 } // Cache de 5 minutes ); // Mettre à jour une occupation existante const { mutate: updateOccupationMutation } = useMutation(updateOccupation, { onSuccess: () => queryClient.invalidateQueries(['occupations', semaine]), }); // Créer une nouvelle occupation const { mutate: createOccupationMutation } = useMutation(createOccupation, { onSuccess: () => queryClient.invalidateQueries(['occupations', semaine]), }); // Synchroniser les modifications locales avec Airtable const syncEdits = async (edits) => { for (const [key, occs] of Object.entries(edits)) { for (const occ of occs) { if (occ.id) { await updateOccupationMutation(occ); // Mise à jour si ID existe } else { await createOccupationMutation(occ); // Création sinon } } } }; return { occupations, isLoading, error, syncEdits, updateOccupation: updateOccupationMutation, createOccupation: createOccupationMutation, }; };
hooks/usePlanningLogic.js (Logique Métier)jsximport { useState, useMemo, useCallback } from 'react'; import { validateOccupations } from '../utils/validators'; export const usePlanningLogic = (semaine, occupations = [], edits = {}) => { // Fusionner les données Airtable et les modifications locales const mergedData = useMemo(() => { const idx = {}; // Ajouter les occupations depuis Airtable occupations.forEach(occ => { const key = `${occ.r}__${occ.j}`; if (!idx[key]) idx[key] = []; idx[key].push(occ); }); // Appliquer les modifications locales Object.entries(edits).forEach(([key, occs]) => { const { ressource, jour } = parseKey(key); idx[`${ressource}__${jour}`] = occs; }); return idx; }, [occupations, edits]); // Valider les occupations (ex: pas de double réservation) const validate = useCallback((occs) => { try { validateOccupations(occs); return { valid: true }; } catch (error) { return { valid: false, message: error.message }; } }, []); // Calculer les résumés par jour const daySummary = useCallback((jour) => { const acc = { liv: 0, rep: 0, plein: 0, entretien: 0, alertes: 0 }; Object.entries(mergedData).forEach(([key, occs]) => { if (key.endsWith(`__${jour}`)) { occs.forEach(occ => { if (occ.act === "liv") acc.liv++; else if (occ.act === "rep") acc.rep++; else if (occ.act === "plein") acc.plein++; else if (occ.act === "entretien" || occ.act === "atelier") acc.entretien++; if (occ.al || occ.ck === "al") acc.alertes++; }); } }); return acc; }, [mergedData]); return { mergedData, validate, daySummary }; };
utils/validators.js (Validation des Règles Métier)javascriptexport const validateOccupations = (occs) => { const resources = {}; occs.forEach(occ => { const key = `${occ.r}__${occ.j}`; // Clé unique ressource + jour if (resources[key]) { throw new Error(`Conflit de planning: ${occ.r} est déjà réservée le jour ${occ.j}`); } resources[key] = true; }); }; export const validateOccupation = (occ) => { if (!occ.cl?.trim()) throw new Error("Le client est requis"); if (!occ.act) throw new Error("L'action est requise"); if (occ.al && !occ.dt) throw new Error("Les alertes nécessitent une description"); };
components/BulterysPlanning.jsx (Composant Principal)jsximport { useState, useMemo, useCallback } from 'react'; import { FixedSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useSwipeable } from 'react-swipeable'; import { useAirtableSync } from '../hooks/useAirtableSync'; import { usePlanningLogic } from '../hooks/usePlanningLogic'; import DraggableOccupation from './DraggableOccupation'; import DroppableCell from './DroppableCell'; import EditModal from './EditModal'; const BulterysPlanning = () => { const [selWk, setSelWk] = useState("06/04/2026"); const [editCell, setEditCell] = useState(null); const [edits, setEdits] = useState({}); const [alertPanel, setAlertPanel] = useState(false); const [filterSec, setFilterSec] = useState("ALL"); // Synchronisation avec Airtable const { occupations, isLoading, error, syncEdits } = useAirtableSync(selWk); // Logique métier const { mergedData, validate, daySummary } = usePlanningLogic(selWk, occupations, edits); // Gestion des modifications locales const handleSaveCell = useCallback((ressource, semaine, jour, newOccs) => { const validation = validate(newOccs); if (!validation.valid) { alert(validation.message); return; } setEdits(prev => ({ ...prev, [makeKey(ressource, semaine, jour)]: newOccs, })); }, [validate]); // Gestion du drag-and-drop const handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; if (srcRessource === destRessource && srcJour === destJour) return; setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); // Supprimer de la source const srcOccs = newEdits[srcKey] || (mergedData[`${srcRessource}__${srcJour}`] || []); const filteredSrcOccs = srcOccs.filter(o => !(o.cl === occ.cl && o.act === occ.act && o.dt === occ.dt)); // Ajouter à la destination const destOccs = newEdits[destKey] || (mergedData[`${destRessource}__${destJour}`] || []); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; // Valider avant de sauvegarder const validation = validate([...filteredSrcOccs, ...updatedDestOccs]); if (!validation.valid) { alert(validation.message); return prev; } // Mettre à jour les edits if (filteredSrcOccs.length > 0) newEdits[srcKey] = filteredSrcOccs; else delete newEdits[srcKey]; newEdits[destKey] = updatedDestOccs; return newEdits; }); }, [mergedData, validate]); // Synchronisation avec Airtable const handleSync = useCallback(async () => { try { await syncEdits(edits); setEdits({}); // Réinitialiser après synchronisation alert("Synchronisation réussie !"); } catch (error) { alert(`Erreur de synchronisation: ${error.message}`); } }, [edits, syncEdits]); // Filtrer les ressources par section const allRows = useMemo(() => { const rows = []; for (const sec of SECS_ORDER) { if (filterSec !== "ALL" && filterSec !== sec) continue; const items = sec === "VEH" ? RESSOURCES.filter(r => r.sec === sec) : GROUPES.filter(g => g.sec === sec); if (!items.length) continue; rows.push({ type: "header", sec }); for (const res of items) rows.push({ type: "row", res, sec }); } return rows; }, [filterSec]); // Rendu de la grille avec virtualisation return ( <div className="bulterys-planning"> {/* Barre de navigation */} <div className="topbar"> <span>BULTERYS OPS</span> {SEMAINES.map(s => ( <button key={s.wk} onClick={() => setSelWk(s.wk)} className={selWk === s.wk ? "active" : ""}> {s.lb} </button> ))} {Object.keys(edits).length > 0 && ( <> <span>{Object.keys(edits).length} modification(s) non synchronisée(s)</span> <button onClick={handleSync}>Synchroniser</button> </> )} <button onClick={() => setAlertPanel(!alertPanel)}> Alertes ({ALERTES_TECHNIQUES.filter(a => a.wk === selWk).length}) </button> </div> {/* Grille virtualisée */} <div style={{ height: 'calc(100vh - 200px)' }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} rowCount={allRows.length} height={height} width={width} columnWidth={150} rowHeight={30} > {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") { return <div style={{ ...style, background: SEC_COLORS[row.sec] }}>{SEC_LABELS[row.sec]}</div>; } const { res } = row; const dayIndex = columnIndex - 2; if (dayIndex < 0) return <div style={style}>{res.id}</div>; // Colonne "Ressource" if (dayIndex === 0) return <div style={style}>{res.sub || res.modele}</div>; // Colonne "Modèle" const occs = mergedData[`${res.id}__${dayIndex}`] || []; return ( <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > {occs.map((occ, oi) => ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > <div style={{ background: getClientStyle(occ.cl, occ.ck).bg, padding: '2px 4px' }}> {occ.act && <span>{ACT_BADGE[occ.act].lb}</span>} <span>{occ.cl}</span> </div> </DraggableOccupation> ))} </DroppableCell> ); }} </Grid> )} </AutoSizer> </div> {/* Modale d'édition */} {editCell && ( <EditModal cell={editCell} occs={mergedData[`${editCell.ressource}__${editCell.jour}`] || []} onSave={(newOccs) => { handleSaveCell(editCell.ressource, selWk, editCell.jour, newOccs); setEditCell(null); }} onClose={() => setEditCell(null)} /> )} </div> ); };
components/EditModal.jsx (Modale d'Édition)jsximport { useState, useEffect, useCallback } from 'react'; import { useSwipeable } from 'react-swipeable'; const EditModal = ({ cell, occs, onSave, onClose }) => { const [editedOccs, setEditedOccs] = useState([...occs]); const [newOcc, setNewOcc] = useState({ cl: '', act: '', dt: '', ck: '' }); // Gestion du swipe pour fermer la modale const swipeHandlers = useSwipeable({ onSwipedDown: onClose, trackMouse: true, }); const handleAdd = useCallback(() => { if (!newOcc.cl.trim()) return; setEditedOccs([...editedOccs, newOcc]); setNewOcc({ cl: '', act: '', dt: '', ck: '' }); }, [newOcc, editedOccs]); const handleDelete = useCallback((index) => { setEditedOccs(editedOccs.filter((_, i) => i !== index)); }, [editedOccs]); return ( <div className="modal-overlay" {...swipeHandlers}> <div className="modal-content"> <h3>Éditer {cell.ressource} - {cell.dayLabel}</h3> <div className="occupations-list"> {editedOccs.map((occ, index) => ( <div key={index} className="occupation-item"> <span>{occ.cl} ({occ.act})</span> <button onClick={() => handleDelete(index)}>Supprimer</button> </div> ))} </div> <div className="new-occupation-form"> <input value={newOcc.cl} onChange={(e) => setNewOcc({ ...newOcc, cl: e.target.value })} placeholder="Client" /> <select value={newOcc.act} onChange={(e) => setNewOcc({ ...newOcc, act: e.target.value })} > {ACT_OPTIONS.map(opt => ( <option key={opt.v} value={opt.v}>{opt.lb}</option> ))} </select> <input value={newOcc.dt} onChange={(e) => setNewOcc({ ...newOcc, dt: e.target.value })} placeholder="Détails" /> <select value={newOcc.ck} onChange={(e) => setNewOcc({ ...newOcc, ck: e.target.value })} > {CK_OPTIONS.map(opt => ( <option key={opt.v} value={opt.v}>{opt.lb}</option> ))} </select> <button onClick={handleAdd}>Ajouter</button> </div> <div className="modal-actions"> <button onClick={onClose}>Annuler</button> <button onClick={() => onSave(editedOccs)}>Enregistrer</button> </div> </div> </div> ); };
components/DraggableOccupation.jsx et DroppableCell.jsxjsx// DraggableOccupation.jsx import { useDrag } from 'react-dnd'; export default function DraggableOccupation({ occ, ressource, semaine, jour, dayLabel, onDrop, children }) { const [{ isDragging }, drag] = useDrag(() => ({ type: 'OCCUPATION', item: { occ, ressource, semaine, jour, dayLabel }, end: (item, monitor) => { if (monitor.didDrop()) onDrop(item, monitor.getDropResult()); }, collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), })); return ( <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1, cursor: 'move' }}> {children} </div> ); }
jsx// DroppableCell.jsx import { useDrop } from 'react-dnd'; export default function DroppableCell({ ressource, semaine, jour, dayLabel, onDrop, children }) { const [{ isOver }, drop] = useDrop(() => ({ accept: 'OCCUPATION', drop: () => ({ ressource, semaine, jour, dayLabel }), collect: (monitor) => ({ isOver: !!monitor.isOver(), }), })); return ( <div ref={drop} style={{ background: isOver ? 'rgba(40, 100, 180, 0.15)' : undefined }}> {children} </div> ); }
utils/validators.test.jsjavascriptimport { validateOccupations, validateOccupation } from './validators'; describe('validateOccupations', () => { test('détecte les conflits de planning', () => { const occs = [ { r: 'GS0060-024', j: 2, cl: 'Client A', act: 'liv' }, { r: 'GS0060-024', j: 2, cl: 'Client B', act: 'rep' }, // Conflit ! ]; expect(() => validateOccupations(occs)).toThrow('Conflit de planning'); }); test('accepte les occupations sans conflit', () => { const occs = [ { r: 'GS0060-024', j: 2, cl: 'Client A', act: 'liv' }, { r: 'GS0060-025', j: 2, cl: 'Client B', act: 'rep' }, // OK, ressources différentes ]; expect(() => validateOccupations(occs)).not.toThrow(); }); }); describe('validateOccupation', () => { test('rejette une occupation sans client', () => { expect(() => validateOccupation({ cl: '', act: 'liv' })).toThrow('Le client est requis'); }); test('accepte une occupation valide', () => { expect(() => validateOccupation({ cl: 'Fluvius', act: 'liv', dt: '10h' })).not.toThrow(); }); });
hooks/usePlanningLogic.test.jsjavascriptimport { renderHook } from '@testing-library/react-hooks'; import { usePlanningLogic } from '../hooks/usePlanningLogic'; describe('usePlanningLogic', () => { const mockOccupations = [ { r: 'GS0060-024', j: 2, cl: 'Fluvius', act: 'liv', wk: '06/04/2026' }, ]; const mockEdits = { 'GS0060-024||06/04/2026||2': [ { r: 'GS0060-024', j: 2, cl: 'Ores', act: 'rep', wk: '06/04/2026' }, ], }; test('fusionne correctement les données', () => { const { result } = renderHook(() => usePlanningLogic('06/04/2026', mockOccupations, mockEdits) ); const mergedData = result.current.mergedData; expect(mergedData['GS0060-024__2']).toEqual([ { r: 'GS0060-024', j: 2, cl: 'Ores', act: 'rep', wk: '06/04/2026' }, // Edit local prime ]); }); test('calcule correctement les résumés par jour', () => { const { result } = renderHook(() => usePlanningLogic('06/04/2026', mockOccupations, mockEdits) ); const summary = result.current.daySummary(2); expect(summary).toEqual({ liv: 0, rep: 1, plein: 0, entretien: 0, alertes: 0 }); }); });
styles.css (ou styles inline)css/* Responsive */ @media (max-width: 768px) { .bulterys-planning { font-size: 10px !important; } .day-column { min-width: 100px !important; } button { padding: 8px 12px !important; font-size: 12px !important; } .edit-modal { width: 95vw !important; max-width: none !important; } .edit-modal input, .edit-modal select { font-size: 14px !important; padding: 8px 12px !important; } } /* Gestes tactiles */ .occupation-item { touch-action: pan-y; /* Permet le scroll vertical */ } .modal-overlay { touch-action: none; /* Désactive le scroll en arrière-plan */ }
localStorage seulementReact.Query + cache localbashnpx jest
Besoin d’aide pour une étape spécifique ? Dis-moi ce que tu veux approfondir (ex : tests, déploiement, ou adaptation d’un composant) ! 😊
Après avoir analysé en détail la proposition du lien et ton contexte spécifique (Bulterys, React, Airtable, besoins opérationnels), voici une synthèse critique et améliorée des recommandations, avec des extraits de code prêts à l’emploi et optimisés pour ton cas.
✅ Architecture modulaire :
La séparation en composants/hooks/services est une excellente pratique, surtout pour un projet évolutif comme le tien.
→ À adapter : Conserver ta structure actuelle (un seul fichier pour commencer), mais extraire progressivement la logique métier dans des hooks dédiés (ex: usePlanningLogic).
✅ Utilisation de React Query :
Idéal pour gérer les requêtes Airtable (cache, rechargement, optimisme).
→ À adapter : Intégrer React Query uniquement pour la synchronisation Airtable, en gardant localStorage comme fallback.
✅ Approche progressive :
Commencer par un mock des données avant d’intégrer Airtable.
→ À adapter : Utiliser tes données statiques (SEMAINES, RESSOURCES) comme base, puis ajouter la sync Airtable en phase 2.
✅ Focus sur l’UX : Filtres dynamiques, drag-and-drop, et virtualisation sont déjà implémentés dans ta solution. → À adapter : Améliorer les feedbacks utilisateur (ex: messages d’erreur clairs, confirmations visuelles).
Problème dans la proposition :
localStorage, sans gestion des conflits ou des déconnexions.Solution adaptée pour toi :
javascript// hooks/useAirtableSync.js import { useQuery, useMutation, useQueryClient } from 'react-query'; const API_KEY = 'ton_api_key'; const BASE_ID = 'ta_base_id'; const PAGE_SIZE = 100; // Limite Airtable // Récupérer les occupations par pagination const fetchOccupations = async ({ queryKey, pageParam = 0 }) => { const [_, semaine] = queryKey; const offset = pageParam * PAGE_SIZE; const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?maxRecords=${PAGE_SIZE}&offset=${offset}&filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records, offset: nextOffset } = await response.json(); return { data: records.map(r => ({ ...r.fields, id: r.id })), nextOffset, }; }; // Mettre à jour une occupation const updateOccupation = async (occupation) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations/${occupation.id}`; const response = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` }, body: JSON.stringify({ fields: occupation }), }); if (!response.ok) throw new Error("Échec de la mise à jour"); return response.json(); }; export const useAirtableSync = (semaine) => { const queryClient = useQueryClient(); // Récupérer les occupations avec pagination const { data: occupations = [], isLoading, error, fetchNextPage } = useQuery( ['occupations', semaine], ({ queryKey, pageParam = 0 }) => fetchOccupations({ queryKey, pageParam }), { getNextPageParam: (lastPage) => lastPage.nextOffset || undefined, staleTime: 1000 * 60 * 5, // Cache de 5 minutes } ); // Mettre à jour une occupation const { mutate: updateOccupationMutation } = useMutation(updateOccupation, { onSuccess: () => queryClient.invalidateQueries(['occupations', semaine]), }); // Synchroniser les modifications locales const syncEdits = async (edits) => { for (const [key, occs] of Object.entries(edits)) { for (const occ of occs) { if (occ.id) { await updateOccupationMutation(occ); // Mise à jour si ID existe } else { // Logique pour créer une nouvelle occupation (à implémenter) console.warn("Création d'occupation non implémentée", occ); } } } }; return { occupations: occupations.pages.flatMap(p => p.data), isLoading, error, syncEdits }; };
Problème dans la proposition : Aucune mention des conflits si deux utilisateurs modifient la même cellule.
Solution adaptée pour toi :
javascript// utils/conflictResolver.js export const resolveConflicts = (localEdits, remoteOccupations) => { const conflicts = []; const resolvedEdits = { ...localEdits }; Object.entries(localEdits).forEach(([key, localOccs]) => { const { ressource, jour } = parseKey(key); const remoteOccs = remoteOccupations.filter(occ => occ.r === ressource && occ.j === jour); // Comparer les occupations locales et distantes localOccs.forEach(localOcc => { const remoteOcc = remoteOccs.find(occ => occ.cl === localOcc.cl && occ.act === localOcc.act); if (remoteOcc && remoteOcc.timestamp > localOcc.timestamp) { conflicts.push({ key, local: localOcc, remote: remoteOcc, }); // Supprimer l'édition locale en conflit delete resolvedEdits[key]; } }); }); return { resolvedEdits, conflicts }; };
Utilisation dans BulterysPlanning :
javascriptconst handleSync = async () => { try { const { resolvedEdits, conflicts } = resolveConflicts(edits, occupations); if (conflicts.length > 0) { alert(`Conflits détectés pour ${conflicts.length} modification(s). Les modifications distantes ont été conservées.`); } await syncEdits(resolvedEdits); setEdits({}); } catch (error) { alert(`Erreur de synchronisation: ${error.message}`); } };
Problème dans la proposition : Le design responsive est mentionné, mais sans détails sur les gestes tactiles (swipe, appui long).
Solution adaptée pour toi :
jsx// components/SwipeableCell.jsx import { useSwipeable } from 'react-swipeable'; export default function SwipeableCell({ onSwipeLeft, onSwipeRight, children }) { const handlers = useSwipeable({ onSwipedLeft: onSwipeLeft, onSwipedRight: onSwipeRight, trackMouse: true, // Pour aussi fonctionner sur desktop delta: 10, // Sensibilité du swipe }); return ( <div {...handlers} style={{ touchAction: 'pan-y' }}> {children} </div> ); }
Utilisation dans la grille :
jsx<SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })} onSwipeRight={() => {} /* Action alternative, ex: supprimer */} > {/* Contenu de la cellule */} </SwipeableCell>
Problème dans la proposition : Les tests proposés sont génériques et ne couvrent pas les règles spécifiques à Bulterys (ex: pas de double réservation).
Solution adaptée pour toi :
javascript// __tests__/planningRules.test.js import { validateOccupations } from '../utils/validators'; describe('Règles métiers de Bulterys', () => { test('une ressource ne peut pas être réservée deux fois le même jour', () => { const occs = [ { r: 'GS0060-024', j: 2, cl: 'Client A', act: 'liv' }, { r: 'GS0060-024', j: 2, cl: 'Client B', act: 'rep' }, // Conflit ! ]; expect(() => validateOccupations(occs)).toThrow('Conflit de planning'); }); test('une alerte technique bloque les réservations', () => { const occs = [ { r: 'GS0060-024', j: 2, cl: 'Client A', act: 'liv', al: true }, // Alerte active { r: 'GS0060-024', j: 2, cl: 'Client B', act: 'rep' }, // Doit échouer ]; expect(() => validateOccupations(occs)).toThrow('Ressource en alerte'); }); test('les véhicules en maintenance ne peuvent pas être réservés', () => { const occs = [ { r: 'GS0060-024', j: 2, cl: 'Atelier', act: 'atelier', ck: 'mn' }, { r: 'GS0060-024', j: 2, cl: 'Client A', act: 'liv' }, // Doit échouer ]; expect(() => validateOccupations(occs)).toThrow('Ressource en maintenance'); }); });
BulterysPlanning.jsx (Version Améliorée)jsximport { useState, useMemo, useCallback } from 'react'; import { FixedSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useAirtableSync } from '../hooks/useAirtableSync'; import { usePlanningLogic } from '../hooks/usePlanningLogic'; import SwipeableCell from './SwipeableCell'; import DraggableOccupation from './DraggableOccupation'; import DroppableCell from './DroppableCell'; import EditModal from './EditModal'; import { resolveConflicts } from '../utils/conflictResolver'; const BulterysPlanning = () => { const [selWk, setSelWk] = useState("06/04/2026"); const [editCell, setEditCell] = useState(null); const [edits, setEdits] = useState({}); const [alertPanel, setAlertPanel] = useState(false); const [filterSec, setFilterSec] = useState("ALL"); // Synchronisation avec Airtable const { occupations, isLoading, error, syncEdits } = useAirtableSync(selWk); // Logique métier const { mergedData, validate, daySummary } = usePlanningLogic(selWk, occupations, edits); // Gestion des modifications locales const handleSaveCell = useCallback((ressource, semaine, jour, newOccs) => { const validation = validate(newOccs); if (!validation.valid) { alert(validation.message); return; } setEdits(prev => ({ ...prev, [makeKey(ressource, semaine, jour)]: newOccs, })); }, [validate]); // Gestion du drag-and-drop const handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; if (srcRessource === destRessource && srcJour === destJour) return; setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); // Supprimer de la source const srcOccs = newEdits[srcKey] || (mergedData[`${srcRessource}__${srcJour}`] || []); const filteredSrcOccs = srcOccs.filter(o => !(o.cl === occ.cl && o.act === occ.act && o.dt === occ.dt)); // Ajouter à la destination const destOccs = newEdits[destKey] || (mergedData[`${destRessource}__${destJour}`] || []); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; // Valider avant de sauvegarder const validation = validate([...filteredSrcOccs, ...updatedDestOccs]); if (!validation.valid) { alert(validation.message); return prev; } // Mettre à jour les edits if (filteredSrcOccs.length > 0) newEdits[srcKey] = filteredSrcOccs; else delete newEdits[srcKey]; newEdits[destKey] = updatedDestOccs; return newEdits; }); }, [mergedData, validate]); // Synchronisation avec Airtable (avec gestion des conflits) const handleSync = useCallback(async () => { try { const { resolvedEdits, conflicts } = resolveConflicts(edits, occupations); if (conflicts.length > 0) { alert(`Conflits détectés pour ${conflicts.length} modification(s). Les modifications distantes ont été conservées.`); } await syncEdits(resolvedEdits); setEdits({}); alert("Synchronisation réussie !"); } catch (error) { alert(`Erreur de synchronisation: ${error.message}`); } }, [edits, occupations, syncEdits]); // Filtrer les ressources par section const allRows = useMemo(() => { const rows = []; for (const sec of SECS_ORDER) { if (filterSec !== "ALL" && filterSec !== sec) continue; const items = sec === "VEH" ? RESSOURCES.filter(r => r.sec === sec) : GROUPES.filter(g => g.sec === sec); if (!items.length) continue; rows.push({ type: "header", sec }); for (const res of items) rows.push({ type: "row", res, sec }); } return rows; }, [filterSec]); // Rendu de la grille avec virtualisation et gestes tactiles return ( <div className="bulterys-planning"> {/* Barre de navigation */} <div className="topbar"> <span>BULTERYS OPS</span> {SEMAINES.map(s => ( <button key={s.wk} onClick={() => setSelWk(s.wk)} className={selWk === s.wk ? "active" : ""}> {s.lb} </button> ))} {Object.keys(edits).length > 0 && ( <> <span>{Object.keys(edits).length} modification(s) non synchronisée(s)</span> <button onClick={handleSync}>Synchroniser</button> </> )} <button onClick={() => setAlertPanel(!alertPanel)}> Alertes ({ALERTES_TECHNIQUES.filter(a => a.wk === selWk).length}) </button> </div> {/* Grille virtualisée avec gestes tactiles */} <div style={{ height: 'calc(100vh - 200px)' }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} rowCount={allRows.length} height={height} width={width} columnWidth={150} rowHeight={30} > {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") { return <div style={{ ...style, background: SEC_COLORS[row.sec] }}>{SEC_LABELS[row.sec]}</div>; } const { res } = row; const dayIndex = columnIndex - 2; if (dayIndex < 0) return <div style={style}>{res.id}</div>; // Colonne "Ressource" if (dayIndex === 0) return <div style={style}>{res.sub || res.modele}</div>; // Colonne "Modèle" const occs = mergedData[`${res.id}__${dayIndex}`] || []; return ( <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })} > <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > {occs.map((occ, oi) => ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > <div style={{ background: getClientStyle(occ.cl, occ.ck).bg, padding: '2px 4px' }}> {occ.act && <span>{ACT_BADGE[occ.act].lb}</span>} <span>{occ.cl}</span> </div> </DraggableOccupation> ))} </DroppableCell> </SwipeableCell> ); }} </Grid> )} </AutoSizer> </div> {/* Modale d'édition */} {editCell && ( <EditModal cell={editCell} occs={mergedData[`${editCell.ressource}__${editCell.jour}`] || []} onSave={(newOccs) => { handleSaveCell(editCell.ressource, selWk, editCell.jour, newOccs); setEditCell(null); }} onClose={() => setEditCell(null)} /> )} </div> ); };
localStorage seulementReact Query + gestion des conflitsIntègre d’abord la synchronisation Airtable :
useAirtableSync.js et teste la récupération des données.resolveConflicts).Améliore l’UX mobile :
SwipeableCell et teste sur un appareil mobile.Étends les tests :
planningRules.test.js).npx jest pour les exécuter.Déploie progressivement :
Besoin d’aide pour une étape spécifique ? Par exemple :
useAirtableSync à ton compte Airtable.Dis-moi ce qui t’intéresse, et je te fournirai un code détaillé et prêt à l’emploi ! 😊
La proposition du lien que tu as partagé est très complète et bien structurée, avec une approche moderne et scalable pour un projet React. Voici une analyse critique détaillée, adaptée à ton contexte spécifique (Bulterys, Airtable, besoins opérationnels), ainsi que des recommandations pour l’intégrer à ta solution existante.
React Query pour la gestion des données, ce qui est idéal pour les applications avec un backend comme Airtable.→ À adapter pour toi :
useAirtableSync, usePlanningLogic).React Query uniquement pour la synchronisation avec Airtable, en gardant localStorage comme fallback pour le mode hors ligne.→ À adapter pour toi :
React Query pour la sync Airtable, mais garde ta logique actuelle pour les données statiques (SEMAINES, RESSOURCES).localStorage.javascript// Exemple d'intégration de React Query avec fallback const { data: occupations = [], isLoading, error } = useQuery( ['occupations', semaine], () => fetchOccupations(semaine), { onError: () => { // Fallback sur localStorage si Airtable échoue const localData = localStorage.getItem(`occupations_${semaine}`); return localData ? JSON.parse(localData) : []; }, staleTime: 1000 * 60 * 5, // Cache de 5 minutes } );
filterByFormula).→ À adapter pour toi :
javascript// Exemple de requête paginée et filtrée const fetchOccupations = async ({ queryKey, pageParam = 0 }) => { const [_, semaine] = queryKey; const offset = pageParam * 100; // Pagination const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?maxRecords=100&offset=${offset}&filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records, offset: nextOffset } = await response.json(); return { data: records.map(r => ({ ...r.fields, id: r.id })), nextOffset, }; };
timestamps pour résoudre les conflits.→ À adapter pour toi :
timestamp et un userId à chaque modification pour détecter les conflits.javascript// Exemple de résolution de conflits const resolveConflicts = (localEdits, remoteOccupations) => { const conflicts = []; const resolvedEdits = { ...localEdits }; Object.entries(localEdits).forEach(([key, localOccs]) => { const { ressource, jour } = parseKey(key); const remoteOccs = remoteOccupations.filter(occ => occ.r === ressource && occ.j === jour); localOccs.forEach(localOcc => { const remoteOcc = remoteOccs.find(occ => occ.cl === localOcc.cl && occ.act === localOcc.act); if (remoteOcc && remoteOcc.timestamp > localOcc.timestamp) { conflicts.push({ key, local: localOcc, remote: remoteOcc }); delete resolvedEdits[key]; // Supprimer l'édition locale en conflit } }); }); return { resolvedEdits, conflicts }; };
react-swipeable.→ À adapter pour toi :
react-swipeable pour les gestes tactiles (swipe pour éditer/supprimer).jsx// Exemple de composant SwipeableCell import { useSwipeable } from 'react-swipeable'; const SwipeableCell = ({ onSwipeLeft, children }) => { const handlers = useSwipeable({ onSwipedLeft: onSwipeLeft, trackMouse: true, }); return <div {...handlers}>{children}</div>; }; // Utilisation dans la grille <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex })}> {children} </SwipeableCell>
→ À adapter pour toi :
jest et @testing-library/react pour tester les composants et hooks.javascript// Exemple de test pour les règles métiers test("une ressource ne peut pas être réservée deux fois le même jour", () => { const occs = [ { r: "GS0060-024", j: 2, cl: "Client A", act: "liv" }, { r: "GS0060-024", j: 2, cl: "Client B", act: "rep" }, // Conflit ! ]; expect(() => validateOccupations(occs)).toThrow("Conflit de planning"); });
La proposition suppose une architecture modulaire dès le départ, mais ton projet est actuellement structuré en un seul fichier. Recommandation :
usePlanningLogic, useAirtableSync).La proposition ne mentionne pas comment gérer les alertes techniques (ex: un véhicule en panne ne peut pas être réservé). → Solution adaptée :
javascript// Dans usePlanningLogic.js const validateOccupations = (occs) => { occs.forEach(occ => { const resource = RESSOURCES.find(r => r.id === occ.r) || GROUPES.find(g => g.id === occ.r); if (resource && resource.alert) { throw new Error(`La ressource ${occ.r} est en alerte technique et ne peut pas être réservée.`); } }); };
La proposition utilise react-window pour la virtualisation, ce que tu as déjà implémenté. Recommandation :
React.memo pour les composants de cellule si nécessaire.jsx// Exemple d'optimisation avec React.memo const Cell = React.memo(({ ressource, jour, occs, onEdit }) => { // Logique de rendu de la cellule });
La proposition utilise une API générique, mais Airtable a des spécificités. Recommandation :
javascript// Exemple de gestion des limites Airtable const queue = []; let isProcessing = false; const processQueue = async () => { if (isProcessing) return; isProcessing = true; while (queue.length > 0) { const task = queue.shift(); try { await task(); await new Promise(resolve => setTimeout(resolve, 100)); // Délai pour respecter les limites } catch (error) { console.error("Erreur dans la file d'attente Airtable:", error); } } isProcessing = false; }; const enqueueAirtableTask = (task) => { queue.push(task); processQueue(); };
BulterysPlanning.jsx (Version Adaptée)Voici une version adaptée de ton composant principal, intégrant les améliorations tout en conservant ta structure existante :
jsximport { useState, useMemo, useCallback } from 'react'; import { FixedSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { useSwipeable } from 'react-swipeable'; import DraggableOccupation from './DraggableOccupation'; import DroppableCell from './DroppableCell'; import EditModal from './EditModal'; import { resolveConflicts } from '../utils/conflictResolver'; import { validateOccupations } from '../utils/validators'; // Hook personnalisé pour la synchronisation avec Airtable const useAirtableSync = (semaine) => { const queryClient = useQueryClient(); const API_KEY = 'ton_api_key'; const BASE_ID = 'ta_base_id'; const fetchOccupations = async () => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations?filterByFormula=wk="${semaine}"`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!response.ok) throw new Error("Erreur Airtable"); const { records } = await response.json(); return records.map(r => ({ ...r.fields, id: r.id })); }; const updateOccupation = async (occupation) => { const url = `https://api.airtable.com/v0/${BASE_ID}/Occupations/${occupation.id}`; const response = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` }, body: JSON.stringify({ fields: occupation }), }); if (!response.ok) throw new Error("Échec de la mise à jour"); return response.json(); }; const { data: occupations = [], isLoading, error } = useQuery( ['occupations', semaine], fetchOccupations, { staleTime: 1000 * 60 * 5, // Cache de 5 minutes onError: () => { // Fallback sur localStorage en cas d'erreur const localData = localStorage.getItem(`occupations_${semaine}`); return localData ? JSON.parse(localData) : []; }, } ); const { mutate: updateOccupationMutation } = useMutation(updateOccupation, { onSuccess: () => queryClient.invalidateQueries(['occupations', semaine]), }); const syncEdits = async (edits) => { const { resolvedEdits, conflicts } = resolveConflicts(edits, occupations); if (conflicts.length > 0) { alert(`Conflits détectés pour ${conflicts.length} modification(s). Les modifications distantes ont été conservées.`); } for (const [key, occs] of Object.entries(resolvedEdits)) { for (const occ of occs) { if (occ.id) { await updateOccupationMutation(occ); } else { console.warn("Création d'occupation non implémentée", occ); } } } }; return { occupations, isLoading, error, syncEdits }; }; // Hook pour la logique métier const usePlanningLogic = (semaine, occupations, edits) => { const mergedData = useMemo(() => { const idx = {}; occupations.forEach(occ => { const key = `${occ.r}__${occ.j}`; if (!idx[key]) idx[key] = []; idx[key].push(occ); }); Object.entries(edits).forEach(([key, occs]) => { const { ressource, jour } = parseKey(key); idx[`${ressource}__${jour}`] = occs; }); return idx; }, [occupations, edits]); const validate = useCallback((occs) => { try { validateOccupations(occs); return { valid: true }; } catch (error) { return { valid: false, message: error.message }; } }, []); const daySummary = useCallback((jour) => { const acc = { liv: 0, rep: 0, plein: 0, entretien: 0, alertes: 0 }; Object.entries(mergedData).forEach(([key, occs]) => { if (key.endsWith(`__${jour}`)) { occs.forEach(occ => { if (occ.act === "liv") acc.liv++; else if (occ.act === "rep") acc.rep++; else if (occ.act === "plein") acc.plein++; else if (occ.act === "entretien" || occ.act === "atelier") acc.entretien++; if (occ.al || occ.ck === "al") acc.alertes++; }); } }); return acc; }, [mergedData]); return { mergedData, validate, daySummary }; }; // Composant principal const BulterysPlanning = () => { const [selWk, setSelWk] = useState("06/04/2026"); const [editCell, setEditCell] = useState(null); const [edits, setEdits] = useState({}); const [filterSec, setFilterSec] = useState("ALL"); // Synchronisation avec Airtable const { occupations, isLoading, error, syncEdits } = useAirtableSync(selWk); // Logique métier const { mergedData, validate, daySummary } = usePlanningLogic(selWk, occupations, edits); // Gestion des modifications locales const handleSaveCell = useCallback((ressource, semaine, jour, newOccs) => { const validation = validate(newOccs); if (!validation.valid) { alert(validation.message); return; } setEdits(prev => ({ ...prev, [makeKey(ressource, semaine, jour)]: newOccs, })); }, [validate]); // Gestion du drag-and-drop const handleDropOccupation = useCallback((item, dropResult) => { const { occ, ressource: srcRessource, semaine: srcSemaine, jour: srcJour } = item; const { ressource: destRessource, semaine: destSemaine, jour: destJour } = dropResult; if (srcRessource === destRessource && srcJour === destJour) return; setEdits(prev => { const newEdits = { ...prev }; const srcKey = makeKey(srcRessource, srcSemaine, srcJour); const destKey = makeKey(destRessource, destSemaine, destJour); const srcOccs = newEdits[srcKey] || (mergedData[`${srcRessource}__${srcJour}`] || []); const filteredSrcOccs = srcOccs.filter(o => !(o.cl === occ.cl && o.act === occ.act && o.dt === occ.dt)); const destOccs = newEdits[destKey] || (mergedData[`${destRessource}__${destJour}`] || []); const updatedDestOccs = [...destOccs, { ...occ, r: destRessource, wk: destSemaine, j: destJour }]; const validation = validate([...filteredSrcOccs, ...updatedDestOccs]); if (!validation.valid) { alert(validation.message); return prev; } if (filteredSrcOccs.length > 0) newEdits[srcKey] = filteredSrcOccs; else delete newEdits[srcKey]; newEdits[destKey] = updatedDestOccs; return newEdits; }); }, [mergedData, validate]); // Synchronisation avec Airtable const handleSync = useCallback(async () => { try { await syncEdits(edits); setEdits({}); alert("Synchronisation réussie !"); } catch (error) { alert(`Erreur de synchronisation: ${error.message}`); } }, [edits, syncEdits]); // Filtrer les ressources par section const allRows = useMemo(() => { const rows = []; for (const sec of SECS_ORDER) { if (filterSec !== "ALL" && filterSec !== sec) continue; const items = sec === "VEH" ? RESSOURCES.filter(r => r.sec === sec) : GROUPES.filter(g => g.sec === sec); if (!items.length) continue; rows.push({ type: "header", sec }); for (const res of items) rows.push({ type: "row", res, sec }); } return rows; }, [filterSec]); // Rendu de la grille avec virtualisation et gestes tactiles return ( <div className="bulterys-planning"> {/* Barre de navigation */} <div className="topbar"> <span>BULTERYS OPS</span> {SEMAINES.map(s => ( <button key={s.wk} onClick={() => setSelWk(s.wk)} className={selWk === s.wk ? "active" : ""}> {s.lb} </button> ))} {Object.keys(edits).length > 0 && ( <> <span>{Object.keys(edits).length} modification(s) non synchronisée(s)</span> <button onClick={handleSync}>Synchroniser</button> </> )} </div> {/* Grille virtualisée */} <div style={{ height: 'calc(100vh - 200px)' }}> <AutoSizer> {({ height, width }) => ( <Grid columnCount={days.length + 2} rowCount={allRows.length} height={height} width={width} columnWidth={150} rowHeight={30} > {({ columnIndex, rowIndex, style }) => { const row = allRows[rowIndex]; if (row.type === "header") { return <div style={{ ...style, background: SEC_COLORS[row.sec] }}>{SEC_LABELS[row.sec]}</div>; } const { res } = row; const dayIndex = columnIndex - 2; if (dayIndex < 0) return <div style={style}>{res.id}</div>; if (dayIndex === 0) return <div style={style}>{res.sub || res.modele}</div>; const occs = mergedData[`${res.id}__${dayIndex}`] || []; return ( <SwipeableCell onSwipeLeft={() => setEditCell({ ressource: res.id, semaine: selWk, jour: dayIndex, dayLabel: days[dayIndex] })}> <DroppableCell ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > {occs.map((occ, oi) => ( <DraggableOccupation key={oi} occ={occ} ressource={res.id} semaine={selWk} jour={dayIndex} dayLabel={days[dayIndex]} onDrop={handleDropOccupation} > <div style={{ background: getClientStyle(occ.cl, occ.ck).bg, padding: '2px 4px' }}> {occ.act && <span>{ACT_BADGE[occ.act].lb}</span>} <span>{occ.cl}</span> </div> </DraggableOccupation> ))} </DroppableCell> </SwipeableCell> ); }} </Grid> )} </AutoSizer> </div> {/* Modale d'édition */} {editCell && ( <EditModal cell={editCell} occs={mergedData[`${editCell.ressource}__${editCell.jour}`] || []} onSave={(newOccs) => { handleSaveCell(editCell.ressource, selWk, editCell.jour, newOccs); setEditCell(null); }} onClose={() => setEditCell(null)} /> )} </div> ); };
Intègre React Query pour la synchronisation Airtable :
useAirtableSync.js et teste la récupération des données.resolveConflicts).Améliore l’UX mobile :
SwipeableCell et teste sur un appareil mobile.Étends les tests :
planningRules.test.js).npx jest pour les exécuter.Déploie progressivement :
Besoin d’aide pour une étape spécifique ? Par exemple :
useAirtableSync à ton compte Airtable.Dis-moi ce qui t’intéresse, et je te fournirai un code détaillé et prêt à l’emploi ! 😊
Le Chat can make mistakes. Check answers. Learn more