Glisser la barre résumé décale toutes les phases du même nombre de jours, écarts conservés : un projet engagé glisse souvent, et le faire phase par phase les déformait. Restent en place ce qui est terminé — on ne réécrit pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour ce qui est délégué ou tenu du dehors. D'où le format en version 6 : sans l'incrément, un binaire antérieur effacerait ces dates imposées à la première sauvegarde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
7.2 KiB
JavaScript
208 lines
7.2 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;
|
|
formulaire.fixed.checked = phase.fixed;
|
|
// 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,
|
|
fixed: formulaire.fixed.checked,
|
|
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;
|
|
if (formulaire.fixed !== actif) formulaire.fixed.checked = phase.fixed;
|
|
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.fixed === b.fixed &&
|
|
a.notes === b.notes
|
|
);
|
|
}
|