LE JAVASCRIPT ![]()
// ========== 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();
}
}
});