Files
gestion_projets/js/detail.js
Bertrand Benjamin 815e144d9a
All checks were successful
Build / build (push) Successful in 1m2s
Rendre lisibles le statut et la date imposée
Statut : quatre boutons peints de leur texture, si bien que le panneau
sert enfin de légende. « En cours » se raye et « terminé » redevient vif
— la trame les distingue, la teinte n'a plus à le faire.

Date imposée : un cadre tireté cerne la forme entière. Les taquets
tombaient sous les poignées de redimensionnement et disparaissaient sur
une barre courte. Son libellé passe dans le modèle, ce qui la fait
apparaître dans la vue par mois, qui la taisait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:17:15 +02:00

277 lines
9.9 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';
/**
* Les champs de la phase, et la forme de leur contrôle.
*
* Une seule liste, parcourue par tout ce qui traite les champs en bloc :
* l'affichage, le rafraîchissement et la comparaison. Ajouter `fixed` avait
* demandé quatre retouches dispersées, dont on ne savait qu'en les cherchant
* qu'elles allaient ensemble ; il n'y a plus qu'une ligne à ajouter ici, et le
* cas particulier à écrire s'il y en a un.
*
* `notes` n'y est pas : son contrôle est bâti par `notes.js` et se remplit par
* lui, jamais en écrivant directement dans le `textarea` — l'aperçu markdown ne
* suivrait pas. Il reste comparé à part, pour la même raison.
*/
const CHAMPS = [
{ nom: 'name' },
{ nom: 'start' },
{ nom: 'end' },
{ nom: 'status' },
{ nom: 'milestone', case: true },
{ nom: 'fixed', case: true },
];
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champDates, 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}`;
for (const champ of CHAMPS) ecrire(champ, phase);
// 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 });
peindre(projet);
accorderLesChamps(phase);
afficherErreur(null);
}
/**
* Donne au formulaire la couleur du projet qu'il édite : les textures des
* quatre boutons de statut s'y peignent, comme la barre qu'on regarde
* derrière le panneau. Rejoué au rafraîchissement, faute de quoi recolorer un
* projet laisserait le panneau à sa teinte d'ouverture.
*/
function peindre(projet) {
formulaire.style.setProperty('--couleur-projet', projet.color);
}
/** Écrit un champ du modèle dans son contrôle, quelle que soit sa forme. */
function ecrire(champ, phase) {
const controle = formulaire[champ.nom];
if (champ.case) controle.checked = phase[champ.nom];
// Les quatre boutons de statut portent le même nom : leur affecter une
// valeur inconnue les décocherait tous, et le panneau n'afficherait plus
// rien là où le modèle, lui, retombe sur « à venir ».
else if (champ.nom === 'status') controle.value = statutAffiche(phase);
else controle.value = phase[champ.nom];
}
/**
* Ce que le formulaire montre en plus de ses valeurs : les champs qui n'ont
* pas lieu d'être, et la marque de ceux qu'on ne décide pas.
*
* Rejoué à chaque frappe et pas seulement à l'ouverture : les deux cases
* changent ce que le reste du panneau donne à voir, et l'effet doit suivre le
* clic qui le déclenche.
*/
function accorderLesChamps(phase) {
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
champFin.hidden = phase.milestone;
// Le cadre tireté de la frise, reporté sur les dates que la case qualifie.
champDates.classList.toggle('champ-double--imposee', phase.fixed);
}
/** 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);
}
accorderLesChamps(apres);
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 CHAMPS) {
if (!aLeFocus(formulaire[champ.nom], actif)) ecrire(champ, phase);
}
// 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);
peindre(projet);
accorderLesChamps(phase);
},
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 CHAMPS.every((champ) => a[champ.nom] === b[champ.nom]) && a.notes === b.notes;
}
/**
* Le statut que le formulaire doit montrer.
*
* Le modèle ramène un statut inconnu à « à venir » sans bloquer le chargement
* (model.js) ; le panneau fait le même choix, faute de quoi il n'afficherait
* rien là où la frise, elle, dessine une barre.
*/
function statutAffiche(phase) {
return STATUTS.includes(phase.status) ? phase.status : 'todo';
}
/**
* Ce contrôle a-t-il le focus ?
*
* Les quatre boutons de statut partagent un nom, si bien que `formulaire.status`
* est la liste des quatre et non l'un d'eux : elle n'est jamais l'élément
* focalisé, et sans ce détour le panneau réécrirait le statut sous les flèches
* de qui est en train de le choisir.
*/
function aLeFocus(controle, actif) {
if (controle instanceof RadioNodeList) return [...controle].includes(actif);
return controle === actif;
}