Files
gestion_projets/js/detail.js
Bertrand Benjamin ff147a17b9 Rendre les notes en markdown, et en donner au projet
Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.

Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.

Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).

La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.

Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.

Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:35:17 +02:00

204 lines
7.1 KiB
JavaScript

/**
* 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';
import { creerBlocNotes } from './notes.js';
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, notes, supprimer, fermer } = refs;
// Le bloc pose lui-même un `<textarea name="notes">` dans le formulaire, et
// émet un `input` à chaque changement — bouton de la barre d'outils ou case
// cochée comprises. Le reste du panneau continue donc de lire son formulaire
// sans rien savoir du markdown.
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes' });
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;
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
// y écrire quelque chose.
blocNotes.definir(phase.notes, { reinitialiserMode: true });
// 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']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
}
// Le mode n'est pas réinitialisé : basculer en aperçu sous les doigts de
// qui écrit, parce qu'un glisser sur la frise a rafraîchi le panneau,
// serait le pire moment pour changer d'état.
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
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
);
}