[Custom Widget] Kanban

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 »