Files
gestion_projets/js/projet.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

246 lines
8.0 KiB
JavaScript

/**
* 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';
export function creerPanneauProjet(refs, outils, rappels) {
const {
panneau,
formulaire,
titre,
erreur,
conteneurCouleurs,
suggestions,
blocSuggestions,
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;
}
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();
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,
...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]);
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
) {
return;
}
projetCourant = suivant;
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
rappels.surModification(idCourant, suivant);
}
formulaire.addEventListener('input', () => appliquer());
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
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();
},
fermer() {
if (panneau.hidden) return;
idCourant = null;
projetCourant = null;
panneau.hidden = true;
rappels.surFermeture();
},
estOuvert() {
return idCourant !== null;
},
projetOuvert() {
return idCourant;
},
};
return api;
}