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>
892 lines
31 KiB
JavaScript
892 lines
31 KiB
JavaScript
/**
|
||
* Amorçage et câblage.
|
||
*
|
||
* L'état vit ici, dans une seule variable `planning`. Toute modification passe
|
||
* par `majPlanning`, qui rend la frise et déclenche la sauvegarde : il n'y a
|
||
* qu'un seul chemin pour changer quelque chose, ce qui rend le flux facile à
|
||
* suivre.
|
||
*/
|
||
|
||
import { creerPanneau } from './detail.js';
|
||
import { creerPanneauProjet } from './projet.js';
|
||
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
|
||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||
import {
|
||
COULEURS,
|
||
ajouterJours,
|
||
ajouterPhase,
|
||
aujourdhui,
|
||
bornesPlanning,
|
||
cleTag,
|
||
creerPhase,
|
||
creerProjet,
|
||
figerReference,
|
||
libererReference,
|
||
normaliserTags,
|
||
planningVide,
|
||
pourEcriture,
|
||
projetFiltre,
|
||
remplacerPhase,
|
||
remplacerProjet,
|
||
supprimerPhase,
|
||
supprimerProjet,
|
||
teinteTag,
|
||
tousLesTags,
|
||
validerPlanning,
|
||
versUTC,
|
||
} from './model.js';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const refs = {
|
||
libelles: $('libelles'),
|
||
entete: $('entete'),
|
||
couloirs: $('couloirs'),
|
||
frise: $('frise'),
|
||
etat: $('etat-sauvegarde'),
|
||
messageVide: $('message-vide'),
|
||
messageFiltre: $('message-filtre'),
|
||
filtres: $('filtres'),
|
||
filtresListe: $('filtres-liste'),
|
||
effacerFiltres: $('effacer-filtres'),
|
||
alerte: $('alerte'),
|
||
alerteMessage: $('alerte-message'),
|
||
panneau: $('panneau'),
|
||
};
|
||
|
||
let planning = planningVide();
|
||
let echelle = null;
|
||
let selection = null; // { projet, phase }
|
||
|
||
/**
|
||
* Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle,
|
||
* c'est un état de **vue** : jamais enregistré, jamais partagé.
|
||
*
|
||
* Un filtre qui survivrait au rechargement rouvrirait le planning amputé sans
|
||
* qu'on se souvienne pourquoi. `hidden`, lui, est bien enregistré — c'est une
|
||
* décision durable sur un projet, pas un coup d'œil.
|
||
*
|
||
* La sélection est conjonctive : un projet doit porter *tous* les tags cochés.
|
||
*/
|
||
let tagsActifs = [];
|
||
|
||
/**
|
||
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
|
||
*
|
||
* Elle démarre autour des phases existantes puis s'élargit toute seule quand le
|
||
* défilement approche d'un bord, ce qui permet d'aller poser une phase dans un
|
||
* futur qu'aucune donnée n'occupe encore.
|
||
*/
|
||
let fenetre = null;
|
||
|
||
/** Marge conservée autour des phases : on peut toujours glisser au-delà. */
|
||
const MARGE_JOURS = 120;
|
||
|
||
/** Longueur ajoutée à chaque élargissement, environ six mois. */
|
||
const PAS_ELARGISSEMENT = 182;
|
||
|
||
/** Distance au bord, en pixels, à partir de laquelle on élargit. */
|
||
const SEUIL_ELARGISSEMENT = 400;
|
||
|
||
/**
|
||
* Garde-fou : l'élargissement s'arrête à dix ans de part et d'autre des
|
||
* données. « Infini » au sens de l'usage — on ne planifie pas au-delà — sans
|
||
* laisser un défilement prolongé faire enfler le DOM sans limite.
|
||
*/
|
||
const PORTEE_MAX_ANNEES = 10;
|
||
|
||
const stockage = creerStockage(afficherEtat);
|
||
const menu = creerMenu($('menu'));
|
||
|
||
const demanderConfirmation = creerDialogueConfirmation({
|
||
dialogue: $('dialogue-confirmation'),
|
||
titre: $('dialogue-confirmation-titre'),
|
||
message: $('dialogue-confirmation-message'),
|
||
valider: $('dialogue-confirmation-ok'),
|
||
});
|
||
|
||
const panneau = creerPanneau(
|
||
{
|
||
panneau: $('panneau'),
|
||
formulaire: $('formulaire-phase'),
|
||
titre: $('panneau-titre'),
|
||
erreur: $('erreur-phase'),
|
||
champFin: $('champ-fin'),
|
||
supprimer: $('supprimer-phase'),
|
||
fermer: $('fermer-panneau'),
|
||
},
|
||
{
|
||
surModification(idProjet, idPhase, phase) {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) =>
|
||
remplacerPhase(projet, idPhase, () => phase)
|
||
)
|
||
);
|
||
},
|
||
demanderSuppression(_idProjet, _idPhase, nomPhase) {
|
||
return demanderConfirmation({
|
||
titreTexte: 'Supprimer la phase',
|
||
messageTexte: `« ${nomPhase} » sera définitivement supprimée du planning.`,
|
||
libelleValider: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
},
|
||
surSuppression(idProjet, idPhase) {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase))
|
||
);
|
||
},
|
||
surFermeture() {
|
||
selection = null;
|
||
dessiner();
|
||
},
|
||
}
|
||
);
|
||
|
||
const panneauProjet = creerPanneauProjet(
|
||
{
|
||
panneau: $('panneau-projet'),
|
||
formulaire: $('formulaire-projet'),
|
||
titre: $('panneau-projet-titre'),
|
||
erreur: $('erreur-projet'),
|
||
conteneurCouleurs: $('panneau-projet-couleurs'),
|
||
suggestions: $('panneau-projet-suggestions'),
|
||
blocSuggestions: $('panneau-projet-suggestions-bloc'),
|
||
supprimer: $('supprimer-projet'),
|
||
fermer: $('fermer-panneau-projet'),
|
||
},
|
||
{ couleurs: COULEURS, normaliserTags, cleTag, teinteTag },
|
||
{
|
||
tagsConnus: () => tousLesTags(planning),
|
||
surModification(idProjet, projet) {
|
||
// L'identifiant ne change pas : il identifie, il ne décrit pas.
|
||
majPlanning(remplacerProjet(planning, idProjet, () => projet));
|
||
},
|
||
demanderSuppression(_idProjet, projet) {
|
||
return confirmerSuppressionProjet(projet);
|
||
},
|
||
surSuppression(idProjet) {
|
||
if (selection?.projet === idProjet) panneau.fermer();
|
||
majPlanning(supprimerProjet(planning, idProjet));
|
||
},
|
||
surFermeture() {
|
||
dessiner();
|
||
},
|
||
}
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// État
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Unique point d'entrée des modifications : rend puis sauvegarde. */
|
||
function majPlanning(suivant) {
|
||
planning = suivant;
|
||
// Retirer son dernier tag à un projet peut vider le filtre de sa substance :
|
||
// sans cet élagage, un tag que plus personne ne porte masquerait tout.
|
||
tagsActifs = tagsActifs.filter((tag) =>
|
||
planning.projects.some((projet) => projet.tags.some((pose) => cleTag(pose) === cleTag(tag)))
|
||
);
|
||
dessiner();
|
||
stockage.sauvegarder(pourEcriture(planning));
|
||
}
|
||
|
||
function dessiner() {
|
||
const aDesProjets = planning.projects.length > 0;
|
||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||
|
||
refs.messageVide.hidden = aDesProjets;
|
||
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
|
||
// message, la frise vide laisserait croire que les projets ont disparu.
|
||
refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0;
|
||
refs.frise.hidden = !aDesProjets || retenus.length === 0;
|
||
|
||
peindreFiltres();
|
||
|
||
if (!aDesProjets) {
|
||
echelle = null;
|
||
return;
|
||
}
|
||
|
||
elargirPourContenir();
|
||
echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
|
||
|
||
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
|
||
// sur la frise doit se voir dans les champs.
|
||
if (selection) {
|
||
const trouve = localiser(selection.projet, selection.phase);
|
||
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
|
||
}
|
||
|
||
const idProjetOuvert = panneauProjet.projetOuvert();
|
||
if (idProjetOuvert) {
|
||
const projet = planning.projects.find((p) => p.id === idProjetOuvert);
|
||
if (projet) panneauProjet.rafraichir(projet);
|
||
}
|
||
}
|
||
|
||
function localiser(idProjet, idPhase) {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (!projet) return null;
|
||
const phase = projet.phases.find((p) => p.id === idPhase);
|
||
return phase ? { projet, phase } : null;
|
||
}
|
||
|
||
function afficherEtat(etat, detail) {
|
||
const messages = {
|
||
inactif: '',
|
||
'en-cours': 'Enregistrement…',
|
||
enregistre: 'Enregistré',
|
||
erreur: detail || 'Erreur d’enregistrement',
|
||
};
|
||
refs.etat.textContent = messages[etat] ?? '';
|
||
refs.etat.dataset.etat = etat;
|
||
refs.etat.title = etat === 'erreur' ? detail || '' : '';
|
||
}
|
||
|
||
function afficherAlerte(message) {
|
||
refs.alerteMessage.textContent = message;
|
||
refs.alerte.hidden = false;
|
||
// L'alerte recouvre déjà la page, mais laisser une frise vide « visible »
|
||
// derrière elle brouillerait le diagnostic si le style venait à changer.
|
||
refs.frise.hidden = true;
|
||
refs.messageVide.hidden = true;
|
||
refs.messageFiltre.hidden = true;
|
||
refs.filtres.hidden = true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filtre par tags
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Reconstruit la barre de filtres depuis les tags réellement employés.
|
||
*
|
||
* La liste n'est pas un référentiel à tenir à jour : elle se déduit des projets,
|
||
* si bien qu'un tag apparaît dès qu'on le pose et disparaît dès qu'il n'orne
|
||
* plus rien.
|
||
*/
|
||
function peindreFiltres() {
|
||
const tags = tousLesTags(planning);
|
||
refs.filtres.hidden = tags.length === 0;
|
||
refs.effacerFiltres.hidden = tagsActifs.length === 0;
|
||
|
||
const clesActives = new Set(tagsActifs.map(cleTag));
|
||
refs.filtresListe.replaceChildren(
|
||
...tags.map((tag) => {
|
||
const bouton = document.createElement('button');
|
||
bouton.type = 'button';
|
||
bouton.className = 'tag';
|
||
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
|
||
bouton.textContent = tag;
|
||
|
||
const actif = clesActives.has(cleTag(tag));
|
||
bouton.setAttribute('aria-pressed', String(actif));
|
||
bouton.title = actif ? `Retirer « ${tag} » du filtre` : `N'afficher que les projets « ${tag} »`;
|
||
bouton.addEventListener('click', () => basculerTag(tag));
|
||
|
||
return bouton;
|
||
})
|
||
);
|
||
}
|
||
|
||
/** Ajoute le tag au filtre, ou l'en retire s'il y était déjà. */
|
||
function basculerTag(tag) {
|
||
const cle = cleTag(tag);
|
||
const restants = tagsActifs.filter((actif) => cleTag(actif) !== cle);
|
||
tagsActifs =
|
||
restants.length === tagsActifs.length ? normaliserTags([...tagsActifs, tag]) : restants;
|
||
dessiner();
|
||
}
|
||
|
||
function effacerFiltres() {
|
||
tagsActifs = [];
|
||
dessiner();
|
||
}
|
||
|
||
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
||
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Largeur de la colonne des libellés
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* La colonne héberge le nom du projet, ses tags, sa dérive et trois commandes.
|
||
* Aucune largeur fixe ne convient à la fois à « Site web » et à « Refonte du
|
||
* portail fournisseurs — lot 2 » : c'est donc à l'utilisateur de trancher.
|
||
*
|
||
* Contrairement à la fenêtre temporelle et au filtre par tags, cette largeur
|
||
* **est** conservée — dans `localStorage`, pas dans le planning. C'est un
|
||
* réglage d'affichage propre à un écran, non une donnée à synchroniser entre
|
||
* machines : le stocker dans le fichier imposerait à un portable la largeur
|
||
* choisie sur un 27 pouces. Et sans persistance, une poignée qu'il faut
|
||
* réajuster à chaque ouverture ne sert à rien.
|
||
*/
|
||
// `defaut` doit rester en accord avec `--largeur-libelles` dans style.css.
|
||
const LARGEUR_LIBELLES = { min: 150, max: 560, defaut: 300 };
|
||
const CLE_LARGEUR_LIBELLES = 'frise.largeurLibelles';
|
||
|
||
/**
|
||
* Largeur courante, tenue ici plutôt que lue sur le DOM : `offsetWidth` vaut
|
||
* zéro tant que la frise est masquée — planning vide, ou filtre qui ne retient
|
||
* rien —, et le premier glisser repartirait alors de zéro.
|
||
*/
|
||
let largeurLibelles = LARGEUR_LIBELLES.defaut;
|
||
|
||
function appliquerLargeurLibelles(largeur) {
|
||
const borne = Math.min(LARGEUR_LIBELLES.max, Math.max(LARGEUR_LIBELLES.min, Math.round(largeur)));
|
||
largeurLibelles = borne;
|
||
document.documentElement.style.setProperty('--largeur-libelles', `${borne}px`);
|
||
try {
|
||
localStorage.setItem(CLE_LARGEUR_LIBELLES, String(borne));
|
||
} catch {
|
||
// Mode privé, quota saturé : la largeur vaut pour la session, sans plus.
|
||
}
|
||
return borne;
|
||
}
|
||
|
||
function restaurerLargeurLibelles() {
|
||
let memorisee = null;
|
||
try {
|
||
memorisee = localStorage.getItem(CLE_LARGEUR_LIBELLES);
|
||
} catch {
|
||
// Lecture refusée : on garde la valeur de la feuille de style.
|
||
}
|
||
const largeur = Number(memorisee);
|
||
if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurLibelles(largeur);
|
||
}
|
||
|
||
restaurerLargeurLibelles();
|
||
|
||
{
|
||
const poignee = $('poignee-colonne');
|
||
let depart = null;
|
||
|
||
poignee.addEventListener('pointerdown', (evenement) => {
|
||
if (evenement.button !== 0) return;
|
||
depart = { x: evenement.clientX, largeur: largeurLibelles };
|
||
poignee.setPointerCapture(evenement.pointerId);
|
||
poignee.dataset.glisse = 'true';
|
||
document.body.classList.add('glisse-en-cours');
|
||
evenement.preventDefault();
|
||
});
|
||
|
||
poignee.addEventListener('pointermove', (evenement) => {
|
||
if (!depart) return;
|
||
appliquerLargeurLibelles(depart.largeur + (evenement.clientX - depart.x));
|
||
});
|
||
|
||
const terminer = (evenement) => {
|
||
if (!depart) return;
|
||
depart = null;
|
||
poignee.releasePointerCapture?.(evenement.pointerId);
|
||
delete poignee.dataset.glisse;
|
||
document.body.classList.remove('glisse-en-cours');
|
||
};
|
||
|
||
poignee.addEventListener('pointerup', terminer);
|
||
poignee.addEventListener('pointercancel', terminer);
|
||
|
||
poignee.addEventListener('dblclick', () => appliquerLargeurLibelles(LARGEUR_LIBELLES.defaut));
|
||
|
||
// Les flèches ajustent aussi la largeur quand la poignée a le focus.
|
||
// `stopPropagation` est indispensable : sans lui, le gestionnaire posé plus
|
||
// bas sur `document` décalerait en même temps la phase sélectionnée.
|
||
poignee.addEventListener('keydown', (evenement) => {
|
||
const pas = evenement.key === 'ArrowLeft' ? -10 : evenement.key === 'ArrowRight' ? 10 : 0;
|
||
if (pas === 0) return;
|
||
evenement.preventDefault();
|
||
evenement.stopPropagation();
|
||
appliquerLargeurLibelles(largeurLibelles + pas);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fenêtre temporelle et défilement sans fin
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Fenêtre de départ : les phases existantes, avec une marge de chaque côté. */
|
||
function fenetreInitiale() {
|
||
const bornes = bornesPlanning(planning);
|
||
if (!bornes) {
|
||
const jour = aujourdhui();
|
||
return { debut: ajouterJours(jour, -MARGE_JOURS), fin: ajouterJours(jour, MARGE_JOURS * 2) };
|
||
}
|
||
return {
|
||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Élargit la fenêtre si une phase est sortie de son cadre, sans jamais la
|
||
* rétrécir : le terrain déjà exploré au défilement doit rester accessible.
|
||
*/
|
||
function elargirPourContenir() {
|
||
if (!fenetre) fenetre = fenetreInitiale();
|
||
|
||
const bornes = bornesPlanning(planning);
|
||
if (!bornes) return;
|
||
|
||
const voulu = {
|
||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||
};
|
||
if (versUTC(voulu.debut) < versUTC(fenetre.debut)) fenetre.debut = voulu.debut;
|
||
if (versUTC(voulu.fin) > versUTC(fenetre.fin)) fenetre.fin = voulu.fin;
|
||
}
|
||
|
||
/** Bornes au-delà desquelles on cesse d'élargir. */
|
||
function porteeMaximale() {
|
||
const bornes = bornesPlanning(planning) ?? { start: aujourdhui(), end: aujourdhui() };
|
||
const jours = 365 * PORTEE_MAX_ANNEES;
|
||
return {
|
||
min: ajouterJours(bornes.start, -jours),
|
||
max: ajouterJours(bornes.end, jours),
|
||
};
|
||
}
|
||
|
||
// Vrai pendant qu'on élargit, pour que l'ajustement de `scrollLeft` ne relance
|
||
// pas immédiatement ce même gestionnaire.
|
||
let elargissementEnCours = false;
|
||
|
||
/**
|
||
* Élargit la fenêtre quand le défilement approche d'un bord, ce qui donne
|
||
* l'impression d'une frise sans fin.
|
||
*
|
||
* Élargir vers la gauche déplace l'origine de l'échelle : tout le contenu se
|
||
* décale vers la droite. On compense `scrollLeft` de la même quantité, sinon la
|
||
* vue sauterait en arrière à chaque élargissement.
|
||
*/
|
||
refs.frise.addEventListener('scroll', () => {
|
||
if (elargissementEnCours || !echelle || refs.frise.hidden) return;
|
||
|
||
const zone = refs.frise;
|
||
const portee = porteeMaximale();
|
||
const resteADroite = zone.scrollWidth - zone.scrollLeft - zone.clientWidth;
|
||
|
||
if (zone.scrollLeft < SEUIL_ELARGISSEMENT && versUTC(fenetre.debut) > versUTC(portee.min)) {
|
||
elargissementEnCours = true;
|
||
fenetre.debut = ajouterJours(fenetre.debut, -PAS_ELARGISSEMENT);
|
||
dessiner();
|
||
zone.scrollLeft += PAS_ELARGISSEMENT * PIXELS_PAR_JOUR;
|
||
elargissementEnCours = false;
|
||
return;
|
||
}
|
||
|
||
if (resteADroite < SEUIL_ELARGISSEMENT && versUTC(fenetre.fin) < versUTC(portee.max)) {
|
||
elargissementEnCours = true;
|
||
fenetre.fin = ajouterJours(fenetre.fin, PAS_ELARGISSEMENT);
|
||
dessiner();
|
||
elargissementEnCours = false;
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interactions de la frise
|
||
// ---------------------------------------------------------------------------
|
||
|
||
installerGlisser(refs.couloirs, {
|
||
echelle: () => echelle,
|
||
trouverPhase(idProjet, idPhase) {
|
||
return localiser(idProjet, idPhase)?.phase ?? null;
|
||
},
|
||
surDeplacement(idProjet, idPhase, phase) {
|
||
// `phase` vaut null quand le geste a été annulé ou n'a rien changé : on
|
||
// redessine pour remettre la barre à sa place, sans rien sauvegarder.
|
||
if (!phase) {
|
||
dessiner();
|
||
return;
|
||
}
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) =>
|
||
remplacerPhase(projet, idPhase, () => phase)
|
||
)
|
||
);
|
||
},
|
||
surClic(idProjet, idPhase) {
|
||
ouvrirPhase(idProjet, idPhase);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* @param {object} options
|
||
* - nomAEcraser : transmis au panneau, pour une phase fraîchement créée.
|
||
* - reveler : recentre la frise si la barre n'est pas dans le champ de
|
||
* vision. Utile depuis la colonne des libellés, où l'on peut cliquer une
|
||
* phase dont la barre est à des mois de la vue courante — sans cela, le
|
||
* panneau s'ouvre sur une phase qu'on ne voit pas, et rien n'indique de
|
||
* quel côté elle se trouve.
|
||
*/
|
||
function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) {
|
||
const trouve = localiser(idProjet, idPhase);
|
||
if (!trouve) return;
|
||
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
|
||
// l'autre.
|
||
panneauProjet.fermer();
|
||
selection = { projet: idProjet, phase: idPhase };
|
||
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||
dessiner();
|
||
// Après l'ouverture du panneau : il recouvre le bord droit de la frise, et
|
||
// `phaseVisible` doit en tenir compte.
|
||
if (reveler && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
|
||
}
|
||
|
||
/**
|
||
* La barre de la phase est-elle dans la portion réellement visible de la frise ?
|
||
*
|
||
* Deux occlusions à retrancher : la colonne des libellés, `sticky` à gauche, et
|
||
* le panneau de détail quand il est ouvert, posé sur le bord droit.
|
||
*/
|
||
function phaseVisible(phase) {
|
||
if (!echelle || refs.frise.hidden) return true;
|
||
|
||
const gauche = refs.frise.scrollLeft + largeurLibelles;
|
||
const droite =
|
||
refs.frise.scrollLeft +
|
||
refs.frise.clientWidth -
|
||
(refs.panneau.hidden ? 0 : refs.panneau.offsetWidth);
|
||
|
||
const debut = largeurLibelles + echelle.x(phase.start);
|
||
const fin = largeurLibelles + echelle.x(phase.end) + PIXELS_PAR_JOUR;
|
||
|
||
return fin > gauche && debut < droite;
|
||
}
|
||
|
||
// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase.
|
||
refs.libelles.addEventListener('click', (evenement) => {
|
||
const cible = evenement.target.closest('[data-action]');
|
||
|
||
if (!cible) {
|
||
const ligne = evenement.target.closest('.libelle-phase');
|
||
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase, { reveler: true });
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'tag') {
|
||
basculerTag(cible.dataset.tag);
|
||
return;
|
||
}
|
||
|
||
const idProjet = cible.dataset.projet;
|
||
|
||
if (cible.dataset.action === 'parametres') {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (projet) ouvrirProjet(projet);
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'ajouter') {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (projet) ajouterUnePhase(projet);
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'plier') {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => ({
|
||
...projet,
|
||
collapsed: !projet.collapsed,
|
||
}))
|
||
);
|
||
} else if (cible.dataset.action === 'masquer') {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => ({ ...projet, hidden: !projet.hidden }))
|
||
);
|
||
} else if (cible.dataset.action === 'menu') {
|
||
ouvrirMenuProjet(idProjet, cible);
|
||
}
|
||
});
|
||
|
||
// Clic droit sur un libellé de projet : même menu, ancré sur le nom.
|
||
refs.libelles.addEventListener('contextmenu', (evenement) => {
|
||
const ligne = evenement.target.closest('.libelle-projet');
|
||
if (!ligne) return;
|
||
evenement.preventDefault();
|
||
const bouton = ligne.querySelector('[data-action="menu"]');
|
||
ouvrirMenuProjet(bouton.dataset.projet, bouton);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Menu d'un projet
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function ouvrirMenuProjet(idProjet, ancre) {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (!projet) return;
|
||
|
||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||
|
||
menu.ouvrir(ancre, [
|
||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||
{
|
||
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
||
separateurAvant: true,
|
||
action: () =>
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (p) => ({ ...p, collapsed: !p.collapsed }))
|
||
),
|
||
},
|
||
{
|
||
libelle: projet.hidden ? 'Afficher sur la frise' : 'Masquer de la frise',
|
||
action: () =>
|
||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||
},
|
||
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
|
||
{
|
||
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||
separateurAvant: true,
|
||
desactive: projet.phases.length === 0,
|
||
action: () => figerLaReference(projet, aUneReference),
|
||
},
|
||
{
|
||
libelle: 'Retirer la référence',
|
||
desactive: !aUneReference,
|
||
action: () => retirerLaReference(projet),
|
||
},
|
||
{
|
||
libelle: 'Supprimer le projet',
|
||
separateurAvant: true,
|
||
danger: true,
|
||
action: () => supprimerLeProjet(projet),
|
||
},
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Ajoute une phase et ouvre son panneau.
|
||
*
|
||
* Il n'y a **pas** de commande distincte pour créer un jalon : un jalon n'est
|
||
* pas un autre objet, c'est une phase dont la fin rejoint le début — une case à
|
||
* cocher du panneau, au même titre que son statut. Proposer deux entrées
|
||
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
|
||
* bascule reste possible à tout moment ensuite.
|
||
*/
|
||
function ajouterUnePhase(projet) {
|
||
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
|
||
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
|
||
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||
// présélectionné : l'utilisateur tape directement par-dessus.
|
||
const derniere = projet.phases[projet.phases.length - 1];
|
||
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||
|
||
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||
|
||
majPlanning(
|
||
remplacerProjet(planning, projet.id, (p) => ({
|
||
...ajouterPhase(p, phase),
|
||
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
|
||
}))
|
||
);
|
||
ouvrirPhase(projet.id, phase.id, { nomAEcraser: true });
|
||
|
||
// La frise défile librement bien au-delà des phases existantes : sans ce
|
||
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
|
||
// écran, et l'ajout semblerait n'avoir rien fait.
|
||
centrerSur(phase.start);
|
||
}
|
||
|
||
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||
function ouvrirProjet(projet, options = {}) {
|
||
panneau.fermer();
|
||
panneauProjet.ouvrir(projet, options);
|
||
dessiner();
|
||
}
|
||
|
||
/**
|
||
* Éléments dont le propre gestionnaire ouvre ou rouvre un panneau. Un clic
|
||
* dessus ne doit surtout pas passer par la fermeture générale ci-dessous : elle
|
||
* redessine la frise, ce qui détacherait du DOM l'élément visé avant que son
|
||
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
|
||
*/
|
||
const CIBLES_OUVRANTES =
|
||
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
|
||
|
||
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
|
||
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
|
||
|
||
/**
|
||
* Un clic dans le vide referme le panneau ouvert.
|
||
*
|
||
* C'est ce que remplace le voile supprimé en décision 17 : celui-ci fermait au
|
||
* clic à côté, mais au prix d'une frise inerte. En filtrant les cibles plutôt
|
||
* qu'en interposant un calque, on garde les deux — la frise reste manipulable,
|
||
* et cliquer à côté ferme quand même.
|
||
*/
|
||
document.addEventListener('pointerdown', (evenement) => {
|
||
if (evenement.button !== 0) return;
|
||
if (evenement.target.closest(CIBLES_PROTEGEES)) return;
|
||
if (evenement.target.closest(CIBLES_OUVRANTES)) return;
|
||
|
||
panneau.fermer();
|
||
panneauProjet.fermer();
|
||
});
|
||
|
||
async function figerLaReference(projet, dejaFigee) {
|
||
const ok = await demanderConfirmation({
|
||
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
|
||
messageTexte: dejaFigee
|
||
? `Le planning actuel de « ${projet.name} » deviendra la nouvelle référence. ` +
|
||
'La dérive affichée repartira de zéro.'
|
||
: `Le planning actuel de « ${projet.name} » sera mémorisé comme référence. ` +
|
||
'La frise affichera ensuite son écart avec cette référence.',
|
||
libelleValider: dejaFigee ? 'Refiger' : 'Figer',
|
||
});
|
||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => figerReference(p)));
|
||
}
|
||
|
||
async function retirerLaReference(projet) {
|
||
const ok = await demanderConfirmation({
|
||
titreTexte: 'Retirer la référence',
|
||
messageTexte:
|
||
`Le planning de référence de « ${projet.name} » sera oublié, ` +
|
||
'et la frise cessera d’afficher sa dérive.',
|
||
libelleValider: 'Retirer',
|
||
danger: true,
|
||
});
|
||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p)));
|
||
}
|
||
|
||
/** Confirmation partagée par le menu du projet et son panneau. */
|
||
function confirmerSuppressionProjet(projet) {
|
||
const nombre = projet.phases.length;
|
||
return demanderConfirmation({
|
||
titreTexte: 'Supprimer le projet',
|
||
messageTexte:
|
||
`« ${projet.name} »` +
|
||
(nombre ? ` et ses ${nombre} phase${nombre > 1 ? 's' : ''}` : '') +
|
||
' seront définitivement supprimés du planning.',
|
||
libelleValider: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
}
|
||
|
||
async function supprimerLeProjet(projet) {
|
||
if (!(await confirmerSuppressionProjet(projet))) return;
|
||
|
||
if (selection?.projet === projet.id) panneau.fermer();
|
||
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
|
||
majPlanning(supprimerProjet(planning, projet.id));
|
||
}
|
||
|
||
/**
|
||
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||
* valider.
|
||
*
|
||
* Même parti pris que pour l'ajout d'une phase (docs/decisions.md, section 13) :
|
||
* le projet est créé tout de suite avec un nom provisoire, et le panneau s'ouvre
|
||
* avec ce nom présélectionné — on tape simplement par-dessus. Un dialogue de
|
||
* plus n'apportait rien, et il présentait les mêmes champs sous une autre forme
|
||
* que celle qui sert ensuite à les modifier.
|
||
*/
|
||
function nouveauProjet() {
|
||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
||
// regardant que les projets « client », c'en est presque toujours un — et
|
||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||
ouvrirProjet(projet, { nomAEcraser: true });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Barre d'outils
|
||
// ---------------------------------------------------------------------------
|
||
|
||
$('aller-aujourdhui').addEventListener('click', () => centrerSur(aujourdhui()));
|
||
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
||
$('premier-projet').addEventListener('click', nouveauProjet);
|
||
|
||
/**
|
||
* Amène une date au centre de la vue, instantanément.
|
||
*
|
||
* Volontairement pas de défilement fluide : la frise s'élargit au défilement et
|
||
* peut atteindre plusieurs dizaines de milliers de pixels. Une animation sur une
|
||
* telle distance est lente, et le gestionnaire de défilement l'interromprait en
|
||
* élargissant la fenêtre au passage près d'un bord.
|
||
*/
|
||
function centrerSur(date) {
|
||
if (!echelle) return;
|
||
|
||
// `scrollLeft` porte sur toute la grille, dont la première colonne est celle
|
||
// des libellés. Comme elle est `sticky`, elle recouvre en permanence les
|
||
// premiers pixels de la vue : le centre utile des couloirs est donc décalé
|
||
// d'une demi-largeur de colonne par rapport au centre de la frise.
|
||
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
|
||
|
||
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
||
}
|
||
|
||
// Molette + Maj : défilement horizontal, geste habituel sur une frise.
|
||
refs.frise.addEventListener(
|
||
'wheel',
|
||
(evenement) => {
|
||
if (!evenement.shiftKey || evenement.ctrlKey) return;
|
||
evenement.preventDefault();
|
||
refs.frise.scrollLeft += evenement.deltaY;
|
||
},
|
||
{ passive: false }
|
||
);
|
||
|
||
// Flèches : ajustement fin de la phase sélectionnée, au jour près.
|
||
document.addEventListener('keydown', (evenement) => {
|
||
if (!selection) return;
|
||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||
|
||
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
||
if (pas === 0) return;
|
||
|
||
evenement.preventDefault();
|
||
const trouve = localiser(selection.projet, selection.phase);
|
||
if (!trouve) return;
|
||
|
||
// Maj + flèche étire la phase au lieu de la déplacer.
|
||
const suivante = evenement.shiftKey
|
||
? etirerAuClavier(trouve.phase, pas)
|
||
: decalerAuClavier(trouve.phase, pas);
|
||
|
||
majPlanning(
|
||
remplacerProjet(planning, selection.projet, (projet) =>
|
||
remplacerPhase(projet, selection.phase, () => suivante)
|
||
)
|
||
);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Démarrage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function demarrer() {
|
||
try {
|
||
const brut = await stockage.charger();
|
||
planning = validerPlanning(brut);
|
||
} catch (err) {
|
||
afficherAlerte(
|
||
`${err.message}\n\n` +
|
||
'Le planning n’a pas été chargé, et rien ne sera enregistré tant que le ' +
|
||
'problème persiste — les données existantes sont donc intactes.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
dessiner();
|
||
viderAvantFermeture(stockage);
|
||
|
||
if (planning.projects.length) {
|
||
// Sans ce report, la largeur de la zone n'est pas encore connue et le
|
||
// centrage tombe à côté.
|
||
requestAnimationFrame(() => centrerSur(aujourdhui()));
|
||
}
|
||
|
||
console.info(
|
||
`Frise chargée : ${planning.projects.length} projet(s), ` +
|
||
`${planning.projects.reduce((n, p) => n + p.phases.length, 0)} phase(s). ` +
|
||
`Aujourd'hui : ${formaterDateLongue(aujourdhui())}.`
|
||
);
|
||
}
|
||
|
||
demarrer();
|