Files
gestion_projets/js/detail.js
Bertrand Benjamin eaca2f7813 Revoir l'ergonomie des panneaux et de la colonne des projets
Les panneaux d'édition étaient modaux et la colonne de gauche tronquait
tout ce qu'elle portait. Sept corrections, mesurées sur data/exemple.json.

Panneaux
- Le voile qui recouvrait la page disparaît : la frise reste manipulable
  pendant l'édition, et cliquer une autre barre y bascule la sélection.
  Il rendait aussi le raccourci flèches inatteignable, le focus partant
  dans le champ « Nom » sans qu'on puisse cliquer ailleurs pour en sortir.
- Un clic dans une zone inerte referme le panneau : ce que rendait le
  voile, sans le calque qui figeait la frise.
- Les paramètres d'un projet quittent le <dialog> modal pour le même
  panneau qu'une phase, avec la même édition au fil de la saisie.
  « Nouveau projet » crée donc le projet aussitôt, comme le fait déjà
  l'ajout d'une phase : plus aucune création ne passe par un formulaire.

Colonne des projets
- Le nom ne disposait que de 56 px sur 250, et « client » s'affichait
  « c. ». Les commandes passent en surimpression au survol, les tags
  cèdent en bloc derrière un compteur, et le nom cède en dernier.
- La colonne se redimensionne, largeur conservée dans localStorage.
- L'œil disparaît : il faisait doublon avec l'entrée « Masquer » du menu.
  Un « + » le remplace, l'ajout d'une phase étant l'action la plus
  fréquente, et le nom du projet devient cliquable — il portait déjà un
  curseur « main » sans aucune action derrière.
- Plus de commande « Ajouter un jalon » : un jalon est une phase dont on
  coche la case, pas un autre objet.

Frise
- Le nom d'une phase reste accroché au bord visible quand sa barre
  s'enfonce sous la colonne.
- Cliquer un nom de phase hors écran ramène la frise sur elle.
- La barre cumulative d'un projet s'affiche aussi quand il est déplié,
  amincie pour ne pas se faire passer pour une barre de phase.

Arbitrages consignés dans docs/decisions.md, sections 8 et 17 à 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:58:47 +02:00

190 lines
6.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';
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
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.notes.value = phase.notes;
// 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,
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', 'notes']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
}
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
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.notes === b.notes
);
}