gestion_table_tournante_libre/src/interface/generation/Reglages.svelte
Mathieu Benoit c6082c3af2 [IMP] proposals, generation, badges, preview: cards and grouped headers
The proposal table had a dozen measure columns with no grouping. Five
group headers now gather neighbouring measures without changing any
column text; numbers align right in tabular figures, and the kept
placement row wears the Retenu tint and a « retenue » pill. Generation
settings and progress, badges and the paper choice become cards, each
paper a distinct choice; the preview toolbar groups its commands. The
printed paper is unchanged. Checked: node 172, browser 162.

--- FR ---

[IMP] propositions, génération, badges, aperçu : cartes, intitulés

Le tableau des propositions alignait une douzaine de mesures sans
groupe. Cinq intitulés de groupe réunissent les mesures voisines sans
changer le texte d'aucune colonne ; les nombres s'alignent à droite en
chiffres tabulaires, la ligne du retenu porte la teinte Retenu et une
pastille « retenue ». Réglages et avancement, badges et papier
deviennent des cartes, chaque papier un choix distinct ; la barre de
l'aperçu groupe ses commandes. Le papier imprimé ne change pas.
Vérifié : node 172, navigateur 162.

Assisted-by: Claude Opus 5.5
2026-10-11 00:58:07 -04:00

329 lines
12 KiB
Svelte

<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les réglages d'une génération (§ 5.7, § 5.10) : la graine, puis le
// nombre de propositions, le compte d'arrêt et l'historique, chacun sous
// son libellé, qui porte son unité ; puis la durée observée du dernier
// calcul de la séance, et ce qu'elle laisse attendre des réglages courants.
// Aucun champ ne prend une durée : la recherche s'arrête sur un compte.
//
// Les réglages de la charge sont ceux de definition, par défaut
// REGLAGES_GENERATION (reglages.js), la définition dont s'engendre aussi
// le tableau de la documentation : chaque entrée de la section
// 'generation' paraît, dans son ordre, champ ou bascule selon son genre,
// sous son libellé et désignée par sa clé (data-champ) ; une entrée d'une
// autre section appartient à un autre écran. Une épreuve passe une
// définition à elle.
//
// Les champs (generation.nombre, generation.arret,
// generation.historique) sont des Champ : une saisie se lit par la
// validation du contrôleur de génération, la même que celle de
// « Générer », et s'écrit à la sortie du champ par modifierReglage — une
// entrée par changement ; en lecture, la séance refuse la première frappe.
//
// La graine n'est pas au fichier : c'est une saisie de la commande, vivante
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire.
//
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
// bascule. À l'issue du geste, la case montre la valeur de la charge,
// écrite ou inchangée. Un refus de la séance — lecture, plan
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
// rien ne s'écrit.
//
// Les cœurs de calcul sont un réglage du poste, que le contrôleur de
// génération tient pour la séance du logiciel : ni le fichier ni le journal
// ne les portent, et ils se règlent en lecture comme en écriture. La case
// « Cœurs automatiques », cochée par défaut, emploie un cœur par
// processeur logique ; décochée, elle montre « Nombre de cœurs », pré-rempli
// du dernier nombre accepté. La saisie se valide à la sortie du champ ou
// par Entrée, par controleur.reglerCoeurs ; son refus se pose contre le
// champ, saisie gardée, et le parent le lit (refusCoeurs, lié) : un refus
// posé, « Générer » ne part pas. Échap rend le dernier nombre accepté ; les
// deux touches sont celles du contexte 'champ' de la table des raccourcis.
// La valeur automatique du poste s'affiche à côté, dans les deux états.
//
// Habillage : la graine et les cœurs se saisissent comme un Champ, sur le
// fond de la carte au filet de contrôle ; les contraintes et les cœurs sont
// deux groupes sous un intertitre en petites capitales.
import { getContext, untrack } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js';
import { REGLAGES_GENERATION, reglagesDeSection, reglagesDuGenre, valeurDuReglage } from './reglages.js';
let {
charge,
mesure,
estimation,
graine = $bindable(),
refusGraine = null,
saisirGraine,
refusCoeurs = $bindable(null),
definition = REGLAGES_GENERATION,
} = $props();
const t = getContext('t');
const seance = getContext('seance');
const generation = getContext('generation');
const uid = $props.id();
const idGraine = `${uid}-graine`;
const idRefusGraine = `${uid}-graine-refus`;
const idCoeursAutomatiques = `${uid}-coeurs-automatiques`;
const idCoeurs = `${uid}-coeurs`;
const idRefusCoeurs = `${uid}-coeurs-refus`;
// Le réglage des cœurs, tel que le contrôleur le publie, et le texte du
// champ « Nombre de cœurs ».
const coeurs = $derived($generation.coeurs);
let saisieCoeurs = $state(untrack(() => String(generation.coeurs().manuel)));
// Rend au champ le dernier nombre accepté, sans refus.
function rendreCoeurs() {
saisieCoeurs = String(generation.coeurs().manuel);
refusCoeurs = null;
}
function basculerCoeurs(evenement) {
generation.reglerCoeurs({ automatique: evenement.currentTarget.checked });
rendreCoeurs();
}
function validerCoeurs() {
refusCoeurs = generation.reglerCoeurs({ saisie: saisieCoeurs }).refus;
}
function toucheCoeurs(evenement) {
const effet = effetDe('champ', evenement.key);
if (effet === 'rendre') rendreCoeurs();
else if (effet === 'valider') {
evenement.preventDefault();
validerCoeurs();
}
}
// Les champs et les bascules de la section de la génération, chacun dans
// l'ordre de la définition.
const deLaGeneration = $derived(reglagesDeSection(definition, 'generation'));
const champs = $derived(reglagesDuGenre(deLaGeneration, 'champ'));
const bascules = $derived(reglagesDuGenre(deLaGeneration, 'bascule'));
// La lecture d'une saisie du réglage nom : la validation du contrôleur sur
// les réglages proposés, ce réglage remplacé par la saisie ; son refus, ou
// à défaut le premier, sinon la valeur lue.
const lecteur = (nom) => (texte) => {
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), [nom]: texte });
const propre = refus.find(({ champ }) => champ === nom) ?? refus[0];
if (propre !== undefined) return { refus: { code: propre.code, details: propre.details } };
return { valeur: reglages[nom] };
};
const ecrire = (cle) => (valeur) => seance.executer('modifierReglage', { cle, valeur });
// Le refus de la dernière bascule, { cle, refus }, posé à côté d'elle.
let refusBascule = $state.raw(null);
// Le clic sur une case : la séance écrit la valeur contraire de celle de
// la charge ; à l'issue, la case montre celle de la charge, écrite ou
// inchangée. Annuler le clic ne suffirait pas : la séance peut avoir écrit
// avant que le navigateur rétablisse la case, à la fin de l'événement.
async function basculer(evenement, entree) {
const caseACocher = evenement.currentTarget;
const { cle } = entree;
refusBascule = null;
try {
await seance.executer('modifierReglage', { cle, valeur: !valeurDuReglage(charge.reglages, entree) });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusBascule = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
} finally {
caseACocher.checked = valeurDuReglage(charge.reglages, entree);
}
}
</script>
<div class="reglages" data-reglages>
<div class="champs">
<div class="champ">
<label for={idGraine}>{t('generation.reglage.graine')}</label>
<input
id={idGraine}
type="text"
data-champ="graine"
bind:value={graine}
aria-invalid={refusGraine === null ? undefined : 'true'}
aria-describedby={refusGraine === null ? undefined : idRefusGraine}
oninput={() => saisirGraine()}
/>
{#if refusGraine !== null}
<Refus id={idRefusGraine} refus={refusGraine} />
{/if}
</div>
{#each champs as entree (entree.cle)}
<Champ
cle={entree.cle}
etiquette={entree.etiquette}
valeur={valeurDuReglage(charge.reglages, entree)}
lire={lecteur(entree.nom)}
executer={ecrire(entree.cle)}
/>
{/each}
</div>
<fieldset class="bascules">
<legend>{t('generation.contraintes.titre')}</legend>
{#each bascules as entree (entree.cle)}
{@const { cle, etiquette } = entree}
<div class="bascule">
<input
id={`${uid}-${cle}`}
type="checkbox"
data-champ={cle}
checked={valeurDuReglage(charge.reglages, entree)}
onclick={(evenement) => basculer(evenement, entree)}
/>
<label for={`${uid}-${cle}`}>{t(etiquette)}</label>
{#if refusBascule !== null && refusBascule.cle === cle}
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
{/if}
</div>
{/each}
</fieldset>
<fieldset class="coeurs" data-coeurs>
<legend>{t('generation.coeurs.titre')}</legend>
<div class="bascule">
<input
id={idCoeursAutomatiques}
type="checkbox"
data-champ="coeursAutomatiques"
checked={coeurs.automatique}
onchange={basculerCoeurs}
/>
<label for={idCoeursAutomatiques}>{t('generation.coeurs.automatique')}</label>
</div>
{#if !coeurs.automatique}
<div class="champ">
<label for={idCoeurs}>{t('generation.coeurs.nombre', { disponibles: coeurs.disponibles })}</label>
<input
id={idCoeurs}
type="text"
inputmode="numeric"
data-champ="coeurs"
bind:value={saisieCoeurs}
aria-invalid={refusCoeurs === null ? undefined : 'true'}
aria-describedby={refusCoeurs === null ? undefined : idRefusCoeurs}
oninput={() => (refusCoeurs = null)}
onkeydown={toucheCoeurs}
onblur={validerCoeurs}
/>
{#if refusCoeurs !== null}
<Refus id={idRefusCoeurs} refus={refusCoeurs} />
{/if}
</div>
{/if}
<p data-coeurs-disponibles>{t('generation.coeurs.disponibles', { disponibles: coeurs.disponibles })}</p>
<p>{t('generation.coeurs.note')}</p>
</fieldset>
{#if mesure === null}
<p class="note" data-duree>{t('generation.duree.inconnue')}</p>
{:else}
<p class="note" data-duree>{t('generation.duree.observee', mesure)}</p>
<p class="note" data-estimation>{t('generation.duree.estimee', estimation)}</p>
{/if}
</div>
<style>
.reglages {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
.champs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-4);
}
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
/* Les deux groupes : un filet de carte, l'intertitre en petites capitales
d'encre secondaire. */
.coeurs,
.bascules {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
}
.coeurs {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
.bascules {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-5);
}
legend {
padding: 0 var(--espace-1);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
.bascule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1);
}
.bascule input {
margin: 0;
}
p {
margin: 0;
}
.note {
color: var(--couleur-page-texte-secondaire);
}
</style>