Frise multi-projets : outil de planification macro
Une frise chronologique unique où plusieurs projets s'empilent en couloirs pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni dépendances entre phases. - Manipulation directe des barres (glisser, redimensionner), accrochées au lundi - Couloirs pliables : plié, un projet devient une barre segmentée par phase - Planning de référence figeable, avec barre fantôme et calcul de dérive - En-tête à trois bandes : année, mois, numéro de semaine ISO 8601 - Frise qui s'élargit au défilement, pour planifier dans un futur encore vide - Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data, avec sauvegarde horodatée avant chaque écriture - Zéro build : modules ES natifs, aucune dépendance à installer 50 tests unitaires sur la logique métier (node --test, sans dépendance). Vérifié dans Chromium et Firefox. Les arbitrages de conception et surtout leurs raisons sont consignés dans docs/decisions.md — notamment l'abandon des dépendances entre phases, de la File System Access API, du SVG et des niveaux de zoom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
170
js/detail.js
Normal file
170
js/detail.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
||||
|
||||
export function creerPanneau(refs, rappels) {
|
||||
const { panneau, voile, 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());
|
||||
voile.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 = {
|
||||
ouvrir(projet, phase) {
|
||||
selection = { projet: projet.id, phase: phase.id };
|
||||
phaseCourante = phase;
|
||||
remplir(projet, phase);
|
||||
panneau.hidden = false;
|
||||
voile.hidden = false;
|
||||
formulaire.name.focus();
|
||||
formulaire.name.select();
|
||||
},
|
||||
|
||||
/** 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() {
|
||||
selection = null;
|
||||
phaseCourante = null;
|
||||
panneau.hidden = true;
|
||||
voile.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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user