/** * Panneau de détail : édition précise d'une phase. * * Le panneau applique les changements au fil de la saisie plutôt qu'à la * validation d'un formulaire. La frise se met à jour en direct, et la * sauvegarde debouncée de storage.js absorbe la fréquence des modifications. * * C'est aussi ici qu'on saisit une date exacte : le glisser sur la frise * s'accroche au lundi, ce panneau est le recours quand une phase doit démarrer * un jeudi précis. * * Le panneau n'est **pas modal** : rien ne recouvre la frise pendant qu'il est * ouvert. Voir docs/decisions.md, section 17. */ import { STATUTS, dateValide, modifierPhase } from './model.js'; export function creerPanneau(refs, rappels) { const { panneau, formulaire, titre, erreur, champFin, supprimer, fermer } = refs; let selection = null; // { projet, phase } let phaseCourante = null; function afficherErreur(message) { erreur.textContent = message || ''; erreur.hidden = !message; } function remplir(projet, phase) { titre.textContent = `${projet.name} — ${phase.name}`; formulaire.name.value = phase.name; formulaire.start.value = phase.start; formulaire.end.value = phase.end; formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo'; formulaire.milestone.checked = phase.milestone; formulaire.notes.value = phase.notes; // Un jalon tient sur une seule date : le champ « fin » n'a rien à dire. champFin.hidden = phase.milestone; afficherErreur(null); } /** Lit le formulaire, applique les invariants et remonte le changement. */ function appliquer() { if (!selection || !phaseCourante) return; const nom = formulaire.name.value.trim(); if (!nom) { afficherErreur('Le nom ne peut pas être vide.'); return; } const debut = formulaire.start.value; if (!dateValide(debut)) { // Saisie en cours dans le champ date : on attend sans rien changer. return; } const jalon = formulaire.milestone.checked; const fin = jalon ? debut : formulaire.end.value; if (!jalon && !dateValide(fin)) return; const avant = phaseCourante; const apres = modifierPhase(phaseCourante, { name: nom, start: debut, end: fin, status: formulaire.status.value, milestone: jalon, notes: formulaire.notes.value, }); // modifierPhase a pu corriger la fin (jalon, ou fin antérieure au début). // On reflète cette correction dans le formulaire pour que l'utilisateur // voie ce qui a réellement été enregistré. if (apres.end !== fin) { formulaire.end.value = apres.end; if (!jalon) { afficherErreur('La fin précédait le début : la durée précédente a été conservée.'); } } else { afficherErreur(null); } champFin.hidden = jalon; phaseCourante = apres; if (!sontEgales(avant, apres)) { rappels.surModification(selection.projet, selection.phase, apres); } } formulaire.addEventListener('input', appliquer); formulaire.addEventListener('change', appliquer); formulaire.addEventListener('submit', (evenement) => evenement.preventDefault()); supprimer.addEventListener('click', async () => { if (!selection) return; const cible = selection; const nom = phaseCourante.name; if (!(await rappels.demanderSuppression(cible.projet, cible.phase, nom))) return; // Le panneau a pu être fermé ou changer de phase pendant la confirmation. if (selection?.phase !== cible.phase || selection?.projet !== cible.projet) return; api.fermer(); rappels.surSuppression(cible.projet, cible.phase); }); fermer.addEventListener('click', () => api.fermer()); document.addEventListener('keydown', (evenement) => { // Échap ferme le panneau, sauf pendant une saisie où il annule d'abord la // complétion native du champ. if (evenement.key === 'Escape' && !panneau.hidden) { api.fermer(); } }); const api = { /** * @param {object} options * - nomAEcraser : place le curseur dans le champ « Nom », texte * présélectionné. Réservé à une phase qui vient d'être créée, dont le * nom provisoire est justement là pour être remplacé. * * Hors de ce cas, le focus se pose sur le panneau lui-même et non dans un * champ : `app.js` ignore les flèches quand la frappe part dans un `input`, * et le raccourci le plus utile de l'outil — décaler la phase d'un jour — * serait inaccessible juste après l'avoir sélectionnée. */ ouvrir(projet, phase, { nomAEcraser = false } = {}) { selection = { projet: projet.id, phase: phase.id }; phaseCourante = phase; remplir(projet, phase); panneau.hidden = false; if (nomAEcraser) { formulaire.name.focus(); formulaire.name.select(); } else { panneau.focus(); } }, /** Recharge le contenu après une modification venue de la frise. */ rafraichir(projet, phase) { if (!selection || selection.phase !== phase.id || selection.projet !== projet.id) return; // On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur // au milieu d'une frappe. const actif = document.activeElement; phaseCourante = phase; titre.textContent = `${projet.name} — ${phase.name}`; for (const champ of ['name', 'start', 'end', 'status', 'notes']) { if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ]; } if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone; champFin.hidden = phase.milestone; }, fermer() { // Sans cette garde, fermer un panneau déjà fermé relancerait un rendu // complet de la frise — ce qui arriverait à chaque clic dans le vide. if (panneau.hidden) return; selection = null; phaseCourante = null; panneau.hidden = true; rappels.surFermeture(); }, selection() { return selection; }, }; return api; } function sontEgales(a, b) { return ( a.name === b.name && a.start === b.start && a.end === b.end && a.status === b.status && a.milestone === b.milestone && a.notes === b.notes ); }