Files
gestion_projets/js/detail.js
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
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>
2026-08-10 18:07:31 +02:00

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
);
}