[Custom Widget] Kanban

Je partage avec vous un petit widget Kanban que j’ai mis en place. C’est un outil visuel qui permet de suivre, par exemple, l’avancement de vos tâches en les faisant glisser entre différentes colonnes.

KANBAN

Fonctionnalités

  • Colonnes personnalisables avec compteur de tâches
  • Cartes déplaçables par drag & drop
  • Champs éditables directement dans les cartes
  • Mise à jour automatique dans Grist

Comment le mettre en place

  1. Créez une table « TACHES » dans Grist avec les colonnes suivantes :

    • TITRE (Texte)
    • DESCRIPTION (Texte)
    • STATUT (de type Choix unique) avec les valeurs : « À FAIRE », « EN COURS », « TERMINÉ »
    • PRIORITE (de type Choix unique) avec les valeurs : « HAUTE », « MOYENNE », « BASSE »
    • ASSIGNÉ À (de type Choix unique) avec les noms d’utilisateurs Marie, Pierre, Sophie
  2. Ajoutez un nouveau widget personnalisé dans votre page

    • Cliquez sur « Nouveau + » / Ajouter une vue à la page
    • Choisissez « Personnalisée »
    • Liez cette vue à la table TACHES
  3. Creez le widget

    • Sélectionnez le Custom widget builder et 'dans la partie html, ajoutez le code
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
  <style>
    body { font-family: sans-serif; padding: 1em; margin: 0; background: #f5f5f5; }
    #conteneur-kanban { display: flex; gap: 1em; align-items: flex-start; min-height: calc(100vh - 2em); overflow-x: auto; padding-bottom: 1em; }
    
    .colonne-kanban { flex: 1; min-width: 300px; background: #f8f9fa; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    
    /* Entêtes colorées selon le statut */
    .entete-colonne { 
      padding: 1em; 
      color: white; 
      border-radius: 8px 8px 0 0; 
      font-weight: bold; 
      text-align: center; 
    }
    .entete-afaire { background-color: #e74c3c; }
    .entete-encours { background-color: #f39c12; }
    .entete-termine { background-color: #27ae60; }
    
    .contenu-colonne { padding: 1em; min-height: 100px; }
    
    .carte { background: white; border-radius: 6px; padding: 1em; margin-bottom: 1em; box-shadow: 0 2px 4px rgba(0,0,0,0.05); cursor: grab; }
    .carte:active { cursor: grabbing; }
    .carte h3 { margin: 0 0 0.5em 0; font-size: 1em; color: #2c3e50; }
    .carte .champ { margin-bottom: 0.8em; }
    
    /* Styles uniformisés pour input et textarea */
    .champ-editable { 
      width: 100%;
      box-sizing: border-box;
      padding: 0.3em;
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-bottom: 0.5em;
      font-family: inherit;
      font-size: 0.9em;
      height: 28px; /* Hauteur fixe pour uniformiser */
    }

    textarea.champ-editable {
      height: 28px; /* Même hauteur que les inputs */
      resize: none; /* Désactive le redimensionnement */
      overflow: auto; /* Permet le scroll si nécessaire */
      vertical-align: top; /* Alignement correct avec les autres champs */
      line-height: 1.2; /* Pour un meilleur alignement du texte */
    }
    
    .champ-editable:focus { outline: none; border-color: #2c3e50; }
    
    .select-priorite, .select-assigne { 
      width: 100%;
      box-sizing: border-box;
      padding: 0.3em;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-family: inherit;
      height: 28px; /* Même hauteur que les autres champs */
    }
    
    label { 
      display: block;
      font-size: 0.9em;
      color: #1e40af;
      margin-bottom: 0.2em;
      font-weight: bold;
    }
    
    .priorite-haute { background-color: #e74c3c; color: white; }
    .priorite-moyenne { background-color: #f1c40f; color: white; }
    .priorite-basse { background-color: #2ecc71; color: white; }

    .compteur-colonne {
      background: rgba(255,255,255,0.3);
      border-radius: 12px;
      padding: 0.1em 0.5em;
      margin-left: 0.5em;
      font-size: 0.9em;
      font-weight: normal;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="widget.js"></script>
</body>
</html>

et ce code dans la partie JavaScript

// Configuration des statuts possibles
const STATUTS = [
  { id: 'À FAIRE', libelle: 'À faire' },
  { id: 'EN COURS', libelle: 'En cours' },
  { id: 'TERMINÉ', libelle: 'Terminé' }
];

function mettreAJourChamp(idTache, champ, valeur) {
  grist.docApi.applyUserActions([
    ['UpdateRecord', 'TACHES', parseInt(idTache), {
      [champ]: valeur
    }]
  ]);
}

function creerChampEditable(tache, nomChamp, valeurInitiale, type = 'text') {
  const input = document.createElement('input');
  input.type = type;
  input.value = valeurInitiale || '';
  input.className = 'champ-editable';
  
  input.addEventListener('change', () => {
    mettreAJourChamp(tache.id, nomChamp, input.value);
  });
  
  return input;
}

function creerTextarea(tache, nomChamp, valeurInitiale) {
  const input = document.createElement('input'); // Changé de textarea à input
  input.type = 'text';
  input.value = valeurInitiale || '';
  input.className = 'champ-editable';
  
  input.addEventListener('change', () => {
    mettreAJourChamp(tache.id, nomChamp, input.value);
  });
  
  return input;
}

function creerSelectPriorite(tache) {
  const select = document.createElement('select');
  select.className = 'select-priorite';
  
  ['HAUTE', 'MOYENNE', 'BASSE'].forEach(priorite => {
    const option = document.createElement('option');
    option.value = priorite;
    option.textContent = priorite;
    option.selected = tache.PRIORITE === priorite;
    select.appendChild(option);
  });
  
  select.addEventListener('change', () => {
    mettreAJourChamp(tache.id, 'PRIORITE', select.value);
  });
  
  return select;
}

function creerSelectAssigne(tache) {
  const select = document.createElement('select');
  select.className = 'select-assigne';
  
  ['Non assigné', 'Marie', 'Pierre', 'Sophie'].forEach(personne => {
    const option = document.createElement('option');
    option.value = personne;
    option.textContent = personne;
    option.selected = tache.ASSIGNE_A === personne;
    select.appendChild(option);
  });
  
  select.addEventListener('change', () => {
    mettreAJourChamp(tache.id, 'ASSIGNE_A', select.value);
  });
  
  return select;
}

function creerCarteTache(tache) {
  const carte = document.createElement('div');
  carte.className = 'carte';
  carte.dataset.idTache = tache.id;
  
  // Créer les éléments éditables
  const titreInput = creerChampEditable(tache, 'TITRE', tache.TITRE);
  const descriptionInput = creerTextarea(tache, 'DESCRIPTION', tache.DESCRIPTION);
  const prioriteSelect = creerSelectPriorite(tache);
  const assigneSelect = creerSelectAssigne(tache);
  
  // Assembler la carte
  carte.innerHTML = `
    <div class="champ">
      <label>Titre</label>
    </div>
    <div class="champ">
      <label>Description</label>
    </div>
    <div class="champ">
      <label>Priorité</label>
    </div>
    <div class="champ">
      <label>Assigné à</label>
    </div>
  `;
  
  // Insérer les champs éditables
  carte.children[0].appendChild(titreInput);
  carte.children[1].appendChild(descriptionInput);
  carte.children[2].appendChild(prioriteSelect);
  carte.children[3].appendChild(assigneSelect);

  return carte;
}

function creerColonneKanban(statut) {
  const colonne = document.createElement('div');
  colonne.className = 'colonne-kanban';
  
  // Déterminer la classe de l'entête selon le statut
  const classeEntete = statut.id === 'À FAIRE' ? 'entete-afaire' :
                      statut.id === 'EN COURS' ? 'entete-encours' :
                      'entete-termine';
                      
  colonne.innerHTML = `
    <div class="entete-colonne ${classeEntete}">
      ${statut.libelle}
      <span class="compteur-colonne">(0)</span>
    </div>
    <div class="contenu-colonne" data-statut="${statut.id}"></div>
  `;
  return colonne;
}

function mettreAJourCompteur(elementColonne) {
  const contenu = elementColonne.querySelector('.contenu-colonne');
  const compteur = elementColonne.querySelector('.compteur-colonne');
  const nombre = contenu.children.length;
  compteur.textContent = `(${nombre})`;
}

function afficherKanban(taches) {
  const conteneur = document.getElementById('app');
  conteneur.innerHTML = '<div id="conteneur-kanban"></div>';
  const conteneurKanban = document.getElementById('conteneur-kanban');
  
  // Créer les colonnes
  STATUTS.forEach(statut => {
    const colonne = creerColonneKanban(statut);
    conteneurKanban.appendChild(colonne);
    
    // Initialiser Sortable pour chaque colonne
    new Sortable(colonne.querySelector('.contenu-colonne'), {
      group: 'taches',
      animation: 150,
      onEnd: async function(evt) {
        const idTache = evt.item.dataset.idTache;
        const nouveauStatut = evt.to.dataset.statut;
        
        try {
          await grist.docApi.applyUserActions([
            ['UpdateRecord', 'TACHES', parseInt(idTache), {
              'STATUT': nouveauStatut
            }]
          ]);
        } catch (erreur) {
          console.error('Erreur lors de la mise à jour:', erreur);
        }

        // Mettre à jour les compteurs
        const colonnes = document.querySelectorAll('.colonne-kanban');
        colonnes.forEach(mettreAJourCompteur);
      }
    });
  });

  // Distribuer les tâches
  if (taches && taches.length > 0) {
    taches.forEach(tache => {
      const carte = creerCarteTache(tache);
      const statut = tache.STATUT || 'À FAIRE';
      const colonne = document.querySelector(`[data-statut="${statut}"]`);
      if (colonne) {
        colonne.appendChild(carte);
      }
    });
  }

  // Mettre à jour les compteurs initiaux
  const colonnes = document.querySelectorAll('.colonne-kanban');
  colonnes.forEach(mettreAJourCompteur);
}

// Configuration Grist
grist.ready({
  requiredAccess: 'full',
  columns: [
    'TITRE',
    'DESCRIPTION',
    'STATUT',
    'PRIORITE',
    'ASSIGNE_A'
  ]
});

// Écouter les changements de données
grist.onRecords(records => {
  afficherKanban(records);
});
  • Configurez le widget pen mappant vos colonnes titre, description, statut, priorité, assigné à

Personnalisation possible

Vous pouvez facilement adapter ce Kanban à vos besoins :

  • Modifiez les valeurs possibles pour PRIORITE et ASSIGNE_A dans la fonction « creerSelectPriorite » et « creerSelectAssigne »
  • Ajoutez ou supprimez des champs en modifiant la structure HTML des cartes
  • Modifiez les couleurs, polices et styles dans le CSS
  • Ajustez les espacements et la taille des cartes selon vos préférences

NB

  • Le widget nécessite un accès « complet au document » pour permettre les mises à jour
  • Les modifications sont sauvegardées automatiquement dans Grist
8 « J'aime »

Hello Céline, merci c’est très quali ce tuto.
Je viens de le tester et ça marche nickel.

Je me permet un petit retour : dans la partie 2., à la fin il faudrait préciser que c’est un « Custom Widget Builder » qu’il faut sélectionner. C’est mentionné nulle part et un débutant pourrait être un peu perdu :slight_smile:

Encore une fois, beau travail et merci du partage !

tout à fait merci bcp pour ton retour !!

Bonjour,
Merci pour cette bonne idée. Je viens de tester, facile à mettre en œuvre.
Cependant j’ai du loupé quelque chose car ce que je modifie dans mes « cards » n’est pas modifié dans la table. J’ai bien les accès complets !

J’ai trouvé pourquoi les informations saisies dans les cards ne s’enregistraient pas dans la table. La vue s’appelait bien « TABLES » mais pas la table « source ».
J’ai présenté le projet à mes collègues, ils trouvent ça très bien et me demandaient s’il y avait moyen de rajouter une date de fin.
Sur la table pas de problème, mais sur la card je n’arrive pas à afficher ne serait-ce que la date saisie dans la table.
Une idée ?

1 « J'aime »

Voici la V2 du kanban.
J’y ai ajouté la possibilité de créer et de supprimer de nouvelles cartes. Plus besoin du tableau pour gérer les données, le kanban peut suffire.
J’ai ajouté un champ date @Vlacoume, on ne le voit pas sur le gif je crois mais ça t’ouvre un calendrier.

Kanban V2

Voici les codes à mettre dans le custom widget builder :

HTML

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
  <style>
    body { 
      font-family: sans-serif; 
      padding: 1em; 
      margin: 0; 
      background: #f5f5f5; 
    }

    #conteneur-kanban { 
      display: flex; 
      gap: 1em; 
      align-items: flex-start; 
      min-height: calc(100vh - 2em); 
      overflow-x: auto; 
      padding-bottom: 1em; 
    }

    .colonne-kanban { 
      display: flex;
      flex-direction: column;
      flex: 1; 
      min-width: 300px; 
      background: #f8f9fa; 
      border-radius: 8px; 
      box-shadow: 0 2px 4px rgba(0,0,0,0.1); 
    }

    .entete-colonne { 
      padding: 1em; 
      color: white; 
      border-radius: 8px 8px 0 0; 
      font-weight: bold;
      text-align: center;
      font-size: 1.2em;
    }

    .bouton-ajouter {
      width: fit-content;
      margin: 1em auto;
      padding: 0.7em 1.2em;
      border: none;
      border-radius: 6px;
      background: #f0f0f0;
      color: #666;
      cursor: pointer;
      text-align: center;
      font-size: 0.9em;
      transition: all 0.2s ease;
      display: block;
    }

    .bouton-ajouter:hover {
      background: #e0e0e0;
      color: #333;
    }

    .entete-afaire { background-color: #e74c3c; }
    .entete-encours { background-color: #f39c12; }
    .entete-termine { background-color: #27ae60; }

    .colonne-afaire label { color: #e74c3c !important; }
    .colonne-encours label { color: #f39c12 !important; }
    .colonne-termine label { color: #27ae60 !important; }

    .contenu-colonne { 
      flex: 1;
      padding: 0;
      min-height: 100px;
    }

    .carte { 
      position: relative;
      background: white; 
      border-radius: 6px; 
      padding: 1em; 
      margin: 1em;
      box-shadow: 0 2px 4px rgba(0,0,0,0.05); 
      cursor: grab;
      transition: all 0.2s ease;
    }

    .carte:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 8px rgba(0,0,0,0.1);
    }
    
    .carte:active { 
      cursor: grabbing;
    }

    .champ {
      margin-bottom: 0.8em;
      width: 100%;
    }

    .champ label {
      display: block;
      margin-bottom: 0.3em;
      font-weight: bold;
    }

    .champ-editable, 
    .select-assigne,
    .select-priorite { 
      width: 100%;
      box-sizing: border-box;
      padding: 0.5em;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-family: inherit;
      font-size: 0.9em;
    }

    textarea.champ-editable {
      min-height: 60px;
      resize: vertical;
    }

    .champ-editable:focus, 
    .select-assigne:focus,
    .select-priorite:focus { 
      outline: none;
      border-color: #2c3e50;
    }

    .info-creation-wrapper {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-top: 1em;
      padding-top: 1em;
      border-top: 1px solid #eee;
    }

    .info-creation {
      display: flex;
      gap: 2em;
    }

    .info-creation > div {
      display: flex;
      gap: 0.5em;
      align-items: center;
    }

    .info-creation label {
      font-weight: bold;
      margin-right: 0.3em;
    }

    .info-creation span {
      color: #666;
    }

    .bouton-supprimer {
      padding: 0.5em 1em;
      border: none;
      border-radius: 4px;
      background: rgba(255, 68, 68, 0.1);
      color: #ff4444;
      cursor: pointer;
      transition: all 0.2s ease;
    }

    .bouton-supprimer:hover {
      background: rgba(255, 68, 68, 0.2);
    }

    .titre-statut {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 0.5em;
    }

    .compteur-colonne {
      font-size: 0.8em;
      opacity: 0.9;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="widget.js"></script>
</body>
</html>

et le javascript

const STATUTS = [
  { id: 'À FAIRE', libelle: 'À FAIRE' },
  { id: 'EN COURS', libelle: 'EN COURS' },
  { id: 'TERMINÉ', libelle: 'TERMINÉ' }
];

function mettreAJourChamp(idTache, champ, valeur) {
  grist.docApi.applyUserActions([
    ['UpdateRecord', 'TACHES', parseInt(idTache), {
      [champ]: valeur
    }]
  ]);
}

async function creerNouvelleTache(statut) {
  try {
    await grist.docApi.applyUserActions([
      ['AddRecord', 'TACHES', null, {
        'TITRE': 'Nouvelle tâche',
        'STATUT': statut,
        'ASSIGNE_A': 'Non assigné',
        'PRIORITE': 'NORMALE'
      }]
    ]);
  } catch (erreur) {
    console.error('Erreur lors de la création:', erreur);
  }
}

function formatDate(dateStr) {
  if (!dateStr) return '-';
  return new Date(dateStr).toLocaleString('fr-FR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  });
}

function creerCarteTache(tache) {
  const carte = document.createElement('div');
  carte.className = 'carte';
  carte.dataset.idTache = tache.id;

  carte.innerHTML = `
    <div class="champ">
      <label>Titre</label>
      <input type="text" class="champ-editable" value="${tache.TITRE || ''}" 
        onchange="mettreAJourChamp(${tache.id}, 'TITRE', this.value)">
    </div>
    <div class="champ">
      <label>Description</label>
      <textarea class="champ-editable description" 
        onchange="mettreAJourChamp(${tache.id}, 'DESCRIPTION', this.value)">${tache.DESCRIPTION || ''}</textarea>
    </div>
    <div class="champ">
      <label>Assigné à</label>
      <select class="select-assigne" onchange="mettreAJourChamp(${tache.id}, 'ASSIGNE_A', this.value)">
        <option value="Non assigné" ${tache.ASSIGNE_A === 'Non assigné' ? 'selected' : ''}>Non assigné</option>
        <option value="Amandine" ${tache.ASSIGNE_A === 'Amandine' ? 'selected' : ''}>Amandine</option>
        <option value="Céline" ${tache.ASSIGNE_A === 'Céline' ? 'selected' : ''}>Céline</option>
        <option value="Noémie" ${tache.ASSIGNE_A === 'Noémie' ? 'selected' : ''}>Noémie</option>
      </select>
    </div>
    <div class="champ">
      <label>Deadline</label>
      <input type="date" class="champ-editable" value="${tache.DEADLINE || ''}" 
        onchange="mettreAJourChamp(${tache.id}, 'DEADLINE', this.value)">
    </div>
    <div class="champ">
      <label>Priorité</label>
      <select class="select-priorite" onchange="mettreAJourChamp(${tache.id}, 'PRIORITE', this.value)">
        <option value="BASSE" ${tache.PRIORITE === 'BASSE' ? 'selected' : ''}>BASSE</option>
        <option value="NORMALE" ${tache.PRIORITE === 'NORMALE' ? 'selected' : ''}>NORMALE</option>
        <option value="HAUTE" ${tache.PRIORITE === 'HAUTE' ? 'selected' : ''}>HAUTE</option>
        <option value="URGENTE" ${tache.PRIORITE === 'URGENTE' ? 'selected' : ''}>URGENTE</option>
      </select>
    </div>
    <div class="info-creation-wrapper">
      <div class="info-creation">
        <div>
          <label>Créé par :</label>
          <span>${tache.CREE_PAR || '-'}</span>
        </div>
        <div>
          <label>Créé le :</label>
          <span>${formatDate(tache.CREE_LE)}</span>
        </div>
      </div>
      <button class="bouton-supprimer" title="Supprimer cette carte">🗑️</button>
    </div>
  `;

  const boutonSupprimer = carte.querySelector('.bouton-supprimer');
  boutonSupprimer.addEventListener('click', async () => {
    const titre = tache.TITRE || 'Sans titre';
    if (confirm(`Voulez-vous vraiment supprimer la carte "${titre}" ?`)) {
      try {
        await grist.docApi.applyUserActions([
          ['RemoveRecord', 'TACHES', parseInt(tache.id)]
        ]);
      } catch (erreur) {
        console.error('Erreur lors de la suppression:', erreur);
      }
    }
  });

  return carte;
}

function creerColonneKanban(statut) {
  const colonne = document.createElement('div');
  colonne.className = 'colonne-kanban ' + 
    (statut.id === 'À FAIRE' ? 'colonne-afaire' :
     statut.id === 'EN COURS' ? 'colonne-encours' :
     'colonne-termine');

  const entete = document.createElement('div');
  const classeEntete = statut.id === 'À FAIRE' ? 'entete-afaire' :
                      statut.id === 'EN COURS' ? 'entete-encours' :
                      'entete-termine';
  entete.className = `entete-colonne ${classeEntete}`;
  
  entete.innerHTML = `<div class="titre-statut">${statut.libelle} <span class="compteur-colonne">(0)</span></div>`;
  
  const contenu = document.createElement('div');
  contenu.className = 'contenu-colonne';
  contenu.dataset.statut = statut.id;

  const boutonAjouter = document.createElement('button');
  boutonAjouter.className = 'bouton-ajouter';
  boutonAjouter.textContent = '+ Ajouter une carte';
  boutonAjouter.onclick = () => creerNouvelleTache(statut.id);

  colonne.appendChild(entete);
  colonne.appendChild(boutonAjouter);
  colonne.appendChild(contenu);

  return colonne;
}

function mettreAJourCompteur(colonne) {
  const contenu = colonne.querySelector('.contenu-colonne');
  const compteur = colonne.querySelector('.compteur-colonne');
  if (contenu && compteur) {
    const nombre = contenu.children.length;
    compteur.textContent = `(${nombre})`;
  }
}

function afficherKanban(taches) {
  const conteneur = document.getElementById('app');
  conteneur.innerHTML = '<div id="conteneur-kanban"></div>';
  const conteneurKanban = document.getElementById('conteneur-kanban');

  STATUTS.forEach(statut => {
    const colonne = creerColonneKanban(statut);
    conteneurKanban.appendChild(colonne);

    const conteneurCartes = colonne.querySelector('.contenu-colonne');
    new Sortable(conteneurCartes, {
      group: 'kanban',
      animation: 150,
      onEnd: async function(evt) {
        const idTache = evt.item.dataset.idTache;
        const nouveauStatut = evt.to.dataset.statut;

        try {
          await grist.docApi.applyUserActions([
            ['UpdateRecord', 'TACHES', parseInt(idTache), {
              'STATUT': nouveauStatut
            }]
          ]);
        } catch (erreur) {
          console.error('Erreur lors de la mise à jour:', erreur);
        }

        // Mettre à jour les compteurs après un déplacement
        const colonnes = document.querySelectorAll('.colonne-kanban');
        colonnes.forEach(mettreAJourCompteur);
      }
    });
  });

  if (taches && taches.length > 0) {
    taches.forEach(tache => {
      const carte = creerCarteTache(tache);
      const conteneurCartes = document.querySelector(`.contenu-colonne[data-statut="${tache.STATUT}"]`);
      if (conteneurCartes) {
        conteneurCartes.appendChild(carte);
      }
    });
  }

  // Mettre à jour les compteurs initiaux
  const colonnes = document.querySelectorAll('.colonne-kanban');
  colonnes.forEach(mettreAJourCompteur);
}

// Initialisation
grist.ready({
  requiredAccess: 'full',
  columns: [
    'TITRE',
    'DESCRIPTION',
    'STATUT',
    'PRIORITE',
    'ASSIGNE_A',
    'ID2',
    'DEADLINE',
    'CREE_PAR',
    'CREE_LE'
  ]
});

// Écouter les changements
grist.onRecords(records => {
  afficherKanban(records);
});

Par rapport à la table de données de la table du 1er kanban, j’ai ajouté les champs :
DEADLINE (de type date)
CRÉÉ PAR (Formule d’initialisation : user.Name)
CRÉÉ LE (Formule d’initialisation : NOW())
ID (pour l’exemple j’utilise une formule d’initialisation UUID() mais vous pouvez y mettre une référence de votre choix)

7 « J'aime »

Vraiment top , merci beaucoup !

Je viens de tester, ça ouvre bien un calendrier pour choisir la date. Sur la carte il est toujours affiché jj/mm/aaa et pas la date saisie, pas grand chose à corriger je pense.

Bon WE

je n’ai pas ce probleme, tu dois avoir une erreur de mapping avec ton champ DEADLINE

Génial @celine ! Merci !
J’ai augmenté le contraste sur les fiches (background) pour améliorer le distinguo entre elles.

Un grand merci pour ce super outil qui tombe à pic.
Je suis une grande débutante, qui ne sait pas coder, mais j’ai réussi à installer la première version. Cependant, je suis plus intéressée par la V2, mais bizarrement cela ne fonctionne pas, le bouton « Ajouter une carte » refuse désespérement de s’ouvrir… Une idée?

@FLetiche Tu as choisi ‹ Accès complet au document › dans le niveau d’accès ?

ou sinon as tu vérifié dans la table de données si de nouvelles lignes sont crées ? si de nouvelles lignes créées n’ont pas de statut , elles n’apparaissent pas dans le kanban

Oui, il y a bien l’accès à tout le document, et les lignes apparaissent bien mais le bouton "Ajouter une carte ne marche pas…
Je laisse reposer quelques heures pour comprendre …

je ne comprends pas ta réponse, le bouton ne marche pas mais des lignes sont créées dans la table ?

Je peux créer des lignes dans la table mais elles ne sont pas liées au Kanban…

peux tu m’envoyer ton code par message privé pour que je regarde cela ? ça vient soit d’un nom de champ soit d’un nom de table je pense :slight_smile:

Et voici une V3 !

Guide : Créer un Kanban Todo List dans Grist

Ce guide vous permettra de créer un kanban todo list interactif dans votre document Grist, avec des cartes style post-it déplaçables entre colonnes.

Fonctionnalités incluses

  • Drag & drop des tâches entre colonnes
  • Animation de confettis quand une tâche passe en « Fait »
  • Tri automatique par deadline dans les colonnes actives
  • Tri par date de mise à jour dans les colonnes Fait/Annulé
  • Popup détaillé pour chaque tâche
  • Colonnes repliables
  • Compteurs automatiques
  • Style post-it avec rotation aléatoire

KANBAN V3

1. Création de la table de données

Commencez par créer une nouvelle table nommée exactement TODO_LIST avec les colonnes suivantes (il faut que les noms de la table et des champs soient identiques à ceux-ci car ils sont repris dans le code, si vous voulez les modifier, pensez à les changer également dans le code, plus bas je vous indique comment modifier cela dans un second temps) :

Nom de colonne Type Description
TYPE Choix simple Type de tâche
DESCRIPTION Texte Description de la tâche
DEADLINE Date Date limite
STATUT Choix simple Statut de la tâche
REFERENCE_PROJET Numerique Référence du projet associé
NOTES Texte Notes additionnelles
RESPONSABLE Texte Personne responsable
CREE_PAR Champ paternité Créateur de la tâche
CREE_LE Champ horodage Date de création
DERNIERE_MISE_A_JOUR Champ horodage quand modification du STATUT Dernière modification

Pour la colonne STATUT, ajoutez ces choix exactement :

  • :raised_hand_with_fingers_splayed: À faire
  • :recycle: En cours
  • :white_check_mark: Fait
  • :x: Annulé

2. Création du widget custom

  1. Ajoutez une nouvelle vue / Personnalisée/ Custom widget builder
  2. Copiez le code HTML dans le premier onglet
  3. Copiez le code JavaScript dans le second onglet

3. Personnalisation

Adaptez le code à votre structure de données

Si vous souhaitez utiliser une table avec un nom différent ou des noms de colonnes différents, voici où effectuer les modifications dans le code :

1. Dans le code JavaScript

Localisez la section d’initialisation Grist (vers la fin du code) :

grist.ready({
  requiredAccess: 'full',
  columns: [
    'TYPE', 'DESCRIPTION', 'DEADLINE', 
    'STATUT', 'REFERENCE_PROJET', 'NOTES', 
    'RESPONSABLE', 'CREE_PAR', 'CREE_LE', 'DERNIERE_MISE_A_JOUR'
  ]
});

Remplacez les noms des colonnes par ceux de votre table.

2. Rechercher et remplacer les références aux colonnes

Utilisez la fonction de recherche de votre éditeur (Ctrl+F ou Cmd+F) pour remplacer :

  • TODO_LIST par le nom de votre table
  • Les noms des champs dans tout le code :
    • DESCRIPTION
    • DEADLINE
    • STATUT
    • REFERENCE_PROJET
    • TYPE
    • NOTES
    • RESPONSABLE
    • CREE_PAR
    • CREE_LE
    • DERNIERE_MISE_A_JOUR

Important : Faites attention à :

  • Respecter la casse (majuscules/minuscules)
  • Ne remplacer que les noms de colonnes, pas les autres textes du code
  • Garder la cohérence entre les noms dans le code JavaScript et les noms réels de vos colonnes dans Grist

3. Vérifier les colonnes critiques

Le kanban repose particulièrement sur certaines colonnes clés :

  • Le champ STATUT doit contenir exactement les mêmes valeurs que celles définies dans COLONNES_AFFICHAGE
  • Les dates (DEADLINE, CREE_LE, DERNIERE_MISE_A_JOUR) doivent être au format DateTime de Grist
  • Le champ REFERENCE_PROJET doit être de type numérique

4. Test après modifications

Après avoir effectué ces changements :

  1. Vérifiez que le widget se charge correctement
  2. Testez la création d’une nouvelle tâche
  3. Vérifiez que le drag & drop fonctionne
  4. Confirmez que les données sont bien sauvegardées dans votre table

Si vous rencontrez des erreurs, vérifiez dans la console du navigateur (F12) pour identifier quels champs posent problème.

Modifiez les couleurs

Dans le code HTML, trouvez la section :root et modifiez les couleurs selon vos préférences :

:root {
  --couleur-a-faire: #f95c5e;     /* Couleur colonne "À faire" */
  --couleur-en-cours: #417DC4;    /* Couleur colonne "En cours" */
  --couleur-fait: #27a658;        /* Couleur colonne "Fait" */
  --couleur-annule: #301717;      /* Couleur colonne "Annulé" */ 
}

Modifiez les libellés des colonnes

Dans le code JavaScript, trouvez la section COLONNES_AFFICHAGE et modifiez les libellés :

const COLONNES_AFFICHAGE = [
  { id: '🖐️ À faire', libelle: 'À faire', classe: 'a-faire' },
  { id: '♻️ En cours', libelle: 'En cours', classe: 'en-cours' },
  // etc.
];

Note: Ne modifiez pas les valeurs ‹ id › et ‹ classe › qui doivent correspondre au reste du code.

Modifiez les types de tâches

Dans le code JavaScript, recherchez la section avec les options du champ « Type » et modifiez selon vos besoins :

<option value="Développement">Développement</option>
<option value="Réponse à donner">Réponse à donner</option>
// etc.

Modifiez les responsables

De même, trouvez la section des responsables et adaptez la liste :

<option value="Personne1">Personne1</option>
<option value="Personne2">Personne2</option>
// etc.

Astuces supplémentaires

  • Pour modifier le style des post-it, cherchez la classe .carte dans le CSS
  • Pour changer les icônes, modifiez les émojis dans le code JavaScript
  • N’hésitez pas à adapter les couleurs pour correspondre à votre charte graphique

Et enfin, les codes à copier coller dans les onglets du custom widget builder :

LE HTML :point_down:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Kanban TODO</title>

  <!-- Scripts essentiels - NE PAS MODIFIER -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
  <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
  <!-- Ajout de la librairie pour les confettis -->
  <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>

  <style>
    /* ========== VARIABLES PERSONNALISABLES ========== */
    :root {
      --couleur-a-faire: #f95c5e;      /* red-marianne-625 */
      --couleur-en-cours: #417DC4;     /* blue-france-sun-113 */
      --couleur-fait: #27a658;         /* success-625 */
      --couleur-annule: #301717;       /* error-75 */
      --border-radius: 2px;            /* Coins des post-it */
      --background-color: #f5f6f8;     /* Fond général */
    }

    /* ===== RESET ET BASE ===== */
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      line-height: 1.4;
      color: #333;
      background: var(--background-color);
    }

    /* ===== STRUCTURE DU KANBAN ===== */
    /* Conteneur principal avec scroll horizontal */
    #conteneur-kanban {
      display: flex;
      gap: 1rem;
      padding: 1rem;
      height: 100vh;
      overflow-x: auto;
      background: linear-gradient(to bottom, #e9ecef, #f8f9fa);
    }

    /* Structure des colonnes avec scroll vertical */
    .colonne-kanban {
      flex: 1;
      min-width: 300px;
      max-width: 400px;
      background: rgba(255, 255, 255, 0.9);
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
      display: flex;
      flex-direction: column;
      height: calc(100vh - 2rem);
      overflow: hidden;
    }

    /* État replié des colonnes */
    .colonne-kanban.collapsed {
      min-width: 60px;
      width: 60px;   
      flex: 0 0 auto;
    }

    .colonne-kanban.collapsed .entete-colonne {
      writing-mode: vertical-rl;
      transform: rotate(180deg);
      height: auto;
      align-items: flex-start;
      padding: 2.5rem 0.5rem 1rem 0.5rem;
    }

    .colonne-kanban.collapsed .bouton-toggle {
      transform: rotate(90deg);
      position: absolute;
      top: 10px;
      left: 20px;
    }

    .colonne-kanban.collapsed .contenu-colonne,
    .colonne-kanban.collapsed .bouton-ajouter {
      display: none;
    }

    /* En-têtes des colonnes */
    .entete-colonne {
      padding: 1rem;
      font-weight: 600;
      display: flex;
      justify-content: space-between;
      align-items: center;
      position: sticky;
      top: 0;
      z-index: 10;
      color: white;
      border-radius: 8px 8px 0 0;
      font-size: 0.9rem;
      letter-spacing: 0.02em;
    }

    /* Couleurs des en-têtes */
    .entete-a-faire { background-color: var(--couleur-a-faire); }
    .entete-en-cours { background-color: var(--couleur-en-cours); }
    .entete-fait { background-color: var(--couleur-fait); }
    .entete-annule { background-color: var(--couleur-annule); }

    /* Zone de contenu des colonnes avec scroll */
    .contenu-colonne {
      flex: 1;
      padding: 0.75rem;
      overflow-y: auto;
      min-height: 0;
    }

    /* Espacement pour les colonnes sans bouton d'ajout */
    .colonne-en-cours .contenu-colonne,
    .colonne-fait .contenu-colonne,
    .colonne-annule .contenu-colonne {
      padding-top: 1.5rem;
    }

    /* ===== CARTES POST-IT ===== */
    .carte {
      background: #FFFFD1;
      border-radius: var(--border-radius);
      margin-bottom: 1rem;
      padding: 1.5rem;
      cursor: pointer;
      position: relative;
      box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
      transform: rotate(var(--rotation));
      transition: all 0.3s;
      border: none;
    }

    /* Effet de brillance du post-it */
    .carte::before {
      content: '';
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      height: 20px;
      background: linear-gradient(to bottom, rgba(0,0,0,0.02), transparent);
    }

    /* Rotation aléatoire des post-it */
    .carte:nth-child(odd) { --rotation: -1deg; }
    .carte:nth-child(even) { --rotation: 1deg; }
    .carte:nth-child(3n) { --rotation: -0.5deg; }
    .carte:nth-child(5n) { --rotation: 0.5deg; }

    /* Effets au survol et sélection */
    .carte:hover, .carte.active {
      transform: rotate(0deg) translateY(-5px);
      box-shadow: 3px 3px 8px rgba(0,0,0,0.2);
    }

    .carte.active {
      background: #FFFFF0;
    }

    /* Éléments des cartes */
    .projet-ref {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      color: #e11d48;
      border: 2px solid currentColor;
      padding: 0.2em 0.5em;
      border-radius: 4px;
      font-size: 0.8em;
      font-weight: 700;
      background: rgba(255,255,255,0.9);
      z-index: 2;
    }

    .type-tag {
      display: inline-block;
      padding: 0.2rem 0.5rem;
      border-radius: 3px;
      font-size: 0.75rem;
      font-weight: 600;
      background: rgba(0,0,0,0.05);
      margin-bottom: 0.5rem;
    }

    .description {
      font-size: 0.95rem;
      margin: 0.5rem 0;
      color: #2d3748;
    }

    .deadline {
      font-size: 0.8rem;
      color: #e11d48;
      margin-top: 0.5rem;
      font-weight: 500;
    }

    .responsable-badge {
      position: absolute;
      bottom: 0.5rem;
      right: 0.5rem;
      padding: 0.2rem 0.5rem;
      border-radius: 4px;
      font-size: 0.75rem;
      font-weight: 500;
      background-color: rgba(0, 0, 0, 0.05);
    }

    /* Tampons terminé/annulé */
    .tampon-termine, .tampon-annule {
      position: absolute;
      top: 40%;
      left: 50%;
      transform: translate(-50%, -50%) rotate(-12deg);
      font-size: 1.4rem;
      font-weight: bold;
      padding: 0.3rem 1rem;
      border-radius: 6px;
      pointer-events: none;
      z-index: 2;
      text-transform: uppercase;
      background: rgba(255, 255, 255, 0.9);
      white-space: nowrap;
      border: 2px solid currentColor;
    }

    .tampon-termine { color: #27a658; }
    .tampon-annule { color: #301717; }

    /* ===== BOUTONS D'INTERFACE ===== */
    .bouton-toggle {
      background: none;
      border: none;
      cursor: pointer;
      font-size: 1.2rem;
      padding: 0.2rem;
      color: inherit;
      opacity: 0.8;
      transition: opacity 0.2s;
    }

    .bouton-toggle:hover {
      opacity: 1;
    }

    /* Bouton d'ajout */
    .bouton-ajouter {
      margin: 0.75rem;
      padding: 0.75rem;
      background: rgba(255,255,255,0.9);
      border: 2px dashed #ddd;
      border-radius: 4px;
      cursor: pointer;
      color: #666;
      transition: all 0.2s;
      position: sticky;
      top: 0;
      z-index: 5;
      font-size: 0.9rem;
    }

    .bouton-ajouter:hover {
      background: #fff;
      border-color: #999;
    }

    /* ===== POPUP ===== */
    /* Conteneur du popup */
    .popup {
      position: fixed;
      top: 0;
      right: -100%;
      width: 100%;
      max-width: 600px;
      height: 100vh;
      background: white;
      box-shadow: -2px 0 15px rgba(0,0,0,0.1);
      transition: right 0.3s ease;
      z-index: 1000;
      display: flex;
      flex-direction: column;
      border-left: 4px solid transparent;
      border-radius: 16px 0 0 16px;
    }

    .popup.visible {
      right: 0;
    }

    /* En-tête du popup */
    .popup-header {
      padding: 1.5rem;
      color: white;
      display: flex;
      justify-content: space-between;
      align-items: center;
      border-radius: 12px 0 0 0;
    }

    /* Style du titre limité à 2 lignes avec hauteur corrigée */
    .popup-title {
      font-size: 1.25rem;
      font-weight: 600;
      margin: 0;
      flex: 1;
      text-align: center;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      text-overflow: ellipsis;
      word-break: break-word;
      max-height: 4rem;     /* Augmenté pour permettre deux lignes complètes */
      line-height: 1.5;
      padding: 0.5rem 1rem; /* Ajusté pour centrer verticalement */
      width: 100%;
      white-space: normal;
    }

    /* Ajustement de l'en-tête pour accommoder le titre */
    .popup-header {
      padding: 1rem 1.5rem;
      min-height: 5rem;    /* Hauteur minimale pour garantir l'espace */
      color: white;
      display: flex;
      justify-content: space-between;
      align-items: center;
      border-radius: 12px 0 0 0;
    }

    /* Contenu du popup */
    .popup-content {
      flex: 1;
      padding: 1.5rem;
      overflow-y: auto;
      background: #f8f9fa;
    }

    /* Styles des champs du formulaire */
    .field {
      background: white;
      padding: 1rem;
      margin-bottom: 1rem;
      border-radius: 8px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.05);
    }

    .field-row {
      display: flex;
      gap: 1rem;
      margin-bottom: 1rem;
    }

    .field-row .field {
      flex: 1;
      margin-bottom: 0;
    }

    .field-label {
      display: block;
      font-size: 0.85rem;
      color: #4a5568;
      margin-bottom: 0.5rem;
      font-weight: 500;
    }

    .field-input,
    .field-select,
    .field-textarea {
      width: 100%;
      padding: 0.75rem;
      border: 1px solid #e2e8f0;
      border-radius: 6px;
      font-size: 0.9rem;
      transition: all 0.2s;
      background: white;
    }

    /* Champs auto-expandables */
    .field-textarea.auto-expand {
      min-height: 100px;
      overflow: hidden;
      resize: none;
      transition: height 0.1s ease;
    }

    /* Couleurs du popup selon le statut */
    .popup[data-statut="🖐️ À faire"] {
      border-left-color: var(--couleur-a-faire);
    }
    .popup[data-statut="🖐️ À faire"] .popup-header {
      background-color: var(--couleur-a-faire);
    }

    .popup[data-statut="♻️ En cours"] {
      border-left-color: var(--couleur-en-cours);
    }
    .popup[data-statut="♻️ En cours"] .popup-header {
      background-color: var(--couleur-en-cours);
    }

    .popup[data-statut="✅ Fait"] {
      border-left-color: var(--couleur-fait);
    }
    .popup[data-statut="✅ Fait"] .popup-header {
      background-color: var(--couleur-fait);
    }

    .popup[data-statut="❌ Annulé"] {
      border-left-color: var(--couleur-annule);
    }
    .popup[data-statut="❌ Annulé"] .popup-header {
      background-color: var(--couleur-annule);
    }

    /* Bouton de fermeture */
    .bouton-fermer {
      background: none;
      border: none;
      color: white;
      font-size: 1.5rem;
      cursor: pointer;
      opacity: 0.8;
      transition: opacity 0.2s;
      padding: 0.5rem;
    }

    .bouton-fermer:hover {
      opacity: 1;
    }

    /* Bouton de suppression */
    .popup-actions {
      position: fixed;
      bottom: 2rem;
      right: 2rem;
      display: flex;
      gap: 1rem;
      z-index: 100;
    }

    .popup-action-button {
  display: none;  /* Masqué par défaut */
  position: fixed;
  bottom: 2rem;
  right: 2rem;
  width: 50px;
  height: 50px;
  border-radius: 25px;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease;
  color: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

/* N'afficher que quand la popup est visible */
.popup.visible .popup-action-button {
  display: flex;
}

    .popup-action-button:hover {
      transform: scale(1.1);
      filter: brightness(1.1);
    }

    .bouton-supprimer {
      background: #FF5252;
    }

    /* Info de création */
    .info-creation {
      margin-top: 2rem;
      padding: 1rem;
      background: white;
      border-radius: 8px;
      font-size: 0.85rem;
      color: #666;
      text-align: center;
    }

    /* Styles pour l'impression */
    @media print {
      body * {
        visibility: hidden;
      }
      .popup, .popup * {
        visibility: visible;
      }
      .popup {
        position: absolute;
        left: 0;
        top: 0;
      }
      .popup-actions {
        display: none;
      }
    }
 </style>
</head>
<body>
  <div id="conteneur-kanban"></div>
  <div id="popup-todo" class="popup">
    <div class="popup-header">
      <h2 class="popup-title"></h2>
      <button class="bouton-fermer" onclick="fermerPopup()">×</button>
    </div>
    <div class="popup-content"></div>
  </div>
  <script src="widget.js"></script>
</body>
</html>

le javascript vient dans le message suivant

4 « J'aime »

LE JAVASCRIPT :point_down:

// ========== CONFIGURATION DU KANBAN ==========
/* Configuration des colonnes du kanban
   Vous pouvez modifier ces paramètres selon vos besoins :
   - id : identifiant unique de la colonne (conservez les émojis)
   - libelle : texte affiché en haut de la colonne
   - classe : nom CSS utilisé pour le style (doit correspondre au CSS)
*/
const COLONNES_AFFICHAGE = [
  { id: '🖐️ À faire', libelle: 'À faire', classe: 'a-faire' },
  { id: '♻️ En cours', libelle: 'En cours', classe: 'en-cours' },
  { id: '✅ Fait', libelle: 'Fait', classe: 'fait' },
  { id: '❌ Annulé', libelle: 'Annulé', classe: 'annule' }
];

// ========== FONCTIONS UTILITAIRES ==========
/* Gestion du repli/dépli des colonnes */
function toggleColonne(colonne, e) {
  e?.stopPropagation();
  colonne.classList.toggle('collapsed');
  localStorage.setItem(`column-todo-${colonne.querySelector('.titre-statut').textContent.trim()}`, colonne.classList.contains('collapsed'));
}

/* Fonction pour déclencher l'animation de confettis */
function triggerConfetti() {
  const duration = 3 * 1000;
  const animationEnd = Date.now() + duration;
  const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };

  function randomInRange(min, max) {
    return Math.random() * (max - min) + min;
  }

  const interval = setInterval(function() {
    const timeLeft = animationEnd - Date.now();

    if (timeLeft <= 0) {
      return clearInterval(interval);
    }

    const particleCount = 50 * (timeLeft / duration);
    
    confetti(Object.assign({}, defaults, {
      particleCount,
      origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
    }));
    confetti(Object.assign({}, defaults, {
      particleCount,
      origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
    }));
  }, 250);
}

/* Mise à jour d'un champ dans Grist */
async function mettreAJourChamp(todoId, champ, valeur, e) {
  try {
    e?.stopPropagation();
    // Déclencher les confettis si on passe en "Fait"
    if (champ === 'STATUT' && valeur === '✅ Fait') {
      triggerConfetti();
    }
    await grist.docApi.applyUserActions([
      ['UpdateRecord', 'TODO_LIST', parseInt(todoId), {
        [champ]: valeur,
        'DERNIERE_MISE_A_JOUR': new Date().toISOString()
      }]
    ]);
  } catch (erreur) {
    console.error('Erreur mise à jour:', erreur);
  }
}

/* Formatage des dates */
function formatDate(dateStr) {
  if (!dateStr) return '-';
  const date = new Date(dateStr);
  const day = date.getDate().toString().padStart(2, '0');
  const month = date.toLocaleDateString('fr-FR', { month: 'short' });
  const year = date.getFullYear();
  return `${day} ${month} ${year}`;
}

/* Formatage des dates pour les champs input */
function formatDateForInput(dateStr) {
  if (!dateStr) return '';
  if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
  try {
    const date = new Date(dateStr);
    return date.toISOString().split('T')[0];
  } catch (e) {
    console.error('Erreur de formatage de date:', e);
    return '';
  }
}

// ========== CRÉATION DES CARTES ET COLONNES ==========
/* Création d'une carte TODO */
function creerCarteTodo(todo) {
  const carte = document.createElement('div');
  carte.className = 'carte';
  carte.setAttribute('data-todo-id', todo.id);
  carte.setAttribute('data-last-update', todo.DERNIERE_MISE_A_JOUR || '');

  const type = todo.TYPE || '';
  const description = todo.DESCRIPTION || 'Sans titre';
  const deadline = todo.DEADLINE ? formatDate(todo.DEADLINE) : '';
  const responsable = todo.RESPONSABLE || '';
  const projetRef = todo.REFERENCE_PROJET;
  
  carte.innerHTML = `
    ${projetRef && projetRef !== 0 ? `<div class="projet-ref">#${projetRef}</div>` : ''}
    ${type ? `<div class="type-tag">${type}</div>` : ''}
    <div class="description">${description}</div>
    ${deadline ? `<div class="deadline">📅 ${deadline}</div>` : ''}
    ${responsable ? `<div class="responsable-badge">${responsable}</div>` : ''}
    ${todo.STATUT === '✅ Fait' ? '<div class="tampon-termine">✅ Fait</div>' : ''}
    ${todo.STATUT === '❌ Annulé' ? '<div class="tampon-annule">❌ Annulé</div>' : ''}
  `;

  carte.addEventListener('click', () => togglePopupTodo(todo));
  return carte;
}

/* Création d'une colonne */
function creerColonneKanban(colonne) {
  const colonneElement = document.createElement('div');
  colonneElement.className = `colonne-kanban colonne-${colonne.classe}`;
  
  const savedState = localStorage.getItem(`column-todo-${colonne.libelle}`);
  if (savedState === 'true') {
    colonneElement.classList.add('collapsed');
  }

  colonneElement.innerHTML = `
    <div class="entete-colonne entete-${colonne.classe}">
      <div class="titre-statut">${colonne.libelle} <span class="compteur-colonne">(0)</span></div>
      <button class="bouton-toggle" onclick="toggleColonne(this.closest('.colonne-kanban'), event)">⇄</button>
    </div>
    ${colonne.id === '🖐️ À faire' ? `
      <button class="bouton-ajouter" onclick="creerNouvelleTache('${colonne.id}')">+ Ajouter une tâche</button>
    ` : ''}
    <div class="contenu-colonne" data-statut="${colonne.id}"></div>
  `;

  return colonneElement;
}

/* Mise à jour des compteurs */
function mettreAJourCompteur(colonne) {
  const contenu = colonne.querySelector('.contenu-colonne');
  const compteur = colonne.querySelector('.compteur-colonne');
  if (contenu && compteur) {
    compteur.textContent = `(${contenu.children.length})`;
  }
}

/* Tri des cartes */
function trierTodo(conteneur) {
  const cartes = Array.from(conteneur.children);
  const colonne = conteneur.dataset.statut;
  
  cartes.sort((a, b) => {
    if (colonne === '✅ Fait' || colonne === '❌ Annulé') {
      // Pour les colonnes Fait et Annulé, tri par date de dernière mise à jour
      const dateA = a.getAttribute('data-last-update') || '1970-01-01';
      const dateB = b.getAttribute('data-last-update') || '1970-01-01';
      return new Date(dateB) - new Date(dateA); // Plus récent en premier
    } else {
      // Pour les autres colonnes, tri par deadline
      const dateA = a.querySelector('.deadline')?.textContent?.replace('📅 ', '') || '9999-12-31';
      const dateB = b.querySelector('.deadline')?.textContent?.replace('📅 ', '') || '9999-12-31';
      return new Date(dateA) - new Date(dateB); // Plus urgent en premier
    }
  });
  
  cartes.forEach(carte => conteneur.appendChild(carte));
}

// ========== GESTION DU POPUP ==========
/* Affichage et gestion du popup */
function togglePopupTodo(todo) {
  const popup = document.getElementById('popup-todo');
  const currentId = popup.dataset.currentTodo;
  const carteActive = document.querySelector('.carte.active');
  const carteCliquee = document.querySelector(`[data-todo-id="${todo.id}"]`);
  
  if (popup.classList.contains('visible') && currentId === todo.id.toString()) {
    fermerPopup();
    return;
  }

  if (carteActive) {
    carteActive.classList.remove('active');
  }
  
  if (carteCliquee) {
    carteCliquee.classList.add('active');
  }
  
  popup.dataset.statut = todo.STATUT;
  popup.dataset.currentTodo = todo.id;
  
  const popupTitle = popup.querySelector('.popup-title');
  const content = popup.querySelector('.popup-content');
  
  popupTitle.textContent = todo.DESCRIPTION || 'Nouvelle tâche';
  
  content.innerHTML = `
    <div class="field-row">
      <div class="field">
        <label class="field-label">Référence Projet</label>
        <input type="number" class="field-input" value="${todo.REFERENCE_PROJET || ''}" 
               onchange="mettreAJourChamp(${todo.id}, 'REFERENCE_PROJET', parseInt(this.value), event)">
      </div>
      <div class="field">
        <label class="field-label">Date limite</label>
        <input type="date" class="field-input" 
               value="${formatDateForInput(todo.DEADLINE)}"
               onchange="mettreAJourChamp(${todo.id}, 'DEADLINE', this.value, event)">
      </div>
    </div>

    <div class="field-row">
      <div class="field">
        <label class="field-label">Type</label>
        <select class="field-select" onchange="mettreAJourChamp(${todo.id}, 'TYPE', this.value, event)">
          <option value="">-</option>
          <option value="Développement" ${todo.TYPE === 'Développement' ? 'selected' : ''}>Développement</option>
          <option value="Réponse à donner" ${todo.TYPE === 'Réponse à donner' ? 'selected' : ''}>Réponse à donner</option>
          <option value="Documents à envoyer" ${todo.TYPE === 'Documents à envoyer' ? 'selected' : ''}>Documents à envoyer</option>
          <option value="Paramétrage" ${todo.TYPE === 'Paramétrage' ? 'selected' : ''}>Paramétrage</option>
          <option value="Personne à contacter" ${todo.TYPE === 'Personne à contacter' ? 'selected' : ''}>Personne à contacter</option>
        </select>
      </div>
      <div class="field">
        <label class="field-label">Responsable</label>
        <select class="field-select" onchange="mettreAJourChamp(${todo.id}, 'RESPONSABLE', this.value, event)">
          <option value="">-</option>
          <option value="Personne1" ${todo.RESPONSABLE === 'Personne1' ? 'selected' : ''}>Personne1</option>
          <option value="Personne2" ${todo.RESPONSABLE === 'Personne2' ? 'selected' : ''}>Personne2</option>
          <option value="Personne3" ${todo.RESPONSABLE === 'Personne3' ? 'selected' : ''}>Personne3</option>
        </select>
      </div>
    </div>

    <div class="field">
      <label class="field-label">Description</label>
      <textarea class="field-textarea auto-expand" 
                onchange="mettreAJourChamp(${todo.id}, 'DESCRIPTION', this.value, event)"
                oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'">${todo.DESCRIPTION || ''}</textarea>
    </div>

    <div class="field">
      <label class="field-label">Notes</label>
      <textarea class="field-textarea auto-expand" 
                onchange="mettreAJourChamp(${todo.id}, 'NOTES', this.value, event)"
                oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'">${todo.NOTES || ''}</textarea>
    </div>

    <div class="info-creation">
      Créé le ${formatDate(todo.Cree_le)} par ${todo.Cree_par || '-'}
    </div>

    <div class="popup-actions">
      <button class="popup-action-button bouton-supprimer" onclick="supprimerTodo(${todo.id}, event)" 
              title="Supprimer la tâche">🗑️</button>
    </div>
  `;

  // Initialisation des champs auto-expandables
  setTimeout(() => {
    const textareas = document.querySelectorAll('.auto-expand');
    textareas.forEach(textarea => {
      textarea.style.height = '';
      textarea.style.height = textarea.scrollHeight + 'px';
    });
  }, 0);

  popup.classList.add('visible');
}

/* Fermeture du popup */
function fermerPopup() {
  const popup = document.getElementById('popup-todo');
  const todoId = popup.dataset.currentTodo;
  const carteActive = document.querySelector(`[data-todo-id="${todoId}"]`);
  if (carteActive) {
    carteActive.classList.remove('active');
  }
  popup.classList.remove('visible');
}

// ========== GESTION DES TÂCHES ==========
/* Création d'une nouvelle tâche */
async function creerNouvelleTache(colonneId) {
  try {
    await grist.docApi.applyUserActions([
      ['AddRecord', 'TODO_LIST', null, {
        'DESCRIPTION': 'Nouvelle tâche',
        'STATUT': colonneId,
        'TYPE': '',
        'REFERENCE_PROJET': null,
        'DERNIERE_MISE_A_JOUR': new Date().toISOString(),
        'CREE_LE': new Date().toISOString()
      }]
    ]);
  } catch (erreur) {
    console.error('Erreur création:', erreur);
  }
}

/* Suppression d'une tâche */
async function supprimerTodo(todoId, e) {
  e?.stopPropagation();
  if (confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
    try {
      await grist.docApi.applyUserActions([
        ['RemoveRecord', 'TODO_LIST', parseInt(todoId)]
      ]);
      fermerPopup();
    } catch (erreur) {
      console.error('Erreur suppression:', erreur);
    }
  }
}

// ========== AFFICHAGE PRINCIPAL ==========
/* Fonction principale d'affichage du kanban */
function afficherKanban(todos) {
  const conteneurKanban = document.getElementById('conteneur-kanban');
  conteneurKanban.innerHTML = '';

  // Création des colonnes
  COLONNES_AFFICHAGE.forEach(colonneConfig => {
    const colonne = creerColonneKanban(colonneConfig);
    conteneurKanban.appendChild(colonne);
  });

  // Distribution des tâches dans les colonnes
  if (todos?.length > 0) {
    todos.forEach(todo => {
      const carte = creerCarteTodo(todo);
      const conteneurCartes = document.querySelector(`.contenu-colonne[data-statut="${todo.STATUT}"]`);
      if (conteneurCartes) {
        // Insertion au début de la colonne
        conteneurCartes.insertBefore(carte, conteneurCartes.firstChild);
      }
    });

    // Configuration du drag & drop et mise à jour des compteurs
    document.querySelectorAll('.contenu-colonne').forEach(colonne => {
      // Configuration de Sortable pour le drag & drop
      new Sortable(colonne, {
        group: 'kanban-todo',
        animation: 150,
        onEnd: async function(evt) {
          const todoId = evt.item.dataset.todoId;
          const colonneArrivee = evt.to.dataset.statut;
          try {
            await mettreAJourChamp(todoId, 'STATUT', colonneArrivee);
          } catch (erreur) {
            console.error('Erreur mise à jour statut:', erreur);
          }
        }
      });

      // Tri des cartes dans chaque colonne
      trierTodo(colonne);
    });

    // Mise à jour des compteurs
    document.querySelectorAll('.colonne-kanban').forEach(mettreAJourCompteur);
  }
}

// ========== INITIALISATION ET ÉVÉNEMENTS ==========
/* Configuration initiale de Grist */
grist.ready({
  requiredAccess: 'full',
  columns: [
    'TYPE', 'DESCRIPTION', 'DEADLINE', 
    'STATUT', 'REFERENCE_PROJET', 'NOTES', 
    'RESPONSABLE', 'CREE_PAR', 'CREE_LE', 'DERNIERE_MISE_A_JOUR'
  ]
});

/* Écoute des modifications de données */
grist.onRecords(records => {
  console.log("Tâches reçues:", records);
  afficherKanban(records);
});

/* Écoute des modifications individuelles */
grist.onRecord(record => {
  console.log("Modification reçue:", record);
  
  // Mise à jour de la carte modifiée
  const carte = document.querySelector(`[data-todo-id="${record.id}"]`);
  if (carte) {
    const nouvelleCarte = creerCarteTodo(record);
    const conteneurCartes = document.querySelector(`.contenu-colonne[data-statut="${record.STATUT}"]`);
    if (conteneurCartes === carte.parentElement) {
      carte.replaceWith(nouvelleCarte);
    } else {
      carte.remove();
      conteneurCartes.insertBefore(nouvelleCarte, conteneurCartes.firstChild);
    }
  }
  
  // Mise à jour du popup si ouvert
  const popup = document.getElementById('popup-todo');
  if (popup.classList.contains('visible') && popup.dataset.currentTodo === record.id.toString()) {
    togglePopupTodo(record);
  }
  
  // Mise à jour des compteurs et tri
  document.querySelectorAll('.colonne-kanban').forEach(mettreAJourCompteur);
  document.querySelectorAll('.contenu-colonne').forEach(trierTodo);
});

// ========== EXPORT DES FONCTIONS GLOBALES ==========
window.toggleColonne = toggleColonne;
window.togglePopupTodo = togglePopupTodo;
window.fermerPopup = fermerPopup;
window.mettreAJourChamp = mettreAJourChamp;
window.creerNouvelleTache = creerNouvelleTache;
window.supprimerTodo = supprimerTodo;

// ========== GESTION DES ÉVÉNEMENTS DU POPUP ==========
/* Fermeture avec la touche Echap */
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    fermerPopup();
  }
});

/* Fermeture au clic en dehors */
document.addEventListener('click', (e) => {
  const popup = document.getElementById('popup-todo');
  if (popup.classList.contains('visible')) {
    const popupContent = popup.querySelector('.popup-content');
    if (!popupContent.contains(e.target) && !e.target.closest('.carte') && !e.target.closest('.popup-header')) {
      fermerPopup();
    }
  }
});
3 « J'aime »

Wow c’est assez génial ce que tu arrives à faire en terme d’UI avec ce widget.
Seul problème rencontré, j’avais nommé ma table TODO_LIST au lieu de TABLE_TODO_LIST ^^
Encore bravo :wink: