Files
gestion_projets/js/projet.js
Bertrand Benjamin 60a91ba5b2 Dessiner l'horizon sur la frise, l'éditer et le glisser
La barre d'un projet envisagé est pâle et hachurée, aux bords fondus.
Elle se glisse et se redimensionne comme une phase, mais accrochée au
mois : le geste existe, il ne permet pas de poser une date qu'on n'a
pas. Le panneau ajoute deux champs de mois et trois crans.

Une barre « Afficher » filtre par état, disjonctive là où celle des
tags est conjonctive — d'où des boutons carrés et non des pastilles.
Les écartés en sont décochés au départ.

Engager démarre au premier jour de l'horizon, et clore un projet
demande confirmation quand il reste des phases à écraser.

Au passage : releasePointerCapture peut lever quand le pointeur a
disparu, ce qui sautait l'application du geste. Le nettoyage en est
maintenant isolé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:58:05 +02:00

416 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Panneau des paramètres d'un projet : nom, couleur, tags.
*
* Jumeau de `detail.js`, et volontairement : renommer un projet et renommer une
* phase sont la même opération à un niveau de granularité près. Elles se
* présentent donc pareil — même panneau latéral, même édition au fil de la
* saisie, même bouton de suppression au bas du formulaire.
*
* Ces paramètres passaient auparavant par un `<dialog>` modal à valider. Voir
* docs/decisions.md, section 21.
*/
import { creerBlocNotes } from './notes.js';
import {
LIBELLES_ETAT,
decalerHorizon,
definirHorizon,
ecarterProjet,
etatProjet,
formaterDateLongue,
formaterHorizon,
moisValide,
projetADater,
reanimerProjet,
rouvrirProjet,
terminerProjet,
} from './model.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
panneau,
formulaire,
titre,
erreur,
conteneurCouleurs,
suggestions,
blocSuggestions,
etat,
blocHorizon,
crans,
aideHorizon,
terminer,
ecarter,
notes,
supprimer,
fermer,
} = refs;
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes du projet' });
let idCourant = null;
let projetCourant = null;
function afficherErreur(message) {
erreur.textContent = message || '';
erreur.hidden = !message;
}
/** Tags saisis dans le champ, tels qu'ils seront enregistrés. */
function tagsSaisis() {
return normaliserTags(formulaire.tags.value.split(','));
}
/**
* La palette est bâtie une fois pour toutes, et seul l'état pressé change
* ensuite. La reconstruire à chaque frappe ferait perdre le focus à qui
* parcourt les couleurs au clavier — et l'édition en direct provoque un
* rafraîchissement à chaque caractère saisi dans le nom.
*/
function peindrePalette(choisie) {
if (!conteneurCouleurs.childElementCount) {
for (const couleur of couleurs) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'pastille';
bouton.dataset.couleur = couleur;
bouton.style.background = couleur;
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
// Une couleur se choisit d'un clic et s'applique aussitôt : la frise
// change sous les yeux, ce qui est le seul moyen de juger si la teinte
// se distingue bien de celle des couloirs voisins.
bouton.addEventListener('click', () => appliquer({ color: couleur }));
conteneurCouleurs.append(bouton);
}
}
for (const bouton of conteneurCouleurs.children) {
bouton.setAttribute('aria-pressed', String(bouton.dataset.couleur === choisie));
}
}
/**
* Tags déjà employés ailleurs dans le planning, proposés en un clic.
*
* Sans eux, chaque projet inventerait sa propre orthographe et le filtre se
* remplirait de « client », « Clients » et « client final ».
*/
function peindreSuggestions() {
const tagsConnus = rappels.tagsConnus();
// La liste change dès qu'un tag apparaît ou disparaît du planning : on la
// reconstruit, mais on rend le focus au bouton de même rang, sans quoi
// cliquer une suggestion au clavier éjecterait vers le début du document.
const focalise = [...suggestions.children].indexOf(document.activeElement);
suggestions.replaceChildren();
const posees = new Set(tagsSaisis().map(cleTag));
for (const tag of tagsConnus) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'tag';
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
bouton.textContent = tag;
bouton.setAttribute('aria-pressed', String(posees.has(cleTag(tag))));
bouton.addEventListener('click', () => {
const saisis = tagsSaisis();
const restants = saisis.filter((pose) => cleTag(pose) !== cleTag(tag));
const suivants = restants.length === saisis.length ? [...saisis, tag] : restants;
formulaire.tags.value = normaliserTags(suivants).join(', ');
appliquer();
});
suggestions.append(bouton);
}
if (focalise >= 0) suggestions.children[focalise]?.focus();
blocSuggestions.hidden = tagsConnus.length === 0;
}
/**
* Horizon lu dans les deux champs de mois.
*
* Une borne laissée vide prend la valeur de l'autre : on saisit souvent un
* mois unique, et exiger de le taper deux fois serait une formalité. Les deux
* vides valent « pas d'horizon » — un état légal, celui d'un projet hérité
* d'un fichier en version 4, qui se signalera comme réclamant une date.
*
* Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est
* un champ texte, d'où la vérification plutôt qu'une confiance faite au
* navigateur.
*/
function horizonSaisi() {
const debut = formulaire.horizonStart.value;
const fin = formulaire.horizonEnd.value;
if (!moisValide(debut) && !moisValide(fin)) return undefined;
if (!moisValide(debut)) return { start: fin, end: fin };
if (!moisValide(fin)) return { start: debut, end: debut };
return definirHorizon({}, debut, fin).horizon;
}
/**
* Le bloc « Horizon » ne paraît que pour un projet envisagé.
*
* Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et
* l'horizon n'est plus qu'une valeur de repli conservée en silence : la
* proposer à la saisie laisserait croire qu'elle déplace encore quelque chose.
*/
function peindreCycleDeVie(projet) {
const nom = etatProjet(projet);
const envisage = nom === 'considered';
etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom);
etat.dataset.etat = nom;
// Chaque acte se présente dans un sens ou dans l'autre selon où en est le
// projet : une commande qui bascule plutôt que deux dont l'une serait
// toujours grise.
terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé';
// Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par
// « Remettre en jeu » avant tout autre chose.
terminer.disabled = nom === 'considered' || nom === 'discarded';
terminer.title = terminer.disabled
? nom === 'considered'
? "Un projet envisagé n'a pas encore commencé."
: "Remettre le projet en jeu d'abord."
: nom === 'completed'
? 'Les phases resteront terminées : leur statut davant est perdu.'
: 'Toutes les phases et tous les jalons passeront à « Terminé ».';
ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet';
ecarter.title =
nom === 'discarded'
? "Le projet retrouvera l'état que ses données commandent."
: 'Le projet quitte la frise mais garde tout, notes comprises.';
blocHorizon.hidden = !envisage;
if (!envisage) return;
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au
// milieu d'une frappe, comme pour le nom et les tags.
const actif = document.activeElement;
if (formulaire.horizonStart !== actif) {
formulaire.horizonStart.value = projet.horizon?.start ?? '';
}
if (formulaire.horizonEnd !== actif) {
formulaire.horizonEnd.value = projet.horizon?.end ?? '';
}
const aDater = projetADater(projet);
aideHorizon.textContent = aDater
? projet.horizon
? 'Cet horizon est passé : le repousser, ou écarter le projet.'
: "Sans horizon, ce projet n'est situé nulle part sur la frise."
: 'Ajouter une phase engage le projet et lui donne des dates fermes.';
aideHorizon.dataset.alerte = String(aDater);
// Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la
// largeur d'un intervalle, ils n'en inventent pas.
for (const bouton of crans.children) bouton.disabled = !projet.horizon;
}
/** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */
function legendeEtat(projet, nom) {
if (nom === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (nom === 'engaged') {
return `${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`;
}
if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
return ` le ${formaterDateLongue(projet.discardedDate)}`;
}
function remplir(projet) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
formulaire.tags.value = projet.tags.join(', ');
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
peindreCycleDeVie(projet);
afficherErreur(null);
}
/**
* Lit le formulaire et remonte le changement.
*
* @param {object} forces champs imposés hors formulaire — la couleur, qui se
* choisit dans la palette et n'a pas de champ de saisie.
*/
function appliquer(forces = {}) {
if (!idCourant || !projetCourant) return;
const nom = formulaire.name.value.trim();
if (!nom) {
// Le projet garde son nom précédent : on signale sans rien écraser, comme
// le fait le panneau d'une phase.
afficherErreur('Le nom ne peut pas être vide.');
return;
}
afficherErreur(null);
const suivant = {
...projetCourant,
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
// L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas
// effacé pour autant : c'est ce qui permet à un projet ayant perdu sa
// dernière phase de le retrouver plutôt qu'une enveloppe reconstituée.
horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon,
...forces,
};
// Comparaison terme à terme plutôt que par concaténation : un tag peut
// contenir une espace, et « a b » ne doit pas passer pour « a », « b ».
const memesTags =
suivant.tags.length === projetCourant.tags.length &&
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
const memeHorizon =
suivant.horizon?.start === projetCourant.horizon?.start &&
suivant.horizon?.end === projetCourant.horizon?.end;
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
// Les deux dates d'acte comptent ici comme les autres champs : sans elles,
// déclarer un projet terminé ne changerait aucune des valeurs comparées et
// la modification serait tue.
suivant.completedDate === projetCourant.completedDate &&
suivant.discardedDate === projetCourant.discardedDate &&
memesTags &&
memeHorizon
) {
return;
}
projetCourant = suivant;
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
peindreCycleDeVie(suivant);
rappels.surModification(idCourant, suivant);
}
formulaire.addEventListener('input', () => appliquer());
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
// Repousser par crans est le seul geste qui déplace un horizon : la barre de
// la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait
// réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain.
crans.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('[data-cran]');
if (!bouton || !projetCourant?.horizon) return;
appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon });
});
// Les deux actes passent par `appliquer`, comme le reste du panneau : c'est
// lui qui compare, remonte la modification et repeint.
terminer.addEventListener('click', async () => {
if (!projetCourant) return;
if (etatProjet(projetCourant) === 'completed') {
appliquer(rouvrirProjet(projetCourant));
return;
}
// Clore termine aussi les phases : la confirmation vit dans app.js, qui la
// partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis
// un endroit et se taire depuis l'autre.
const cible = idCourant;
if (!(await rappels.demanderCloture(projetCourant))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible || !projetCourant) return;
appliquer(terminerProjet(projetCourant));
});
ecarter.addEventListener('click', () => {
if (!projetCourant) return;
const nom = etatProjet(projetCourant);
appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant));
});
supprimer.addEventListener('click', async () => {
if (!idCourant) return;
const cible = idCourant;
const projet = projetCourant;
if (!(await rappels.demanderSuppression(cible, projet))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible) return;
api.fermer();
rappels.surSuppression(cible);
});
fermer.addEventListener('click', () => api.fermer());
document.addEventListener('keydown', (evenement) => {
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é à un projet qui vient d'être créé, dont le nom
* provisoire est justement là pour être remplacé.
*/
ouvrir(projet, { nomAEcraser = false } = {}) {
idCourant = projet.id;
projetCourant = projet;
remplir(projet);
panneau.hidden = false;
if (nomAEcraser) {
formulaire.name.focus();
formulaire.name.select();
} else {
panneau.focus();
}
},
/** Recharge le contenu après une modification venue d'ailleurs. */
rafraichir(projet) {
if (idCourant !== projet.id) return;
projetCourant = projet;
titre.textContent = projet.name;
// 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;
if (formulaire.name !== actif) formulaire.name.value = projet.name;
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
// L'état a pu changer sans passer par ce panneau : ajouter la première
// phase engage le projet, et le bloc « Horizon » doit disparaître au même
// instant que la barre pâle sur la frise.
peindreCycleDeVie(projet);
},
fermer() {
if (panneau.hidden) return;
idCourant = null;
projetCourant = null;
panneau.hidden = true;
rappels.surFermeture();
},
estOuvert() {
return idCourant !== null;
},
projetOuvert() {
return idCourant;
},
};
return api;
}