[IMP] participants, tables, import: counts, membership pills, messages
Long participant lists were hard to scan by organisation. The list now opens on counts and a legend of memberships, each with its headcount; the grid shows a membership as a pill of its tint, the expected title and the exclusion as icon pills, and row commands wrap without breaking their labels at 1024 px. The import preview and report class each message as refus, avis or information. Editing and shortcuts are as before. Checked: node 144, browser 117, 9 mutations caught. --- FR --- [IMP] participants, tables, import : comptes, pastilles, messages Une longue liste se parcourait mal par organisation. Elle s'ouvre sur des comptes et une légende des appartenances avec leur effectif ; la grille montre l'appartenance en pastille de sa teinte, le titre pressenti et l'exclusion en pastilles à icône, et les commandes de ligne passent à la ligne sans casser leur libellé à 1024 px. L'aperçu et le rapport d'import classent chaque message en refus, avis ou information. L'édition et les raccourcis ne changent pas. Vérifié : node 144, navigateur 117, 9 mutations prises. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
8b69b63a32
commit
c4130d3b5e
23 changed files with 1607 additions and 392 deletions
|
|
@ -17,8 +17,16 @@
|
|||
// choisir(champ, rang) : tranche une ambiguïté, null pour un aperçu figé.
|
||||
// Chaque donnée — nom de fichier, en-tête, cellule, rang, compte — occupe
|
||||
// son propre nœud de texte.
|
||||
//
|
||||
// Chaque message est classé (Message, messageDuBloc) : le refus global et
|
||||
// les lignes refusées en refus, les doublons, les homonymes et les
|
||||
// cellules sur plusieurs lignes en avis, les orthographes fondues en
|
||||
// information. Les tableaux prennent le langage des cartes : en-tête sur
|
||||
// le fond sourd, lignes séparées d'un filet léger.
|
||||
import { getContext } from 'svelte';
|
||||
import { COLONNES_DES_LIGNES, motifLigne, nommer } from './modele.js';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import { COLONNES_DES_LIGNES, messageDuBloc, motifLigne, nommer } from './modele.js';
|
||||
|
||||
let { modele, source, participants = [], refusees = null, choisir = null } = $props();
|
||||
|
||||
|
|
@ -28,6 +36,7 @@
|
|||
|
||||
const refuseesMontrees = $derived(refusees ?? modele.refusees);
|
||||
const global = $derived(modele.global);
|
||||
const refusGlobal = messageDuBloc('global');
|
||||
</script>
|
||||
|
||||
<div class="apercu">
|
||||
|
|
@ -51,6 +60,7 @@
|
|||
|
||||
{#if global !== null}
|
||||
<div class="refus" role="status" data-refus={global.cle}>
|
||||
<Pastille sorte="statut" valeur={refusGlobal.sorte} icone={refusGlobal.icone} libelle={t(refusGlobal.cle)} />
|
||||
<p>{t(global.cle, global.details)}</p>
|
||||
{#if !global.remede.dansLaCause}
|
||||
<p>{t(global.remede.cle, global.remede.details)}</p>
|
||||
|
|
@ -125,39 +135,40 @@
|
|||
{/if}
|
||||
|
||||
{#if refuseesMontrees.length > 0}
|
||||
<table class="refusees" data-refusees>
|
||||
<caption>{t('import.apercu.refusees')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.ligne')}</th>
|
||||
<th scope="col">{t('import.apercu.motif')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each refuseesMontrees as { ligne, code, details } (ligne)}
|
||||
<tr data-ligne={ligne}>
|
||||
<th scope="row">{format.nombre(ligne)}</th>
|
||||
<td>
|
||||
<span data-motif>{motif(code, details)}</span>
|
||||
{#if details.participants !== undefined}
|
||||
<span>{t('import.rapport.designees')}</span>
|
||||
{#each nommer(participants, details.participants) as personne (personne.id)}
|
||||
<span class="personne" data-participant={personne.id}>
|
||||
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
|
||||
<span>{personne.nom}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
<Message bloc="refusees">
|
||||
<table class="refusees" data-refusees>
|
||||
<caption>{t('import.apercu.refusees')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('import.apercu.ligne')}</th>
|
||||
<th scope="col">{t('import.apercu.motif')}</th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each refuseesMontrees as { ligne, code, details } (ligne)}
|
||||
<tr data-ligne={ligne}>
|
||||
<th scope="row">{format.nombre(ligne)}</th>
|
||||
<td>
|
||||
<span data-motif>{motif(code, details)}</span>
|
||||
{#if details.participants !== undefined}
|
||||
<span>{t('import.rapport.designees')}</span>
|
||||
{#each nommer(participants, details.participants) as personne (personne.id)}
|
||||
<span class="personne" data-participant={personne.id}>
|
||||
{#if personne.prenom !== null}<span>{personne.prenom}</span>{/if}
|
||||
<span>{personne.nom}</span>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.fusions.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.fusions')}</h4>
|
||||
<Message bloc="fusions" titre={t('import.apercu.fusions')}>
|
||||
<ul data-fusions>
|
||||
{#each modele.fusions as fusion, rang (rang)}
|
||||
<li data-fusion>
|
||||
|
|
@ -175,12 +186,11 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.doublons.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.doublons')}</h4>
|
||||
<Message bloc="doublons" titre={t('import.apercu.doublons')}>
|
||||
<ul data-doublons>
|
||||
{#each modele.doublons as doublon, rang (rang)}
|
||||
<li data-doublon>
|
||||
|
|
@ -200,12 +210,11 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.homonymes.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.homonymes')}</h4>
|
||||
<Message bloc="homonymes" titre={t('import.apercu.homonymes')}>
|
||||
<ul>
|
||||
{#each modele.homonymes as homonyme (homonyme.ligne)}
|
||||
<li data-homonyme={homonyme.ligne}>
|
||||
|
|
@ -220,12 +229,11 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if modele.multilignes.length > 0}
|
||||
<section class="bloc">
|
||||
<h4>{t('import.apercu.multilignes')}</h4>
|
||||
<Message bloc="multilignes" titre={t('import.apercu.multilignes')}>
|
||||
<ul>
|
||||
{#each modele.multilignes as multiligne (multiligne.ligne)}
|
||||
<li data-multiligne={multiligne.ligne}>
|
||||
|
|
@ -234,7 +242,7 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Message>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -253,20 +261,31 @@
|
|||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Le refus global : la pastille du refus, puis la cause et le remède, de
|
||||
l'encre de la page, sur le fond du refus. */
|
||||
.refus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-refus);
|
||||
color: var(--couleur-refus);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-refus-filet);
|
||||
border-left: 3px solid var(--couleur-refus-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.refus p {
|
||||
|
|
@ -274,7 +293,8 @@
|
|||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
caption {
|
||||
|
|
@ -286,30 +306,47 @@
|
|||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-top: 1px solid var(--couleur-carte-filet);
|
||||
border-bottom-color: var(--couleur-page-filet);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.defile {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.defile thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ambigu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
border: 1px solid var(--couleur-avis-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
}
|
||||
|
||||
.bloc h4 {
|
||||
margin: 0 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.bloc ul {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { SUFFIXES } from '../../stockage/noms.js';
|
|||
import { VERSION } from '../../version.genere.js';
|
||||
import { pointer } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import { composantes, visible } from '../../../test/navigateur/rendu.js';
|
||||
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
|
||||
import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url';
|
||||
import urlGrande from '../../../exemples/participants_demo_grande.csv?url';
|
||||
|
|
@ -892,3 +892,91 @@ describe('les cas du § 10.3', () => {
|
|||
expect(parVariante[1].participants).toEqual(parVariante[0].participants);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’habillage : cartes et messages classés en refus, avis ou information', () => {
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby')));
|
||||
|
||||
// La classe d'un message, lue dans son bloc : la pastille dont le texte
|
||||
// nomme la sorte, sa teinte et son signe.
|
||||
function exigerSorte(bloc, sorte, lieu) {
|
||||
expect(bloc, lieu).not.toBeNull();
|
||||
const libelle = t(`import.sorte.${sorte}`);
|
||||
const pastille = [...bloc.querySelectorAll('*')].find((e) => fondDe(e)[3] > 0 && texteDe(e) === libelle);
|
||||
expect(pastille, `${lieu} : pastille « ${libelle} »`).toBeDefined();
|
||||
expect(fondDe(pastille), lieu).toEqual(jeton(`--couleur-${sorte}-fond`));
|
||||
expect(pastille.querySelector('svg'), lieu).not.toBeNull();
|
||||
expect(composantes(getComputedStyle(pastille).color), lieu).toEqual(jeton('--couleur-page-texte'));
|
||||
}
|
||||
|
||||
// Chaque bloc de messages d'un conteneur, et la sorte qu'il doit porter.
|
||||
function exigerBlocs(conteneur, lieu) {
|
||||
const bloc = (selecteur) => conteneur.querySelector(selecteur)?.closest('section') ?? null;
|
||||
exigerSorte(bloc('[data-refusees]'), 'refus', `${lieu}, lignes refusées`);
|
||||
exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`);
|
||||
exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`);
|
||||
}
|
||||
|
||||
test('les cas limites : refusées en refus, doublons en avis, fusions en information, dans l’aperçu puis le rapport', async () => {
|
||||
const e = await ouvrir();
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
exigerBlocs(dans(e, '[data-apercu]'), 'aperçu');
|
||||
await importer(e);
|
||||
exigerBlocs(dans(e, '[data-rapport]'), 'rapport');
|
||||
});
|
||||
|
||||
test('une mise à jour : homonymes et cellule sur deux lignes qui porte le séparateur en avis, dans l’aperçu puis le rapport', async () => {
|
||||
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris')] });
|
||||
await choisir(e, 'homonymes.csv', enUtf8('nom;prenom;notes\r\nOmbrelle;Aurèle;\r\nPervenche;Théo;"végétarien;\r\nsans noix"\r\n'));
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
|
||||
for (const lieu of ['aperçu', 'rapport']) {
|
||||
const conteneur = dans(e, lieu === 'aperçu' ? '[data-apercu]' : '[data-rapport]');
|
||||
exigerSorte(conteneur.querySelector('[data-homonyme="2"]')?.closest('section') ?? null, 'avis', `${lieu}, homonymes`);
|
||||
exigerSorte(conteneur.querySelector('[data-multiligne="3"]')?.closest('section') ?? null, 'avis', `${lieu}, cellules sur plusieurs lignes`);
|
||||
if (lieu === 'aperçu') await importer(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Un import n'écrit rien quand la charge ne change pas et qu'il refuse
|
||||
// des lignes : ici, une personne déjà là, et une ligne sans nom.
|
||||
test('un import qui n’a rien écrit le dit en information', async () => {
|
||||
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris')] });
|
||||
await choisir(e, 'inchange.csv', enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n'));
|
||||
expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour');
|
||||
const { rendu } = await importer(e);
|
||||
expect(rendu.libelle).toBeNull();
|
||||
const rapport = dans(e, '[data-rapport]');
|
||||
const rien = [...rapport.querySelectorAll('section')].find((bloc) => texteDe(bloc).includes(t('import.rapport.rienEcrit'))) ?? null;
|
||||
exigerSorte(rien, 'information', 'rapport, rien d’écrit');
|
||||
});
|
||||
|
||||
test('un refus global porte la pastille du refus, sa cause et son remède', async () => {
|
||||
const e = await ouvrir();
|
||||
await coller(e, 'Ombrelle\tIris\nPervenche\tThéo');
|
||||
const pose = dans(e, '[data-apercu] [data-refus="AUCUN_ENTETE"]');
|
||||
exigerSorte(pose, 'refus', 'refus global');
|
||||
expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage'));
|
||||
});
|
||||
|
||||
test('la section, l’aperçu et le rapport sont des cartes titrées ; « Choisir un fichier » est l’action principale de la section', async () => {
|
||||
const e = await ouvrir();
|
||||
const regions = () => tous(e, 'section[aria-labelledby]').map(nomDe);
|
||||
expect(regions()).toEqual([t('coquille.section.import')]);
|
||||
const choisirUn = commande(e, 'choisirFichier');
|
||||
expect(fondDe(choisirUn)).toEqual(jeton('--couleur-action-fond'));
|
||||
await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites));
|
||||
expect(regions().slice(0, 2)).toEqual([t('coquille.section.import'), t('import.apercu.titre')]);
|
||||
await importer(e);
|
||||
expect(regions()).toContain(t('import.rapport.titre'));
|
||||
expect(regions()).not.toContain(t('import.apercu.titre'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
src/interface/import/Message.svelte
Normal file
76
src/interface/import/Message.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Un bloc de messages de l'aperçu ou du rapport d'import (§ 10.1), classé
|
||||
// en refus, avis ou information par messageDuBloc (modele.js) : une
|
||||
// pastille de la sorte — son libellé, son icône, le fond de sa teinte —,
|
||||
// puis le titre du bloc, puis son contenu. Le cadre porte le filet de la
|
||||
// sorte et un trait de gauche de sa marque ; le texte garde l'encre de la
|
||||
// page. La sorte se lit au libellé de la pastille : la couleur la redit.
|
||||
//
|
||||
// bloc : le nom du bloc, que messageDuBloc classe ; titre : le titre déjà
|
||||
// traduit, ou null quand le contenu se nomme lui-même (la légende d'un
|
||||
// tableau) ; children : le contenu.
|
||||
import { getContext } from 'svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { messageDuBloc } from './modele.js';
|
||||
|
||||
let { bloc, titre = null, children = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const message = $derived(messageDuBloc(bloc));
|
||||
</script>
|
||||
|
||||
<section class="message {message.sorte}">
|
||||
<div class="tete">
|
||||
<Pastille sorte="statut" valeur={message.sorte} icone={message.icone} libelle={t(message.cle)} />
|
||||
{#if titre !== null}
|
||||
<h4>{titre}</h4>
|
||||
{/if}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
padding: var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
/* Le cadre de chaque sorte : son filet, le trait de gauche de sa marque. */
|
||||
.refus {
|
||||
border-color: var(--couleur-refus-filet);
|
||||
border-left-color: var(--couleur-refus-marque);
|
||||
}
|
||||
|
||||
.avis {
|
||||
border-color: var(--couleur-avis-filet);
|
||||
border-left-color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.information {
|
||||
border-color: var(--couleur-information-filet);
|
||||
border-left-color: var(--couleur-information-marque);
|
||||
}
|
||||
|
||||
.tete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,13 +9,20 @@
|
|||
// mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un
|
||||
// import qui n'a rien écrit le dit.
|
||||
//
|
||||
// Le rapport est une carte titrée ; ses comptes, des chiffres tabulaires
|
||||
// en tête de chaque libellé ; un import qui n'a rien écrit le dit en
|
||||
// information (Message). Ses commandes, celles de la section, viennent
|
||||
// en pied de carte (commandes, un snippet).
|
||||
//
|
||||
// rapport : { modele, source, resume, libelle } ; participants : ceux de
|
||||
// la charge écrite. Les commandes du rapport sont celles de la section.
|
||||
// la charge écrite.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import { refuseesDe } from './modele.js';
|
||||
|
||||
let { rapport, participants } = $props();
|
||||
let { rapport, participants, commandes = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
|
@ -32,42 +39,71 @@
|
|||
const refusees = $derived(refuseesDe(rapport.resume.refusees));
|
||||
</script>
|
||||
|
||||
<section class="rapport" data-rapport>
|
||||
<h3>{t('import.rapport.titre')}</h3>
|
||||
{#if rapport.libelle === null}
|
||||
<p>{t('import.rapport.rienEcrit')}</p>
|
||||
{/if}
|
||||
<ul class="comptes">
|
||||
{#each COMPTES as [nom, cle] (nom)}
|
||||
<li data-compte={nom}>
|
||||
<span data-nombre>{format.nombre(rapport.resume[nom])}</span>
|
||||
<span>{t(cle, { n: rapport.resume[nom] })}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<Apercu modele={rapport.modele} source={rapport.source} {participants} {refusees} />
|
||||
</section>
|
||||
<div class="rapport" data-rapport>
|
||||
<Carte titre={t('import.rapport.titre')} niveau={3}>
|
||||
<div class="contenu">
|
||||
{#if rapport.libelle === null}
|
||||
<Message bloc="rienEcrit"><p>{t('import.rapport.rienEcrit')}</p></Message>
|
||||
{/if}
|
||||
<ul class="comptes">
|
||||
{#each COMPTES as [nom, cle] (nom)}
|
||||
<li data-compte={nom}>
|
||||
<span class="nombre" data-nombre>{format.nombre(rapport.resume[nom])}</span>
|
||||
<span>{t(cle, { n: rapport.resume[nom] })}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<Apercu modele={rapport.modele} source={rapport.source} {participants} {refusees} />
|
||||
{#if commandes !== null}
|
||||
<div class="pied">{@render commandes()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rapport {
|
||||
.contenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
h3 {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Les comptes : une rangée de cases, le nombre en gras et en chiffres
|
||||
tabulaires, son libellé à côté. */
|
||||
.comptes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.comptes li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.nombre {
|
||||
font-size: var(--taille-titre-carte);
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-carte-filet);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
// et ne l'ouvre que sur un import que la commande accepterait.
|
||||
// Le rapport lit le résumé que la séance rend.
|
||||
//
|
||||
// La section est une carte titrée, dont l'en-tête porte la consigne et
|
||||
// « Choisir un fichier CSV… », son action principale ; l'aperçu, puis le
|
||||
// rapport, sont des cartes sous elle.
|
||||
//
|
||||
// Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient
|
||||
// d'exporterRefus de src/application/csv.js, dont le motif est celui de
|
||||
// libelle ; la façade l'enregistre sous ‹base›-refus.csv. La détection
|
||||
|
|
@ -23,6 +27,7 @@
|
|||
import { getContext, untrack } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Apercu from './Apercu.svelte';
|
||||
|
|
@ -184,69 +189,75 @@
|
|||
|
||||
<svelte:window ondragover={survoler} ondrop={deposer} />
|
||||
|
||||
<section class="import" data-section="import">
|
||||
<h2>{t('coquille.section.import')}</h2>
|
||||
<p>{t('import.consigne')}</p>
|
||||
<div class="commandes">
|
||||
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" executer={choisirFichier} />
|
||||
</div>
|
||||
<div class="import" data-section="import">
|
||||
<Carte titre={t('coquille.section.import')} sousTitre={t('import.consigne')}>
|
||||
{#snippet actions()}
|
||||
<Commande cle="import.commande.choisirFichier" nom="choisirFichier" variante="principale" executer={choisirFichier} />
|
||||
{/snippet}
|
||||
</Carte>
|
||||
|
||||
{#if source !== null}
|
||||
{#key source}
|
||||
<section class="apercu" data-apercu>
|
||||
<h3>{t('import.apercu.titre')}</h3>
|
||||
{#if modele.global === null}
|
||||
<fieldset class="modes" data-champ="mode">
|
||||
<legend>{t('import.mode.titre')}</legend>
|
||||
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
|
||||
<label>
|
||||
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
|
||||
<span>{t(cle)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/if}
|
||||
<Apercu {modele} source={{ origine: source.origine, nom: source.nom }} {participants} choisir={choisirColonne} />
|
||||
<div class="commandes">
|
||||
{#if grille !== null}
|
||||
<Commande cle="import.commande.ordreDeLaGrille" nom="ordreDeLaGrille" executer={lireDansLOrdreDeLaGrille} />
|
||||
{/if}
|
||||
{#if modele.global === null}
|
||||
{#key apercu}
|
||||
<Commande cle="import.commande.importer" nom="importer" executer={importer} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if modele.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(apercu))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerApercu" nom="fermerApercu" executer={fermerApercu} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="apercu" data-apercu>
|
||||
<Carte titre={t('import.apercu.titre')} niveau={3}>
|
||||
<div class="contenu">
|
||||
{#if modele.global === null}
|
||||
<fieldset class="modes" data-champ="mode">
|
||||
<legend>{t('import.mode.titre')}</legend>
|
||||
{#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
|
||||
<label>
|
||||
<input type="radio" name={`${uid}-mode`} value={valeur} bind:group={mode} />
|
||||
<span>{t(cle)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/if}
|
||||
<Apercu {modele} source={{ origine: source.origine, nom: source.nom }} {participants} choisir={choisirColonne} />
|
||||
<div class="commandes">
|
||||
{#if grille !== null}
|
||||
<Commande cle="import.commande.ordreDeLaGrille" nom="ordreDeLaGrille" executer={lireDansLOrdreDeLaGrille} />
|
||||
{/if}
|
||||
{#if modele.global === null}
|
||||
{#key apercu}
|
||||
<Commande cle="import.commande.importer" nom="importer" variante="principale" executer={importer} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if modele.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(apercu))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerApercu" nom="fermerApercu" executer={fermerApercu} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if rapport !== null}
|
||||
<Rapport {rapport} {participants} />
|
||||
<div class="commandes">
|
||||
{#if rapport.resume.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerRapport" nom="fermerRapport" executer={() => (rapport = null)} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
<Rapport {rapport} {participants}>
|
||||
{#snippet commandes()}
|
||||
<div class="commandes">
|
||||
{#if rapport.resume.refusees.length > 0}
|
||||
<Commande
|
||||
cle="import.commande.enregistrerRefus"
|
||||
nom="enregistrerRefus"
|
||||
executer={() => enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
|
||||
/>
|
||||
{/if}
|
||||
<Commande cle="import.commande.fermerRapport" nom="fermerRapport" executer={() => (rapport = null)} />
|
||||
</div>
|
||||
{#if enregistre !== null}
|
||||
<p><span>{t('import.refusEnregistres')}</span> <span data-enregistre>{enregistre}</span></p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Rapport>
|
||||
{/if}
|
||||
|
||||
{#if confirmation !== null}
|
||||
|
|
@ -257,27 +268,23 @@
|
|||
surAnnuler={() => (confirmation = null)}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.apercu {
|
||||
.contenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
|
|
@ -291,7 +298,15 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
margin: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 var(--espace-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,49 @@ export const MODES_D_IMPORT = Object.freeze(
|
|||
].map(([mode, cle]) => Object.freeze({ mode, cle })),
|
||||
);
|
||||
|
||||
/** Les trois sortes d'un message de l'aperçu et du rapport, chacune un ton
|
||||
* de pastille (Pastille, sorte 'statut'). */
|
||||
export const SORTES_DE_MESSAGE = Object.freeze(['refus', 'avis', 'information']);
|
||||
|
||||
// L'icône de chaque sorte, distincte des deux autres : la sorte se lit au
|
||||
// libellé de sa pastille, et l'icône la redit sans la couleur.
|
||||
const ICONES_DE_SORTE = new Map([
|
||||
['refus', 'interdit'],
|
||||
['avis', 'triangle'],
|
||||
['information', 'information'],
|
||||
]);
|
||||
|
||||
// La sorte de chaque bloc de messages. Un refus écarte des lignes, ou tout
|
||||
// l'import ; un avis demande à l'opérateur de vérifier ou de trancher ce
|
||||
// que le logiciel ne fusionne jamais seul — doublons, homonymes d'une mise
|
||||
// à jour —, ou une cellule sur plusieurs lignes qui porte le séparateur,
|
||||
// peut-être un guillemet mal fermé ; une information dit ce qui a été fait
|
||||
// ou lu sans rien écarter.
|
||||
const SORTES_DES_BLOCS = new Map([
|
||||
['global', 'refus'],
|
||||
['refusees', 'refus'],
|
||||
['doublons', 'avis'],
|
||||
['homonymes', 'avis'],
|
||||
['multilignes', 'avis'],
|
||||
['fusions', 'information'],
|
||||
['rienEcrit', 'information'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La classe d'un bloc de messages de l'aperçu ou du rapport.
|
||||
*
|
||||
* @param {string} bloc 'global', 'refusees', 'doublons', 'homonymes',
|
||||
* 'fusions', 'multilignes' ou 'rienEcrit'
|
||||
* @returns {{sorte: string, cle: string, icone: string}} la sorte, la clé
|
||||
* de son libellé et l'icône de sa pastille
|
||||
* @throws {RangeError} un bloc inconnu
|
||||
*/
|
||||
export function messageDuBloc(bloc) {
|
||||
const sorte = SORTES_DES_BLOCS.get(bloc);
|
||||
if (sorte === undefined) throw new RangeError(`bloc de messages inconnu : ${JSON.stringify(bloc)}`);
|
||||
return { sorte, cle: `import.sorte.${sorte}`, icone: ICONES_DE_SORTE.get(sorte) };
|
||||
}
|
||||
|
||||
/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de
|
||||
* CHAMPS, et la clé de son nom. */
|
||||
export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) })));
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import {
|
|||
ENCODAGES,
|
||||
MODES_D_IMPORT,
|
||||
SEPARATEURS,
|
||||
SORTES_DE_MESSAGE,
|
||||
avecEntetesDeLaGrille,
|
||||
messageDuBloc,
|
||||
modeleApercu,
|
||||
modePropose,
|
||||
motifLigne,
|
||||
|
|
@ -406,3 +408,37 @@ describe('les titres que « Remplacer » annonce (§ 10.1, § 4.1)', () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en refus, avis ou information', () => {
|
||||
test('les refus : le refus global et les lignes refusées', () => {
|
||||
for (const bloc of ['global', 'refusees']) {
|
||||
assert.deepEqual(messageDuBloc(bloc), { sorte: 'refus', cle: 'import.sorte.refus', icone: 'interdit' }, bloc);
|
||||
}
|
||||
});
|
||||
|
||||
test('les avis : les doublons et les homonymes, que rien ne fusionne seul, et les cellules sur plusieurs lignes qui portent le séparateur, à vérifier', () => {
|
||||
for (const bloc of ['doublons', 'homonymes', 'multilignes']) {
|
||||
assert.deepEqual(messageDuBloc(bloc), { sorte: 'avis', cle: 'import.sorte.avis', icone: 'triangle' }, bloc);
|
||||
}
|
||||
});
|
||||
|
||||
test('les informations : les orthographes fondues, un import qui n’a rien écrit', () => {
|
||||
for (const bloc of ['fusions', 'rienEcrit']) {
|
||||
assert.deepEqual(messageDuBloc(bloc), { sorte: 'information', cle: 'import.sorte.information', icone: 'information' }, bloc);
|
||||
}
|
||||
});
|
||||
|
||||
test('un bloc inconnu lève RangeError, en le nommant', () => {
|
||||
assert.throws(() => messageDuBloc('fusion'), { name: 'RangeError', message: /"fusion"/ });
|
||||
});
|
||||
|
||||
test('trois sortes, chacune un ton de pastille, un libellé traduit et une icône distincte', () => {
|
||||
assert.deepEqual(SORTES_DE_MESSAGE, ['refus', 'avis', 'information']);
|
||||
const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'multilignes', 'rienEcrit'].map(messageDuBloc);
|
||||
assert.equal(new Set(blocs.map(({ icone }) => icone)).size, 3);
|
||||
assert.deepEqual(
|
||||
SORTES_DE_MESSAGE.map((sorte) => t(`import.sorte.${sorte}`)),
|
||||
['Refus', 'Avis', 'Information'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ export const TABLE = new Map([
|
|||
['import.commande.fermerRapport', 'Fermer le rapport'],
|
||||
['import.refusEnregistres', 'Lignes refusées enregistrées :'],
|
||||
|
||||
// --- La sorte d'un message de l'aperçu et du rapport (messageDuBloc) ------
|
||||
['import.sorte.refus', 'Refus'],
|
||||
['import.sorte.avis', 'Avis'],
|
||||
['import.sorte.information', 'Information'],
|
||||
|
||||
// --- Le mode de l'import (§ 10.1) -------------------------------------------
|
||||
['import.mode.titre', "Mode de l'import"],
|
||||
['import.mode.ajouter', 'Ajouter'],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés de la liste des participants : grille, formulaire, fiche (§
|
||||
// 14.6). Chaque clé commence par « participants. ».
|
||||
// Les libellés de la liste des participants : en-tête, grille, formulaire,
|
||||
// fiche (§ 14.6). Chaque clé commence par « participants. ».
|
||||
//
|
||||
// participants.entete.comptes compose la ligne de comptes de la carte
|
||||
// (comptesEntete, entete.js), ses parties séparées d'un point médian ;
|
||||
// participants.legende.* nomme la légende des appartenances et chacune de
|
||||
// ses pastilles, le nom puis le compte.
|
||||
//
|
||||
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
|
||||
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
|
||||
|
|
@ -51,6 +56,20 @@ export const TABLE = new Map([
|
|||
['participants.colonne.exclu', 'Exclusion'],
|
||||
['participants.colonne.notes', 'Notes'],
|
||||
|
||||
// --- L'en-tête de la carte : la ligne de comptes et la légende -----------
|
||||
[
|
||||
'participants.entete.comptes',
|
||||
({ personnes, aPlacer, exclues, appartenances }) =>
|
||||
[
|
||||
compte(personnes, 'personne', 'personnes'),
|
||||
`${aPlacer} à placer`,
|
||||
compte(exclues, 'exclue', 'exclues'),
|
||||
compte(appartenances, 'appartenance', 'appartenances'),
|
||||
].join(' \u{00B7} '),
|
||||
],
|
||||
['participants.legende.titre', 'Appartenances'],
|
||||
['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`],
|
||||
|
||||
// --- Le formulaire d'ajout (§ 10.2) ---------------------------------------
|
||||
['participants.formulaire.titre', 'Ajouter une personne'],
|
||||
['participants.formulaire.ajouter', 'Ajouter'],
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ export const TABLE = new Map([
|
|||
|
||||
// --- La liste des tables (§ 4, § 6.1, § 6.2) ----------------------------------
|
||||
['tables.liste.titre', 'Tables'],
|
||||
// L'en-tête de la liste : le nombre de tables et le total de leurs sièges
|
||||
// (comptesTables), séparés d'un point médian.
|
||||
[
|
||||
'tables.liste.comptes',
|
||||
({ tables, sieges }) => `${compte(tables, 'table', 'tables')} \u{00B7} ${compte(sieges, 'siège', 'sièges')}`,
|
||||
],
|
||||
['tables.liste.aucune', 'Aucune table : ajoutez-en une, ronde ou carrée.'],
|
||||
['tables.liste.table', 'Table'],
|
||||
['tables.liste.places', 'Places'],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
// tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni
|
||||
// l'autre, et ne porte aucun contrôle de saisie.
|
||||
//
|
||||
// La fiche est une carte : fond et filet de la carte, rayon de 12 px,
|
||||
// ombre légère ; ses étiquettes et légendes de tableau en petites
|
||||
// capitales d'encre secondaire.
|
||||
//
|
||||
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
|
||||
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
|
||||
// grille.
|
||||
|
|
@ -136,10 +140,11 @@
|
|||
box-sizing: border-box;
|
||||
width: 24rem;
|
||||
max-width: 100%;
|
||||
padding: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-page-fond);
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-3);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
box-shadow: var(--ombre-carte);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
line-height: 1.4;
|
||||
|
|
@ -156,24 +161,32 @@
|
|||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--espace-2);
|
||||
padding-bottom: var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-carte-filet);
|
||||
}
|
||||
|
||||
h3 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
font-size: var(--taille-titre-carte);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
margin: var(--espace-3) 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
dt,
|
||||
caption {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
|
|
@ -184,37 +197,47 @@
|
|||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: var(--espace-2) 0;
|
||||
margin: var(--espace-3) 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
caption {
|
||||
font-weight: bold;
|
||||
padding-bottom: var(--espace-1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1);
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom-color: var(--couleur-carte-filet);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--espace-2) 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@
|
|||
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
|
||||
// rien ne fusionne seul.
|
||||
//
|
||||
// Le signal et chaque refus, apparus, se font défiler jusque dans la
|
||||
// fenêtre (montrer) : sous la bande de tête d'une carte, dans une fenêtre
|
||||
// étroite, ils tomberaient sinon sous le bord, loin du champ ou de la
|
||||
// commande qu'ils commentent.
|
||||
//
|
||||
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
|
||||
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
|
||||
//
|
||||
|
|
@ -27,12 +32,18 @@
|
|||
// que l'opérateur tape pendant l'écriture, la personne suivante, reste.
|
||||
// Le focus revient au nom quand rien n'a été tapé entre-temps.
|
||||
//
|
||||
// Le formulaire est la bande de tête de la carte des participants : fond
|
||||
// sourd, champs en grille, « Ajouter » en action principale dans la
|
||||
// dernière case de la grille, au pied des champs ; sur une fenêtre large,
|
||||
// la bande tient en une rangée.
|
||||
//
|
||||
// Les notes sont une zone de texte, qui garde les sauts de ligne :
|
||||
// Maj+Entrée y insère un saut de ligne, Entrée seule envoie
|
||||
// (entreeValide, grille.js).
|
||||
import { getContext, onDestroy, tick, untrack } from 'svelte';
|
||||
import { cleNormalisee } from '../../application/csv.js';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { effetDe } from '../raccourcis.js';
|
||||
import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
|
||||
|
|
@ -119,6 +130,11 @@
|
|||
formulaire.requestSubmit();
|
||||
}
|
||||
|
||||
// Fait défiler l'élément monté jusque dans la fenêtre, au plus court.
|
||||
function montrer(element) {
|
||||
element.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function reprendre() {
|
||||
valeurs.appartenance = orthographe;
|
||||
}
|
||||
|
|
@ -130,7 +146,7 @@
|
|||
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
|
||||
<div class="champs">
|
||||
{#each CHAMPS as cle (cle)}
|
||||
<div class="champ">
|
||||
<div class="champ" class:large={cle === 'notes'}>
|
||||
<label for={`${uid}-${cle}`}>{t(cleDeColonne(cle))}</label>
|
||||
{#if cle === 'nom'}
|
||||
<input
|
||||
|
|
@ -144,7 +160,9 @@
|
|||
oninput={surSaisieNom}
|
||||
/>
|
||||
{#if refusNom !== null}
|
||||
<Refus id={idRefusNom} refus={refusNom} lever={() => (refusNom = null)} />
|
||||
<div class="refus" {@attach montrer}>
|
||||
<Refus id={idRefusNom} refus={refusNom} lever={() => (refusNom = null)} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if cle === 'appartenance'}
|
||||
<input
|
||||
|
|
@ -155,7 +173,8 @@
|
|||
aria-describedby={orthographe === null ? undefined : idOrthographe}
|
||||
/>
|
||||
{#if orthographe !== null}
|
||||
<div class="signal" id={idOrthographe} role="status" data-avertissement="orthographe">
|
||||
<div class="signal" id={idOrthographe} role="status" data-avertissement="orthographe" {@attach montrer}>
|
||||
<span class="signe"><Icone nom="triangle" /></span>
|
||||
<p><span>{t('participants.formulaire.orthographe')}</span> <span class="donnee">{orthographe}</span></p>
|
||||
<button type="button" data-commande="reprendreOrthographe" onclick={reprendre}>
|
||||
{t('participants.formulaire.reprendre')}
|
||||
|
|
@ -169,36 +188,49 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="issue">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="ajouter"
|
||||
aria-busy={envoiEnCours ? 'true' : undefined}
|
||||
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
|
||||
>
|
||||
{#if refusAjout !== null}
|
||||
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
|
||||
{/if}
|
||||
<div class="issue">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="ajouter"
|
||||
aria-busy={envoiEnCours ? 'true' : undefined}
|
||||
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
|
||||
>
|
||||
{#if refusAjout !== null}
|
||||
<div class="refus" {@attach montrer}>
|
||||
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
/* La bande est un conteneur de requête : les notes ne prennent deux
|
||||
colonnes que là où la grille en a au moins deux. */
|
||||
form {
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
border-bottom: 1px solid var(--couleur-carte-filet);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
/* L'intitulé de la bande, en petites capitales. */
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.champs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
align-items: start;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
|
|
@ -206,18 +238,43 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
max-width: 18rem;
|
||||
min-width: 0;
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Les notes prennent deux colonnes quand la grille en a la place ; sur
|
||||
une colonne, un empan de deux en créerait une seconde, implicite, qui
|
||||
élargirait la grille hors de la bande. */
|
||||
@container (min-width: 22rem) {
|
||||
.champ.large {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.refus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* La zone des notes a la hauteur d'un champ, et s'agrandit à la main. */
|
||||
textarea {
|
||||
height: 2.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[aria-invalid='true'] {
|
||||
|
|
@ -231,14 +288,26 @@
|
|||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* L'orthographe existante : un avis, son signe, un texte de l'encre de
|
||||
la page. */
|
||||
.signal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
font-size: var(--taille-texte);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
padding: var(--espace-2);
|
||||
border: 1px solid var(--couleur-avis-filet);
|
||||
border-left: 3px solid var(--couleur-avis-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.signal .signe {
|
||||
display: inline-flex;
|
||||
color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.signal p {
|
||||
|
|
@ -246,6 +315,11 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signal button {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -254,23 +328,43 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
align-self: end;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
font-size: var(--taille-texte-petit);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
/* « Ajouter », l'action principale de la carte : pleine de la teinte de
|
||||
l'action, haute d'une cible principale. */
|
||||
button[type='submit'] {
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: var(--espace-1) var(--espace-4);
|
||||
border-color: var(--couleur-action-fond);
|
||||
background-color: var(--couleur-action-fond);
|
||||
color: var(--couleur-action-texte);
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Occupé, il garde ses couleurs, d'un filet tireté de l'encre de son
|
||||
texte. */
|
||||
button[type='submit'][aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
border-color: var(--couleur-action-texte);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@
|
|||
// la cellule d'exclusion suit sa ligne de même : la personne exclue ou
|
||||
// réintégrée passe dans son nouveau groupe, et sa cellule garde le focus.
|
||||
//
|
||||
// L'appartenance s'affiche en pastille de la teinte que teintes lui donne
|
||||
// (teintesAppartenances, calculée par la section sur la liste entière),
|
||||
// neutre au-delà de la palette ; le titre pressenti en pastille
|
||||
// d'information à étoile ; « exclue » en pastille neutre à croix,
|
||||
// « présente » en texte secondaire. Chaque pastille porte la valeur ou le
|
||||
// libellé en texte, et garde les blancs de la donnée comme la cellule.
|
||||
//
|
||||
// Les conflits du placement montré (conflits, clé d'un groupe → ses
|
||||
// lignes de conflit ; enConflit, identifiant → ses règles), que la section
|
||||
// ne donne que groupée : l'en-tête d'un groupe en conflit, de tout niveau,
|
||||
|
|
@ -59,6 +66,7 @@
|
|||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
import { creerMachineChamp } from '../primitives/champ.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { effetDe } from '../raccourcis.js';
|
||||
|
|
@ -68,6 +76,7 @@
|
|||
let {
|
||||
lignes,
|
||||
groupes = null,
|
||||
teintes = new Map(),
|
||||
replies = $bindable(new Set()),
|
||||
tri = $bindable(null),
|
||||
supprimer,
|
||||
|
|
@ -399,8 +408,18 @@
|
|||
onblur={surSortie}
|
||||
/>
|
||||
{:else if colonne === 'exclu'}
|
||||
<span class:exclue={personne.exclu}>
|
||||
{t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')}
|
||||
{#if personne.exclu}
|
||||
<Pastille sorte="statut" valeur="neutre" icone="croix" libelle={t('participants.grille.exclue')} />
|
||||
{:else}
|
||||
<span class="presente">{t('participants.grille.presente')}</span>
|
||||
{/if}
|
||||
{:else if colonne === 'appartenance' && personne.appartenance !== null}
|
||||
<span class="donnee-pastille">
|
||||
<Pastille sorte="appartenance" valeur={teintes.get(personne.appartenance) ?? null} libelle={personne.appartenance} />
|
||||
</span>
|
||||
{:else if colonne === 'titrePressenti' && personne.titrePressenti !== null}
|
||||
<span class="donnee-pastille">
|
||||
<Pastille sorte="statut" valeur="information" icone="etoile" libelle={personne.titrePressenti} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="valeur">{personne[colonne] ?? ''}</span>
|
||||
|
|
@ -538,17 +557,44 @@
|
|||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Le tableau d'une carte : en-tête sur le fond sourd, lignes séparées
|
||||
d'un filet léger, sans filet vertical. */
|
||||
/* Au-dessous de 59rem, le tableau défile dans son conteneur plutôt que de
|
||||
replier ses cellules caractère par caractère sous des actions d'une
|
||||
rangée. Le plancher tient dans la carte pleine largeur d'une fenêtre de
|
||||
1024 px ; la liste qui la porte (SectionParticipants) réclame un peu
|
||||
plus, pour que la fiche passe dessous plutôt que de le rogner. */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
min-width: 59rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--espace-5);
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-top: 1px solid var(--couleur-carte-filet);
|
||||
border-bottom-color: var(--couleur-page-filet);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
|
|
@ -556,13 +602,32 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
td[data-champ='nom'] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td[data-champ='notes'] {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.valeur {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* L'exclusion se lit en texte ; l'italique la double, sans couleur. */
|
||||
.exclue {
|
||||
font-style: italic;
|
||||
/* La pastille d'une donnée garde ses blancs, comme la cellule de texte :
|
||||
deux orthographes qui ne diffèrent que par eux restent distinctes. */
|
||||
.donnee-pastille {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.donnee-pastille :global(.libelle) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.presente {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
th button {
|
||||
|
|
@ -581,8 +646,12 @@
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* L'en-tête d'un groupe : un bouton qui porte son état en texte, le
|
||||
libellé du groupe en gras et son compte. */
|
||||
/* L'en-tête d'un groupe : une ligne sur le fond sourd, un bouton qui porte
|
||||
son état en texte, le libellé du groupe en gras et son compte. */
|
||||
.groupe th {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.groupe button {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -590,8 +659,8 @@
|
|||
gap: var(--espace-2);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
|
@ -633,6 +702,10 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compte {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* La marque d'un conflit : un cadre plein, de l'encre du texte, autour
|
||||
d'un texte qui nomme le conflit ; la forme ne dépend d'aucune couleur. */
|
||||
.marque-conflit {
|
||||
|
|
@ -640,7 +713,7 @@
|
|||
padding: 0 var(--espace-1);
|
||||
border: 2px solid var(--couleur-page-texte);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -650,6 +723,13 @@
|
|||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
/* Les actions d'une ligne vont sur une rangée, qui passe à la ligne
|
||||
quand la colonne est étroite ; chaque action garde son libellé sur une
|
||||
ligne : un mot ne se coupe jamais entre deux lettres. */
|
||||
td:last-child {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -657,24 +737,33 @@
|
|||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.commandes > :global(button) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Les actions de ligne : des boutons secondaires, comme Commande. */
|
||||
.commandes > button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commandes > button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--espace-1);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ import { SUFFIXES } from '../../stockage/noms.js';
|
|||
import { formeNommee } from '../../stockage/placements.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { composantes, sousTheme, visible } from '../../../test/navigateur/rendu.js';
|
||||
import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url';
|
||||
import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { comptesEntete } from './entete.js';
|
||||
import { COLONNES, GROUPEMENTS, cheminDe, cleDeGroupement, cleDeStatut, groupesGrille, lignesGrille, lignesVisibles } from './grille.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
|
@ -1704,3 +1706,223 @@ describe('la liste groupée par une colonne ou plusieurs', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'habillage : carte, bandes, légende et pastilles ----------------------
|
||||
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
|
||||
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
const opaque = (element) => fondDe(element)[3] > 0;
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// La pastille d'un conteneur : le premier élément qu'il contient dont le
|
||||
// fond est peint, ou null.
|
||||
const pastilleDans = (conteneur) => [...conteneur.querySelectorAll('*')].find(opaque) ?? null;
|
||||
|
||||
// La région titrée de la section : la carte des participants.
|
||||
const carte = (m) => section(m).querySelector('section[aria-labelledby]');
|
||||
const legende = (m) => section(m).querySelector(`[aria-label="${t('participants.legende.titre')}"]`);
|
||||
|
||||
// Sept appartenances — une de plus que la palette —, une exclue, une
|
||||
// personne sans appartenance et une au titre pressenti.
|
||||
const SEPT = [
|
||||
{ nom: 'Quenouillet', appartenance: 'Atelier Pivoine' },
|
||||
{ nom: 'Brindelle', appartenance: 'Banc des Loutres', exclu: true },
|
||||
{ nom: 'Calvaille', appartenance: 'Cercle des Grives', titre: 'hôtesse' },
|
||||
{ nom: 'Doucemer', appartenance: 'Cercle des Grives' },
|
||||
{ nom: 'Esquirol', appartenance: 'Dune des Hérons' },
|
||||
{ nom: 'Faverolle', appartenance: 'Étang des Sarcelles' },
|
||||
{ nom: 'Glanduc', appartenance: 'Forge des Martres' },
|
||||
{ nom: 'Houblonnet', appartenance: 'Grange des Mulots' },
|
||||
{ nom: 'Isardin' },
|
||||
];
|
||||
|
||||
describe('l’habillage de la liste : carte, bandes, légende, pastilles', () => {
|
||||
test('la carte « Participants » : sa ligne de comptes suit la liste, le formulaire d’ajout en est la bande de tête, sur le fond sourd, avant la grille', async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT });
|
||||
const region = carte(m);
|
||||
expect(region).not.toBeNull();
|
||||
expect(texteDe(document.getElementById(region.getAttribute('aria-labelledby')))).toBe(t('coquille.section.participants'));
|
||||
const ligneDeComptes = () => [...region.querySelectorAll('p')].map((p) => p.textContent);
|
||||
expect(comptesEntete(participants(m))).toEqual({ personnes: 9, aPlacer: 8, exclues: 1, appartenances: 7 });
|
||||
expect(ligneDeComptes()).toContain(t('participants.entete.comptes', comptesEntete(participants(m))));
|
||||
|
||||
await m.seance.executer('exclureParticipant', { id: idDe(m, 'Isardin') });
|
||||
await attendreSeance(m);
|
||||
expect(ligneDeComptes()).toContain(t('participants.entete.comptes', { personnes: 9, aPlacer: 7, exclues: 2, appartenances: 7 }));
|
||||
|
||||
expect(region.contains(formulaire(m))).toBe(true);
|
||||
expect(fondDe(formulaire(m))).toEqual(jeton('--couleur-carte-fond-sourd'));
|
||||
expect(formulaire(m).compareDocumentPosition(grille(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
// « Ajouter » est l'action principale de la carte : pleine, haute d'une cible.
|
||||
const ajout = formulaire(m).querySelector('[data-commande="ajouter"]');
|
||||
expect(fondDe(ajout)).toEqual(jeton('--couleur-action-fond'));
|
||||
expect(composantes(getComputedStyle(ajout).color)).toEqual(jeton('--couleur-action-texte'));
|
||||
expect(ajout.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
// « Exporter la liste » est dans l'en-tête de la carte, avant le formulaire.
|
||||
const exporter = region.querySelector('[data-commande="exporter"]');
|
||||
expect(exporter.compareDocumentPosition(formulaire(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test('la légende : une pastille par appartenance, dans l’ordre des teintes, avec son compte, la teinte de son rang ; un filtre ne la change pas', async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT });
|
||||
const teintes = teintesAppartenances(participants(m));
|
||||
const attendue = [...teintes].map(([nom, rang]) => ({
|
||||
texte: t('participants.legende.element', { nom, compte: participants(m).filter((p) => p.appartenance === nom).length }),
|
||||
rang,
|
||||
}));
|
||||
expect(attendue.map(({ rang }) => rang)).toEqual([1, 2, 3, 4, 5, 6, null]);
|
||||
const lue = () =>
|
||||
[...legende(m).querySelectorAll('li')].map((li) => ({ texte: texteDe(li), fond: fondDe(pastilleDans(li)) }));
|
||||
const peinte = attendue.map(({ texte, rang }) => ({
|
||||
texte,
|
||||
fond: jeton(rang === null ? '--couleur-neutre-fond' : `--couleur-appartenance-${rang}-fond`),
|
||||
}));
|
||||
expect(lue()).toEqual(peinte);
|
||||
expect(lue()[2].texte).toBe('Cercle des Grives \u{00B7} 2');
|
||||
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Grives');
|
||||
await rendu();
|
||||
expect(idsAffiches(m)).toHaveLength(2);
|
||||
expect(lue()).toEqual(peinte);
|
||||
});
|
||||
|
||||
test('la grille : appartenance en pastille de sa teinte, stable sous le filtre ; « exclue » et le titre pressenti en pastilles à icône ; encre de la page', async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT });
|
||||
const teintes = teintesAppartenances(participants(m));
|
||||
const encre = jeton('--couleur-page-texte');
|
||||
for (const p of participants(m)) {
|
||||
const pastille = pastilleDans(cellule(m, p.id, 'appartenance'));
|
||||
if (p.appartenance === null) {
|
||||
expect(pastille, p.nom).toBeNull();
|
||||
continue;
|
||||
}
|
||||
const rang = teintes.get(p.appartenance);
|
||||
expect(texteDe(pastille), p.nom).toBe(p.appartenance);
|
||||
expect(fondDe(pastille), p.nom).toEqual(jeton(rang === null ? '--couleur-neutre-fond' : `--couleur-appartenance-${rang}-fond`));
|
||||
expect(composantes(getComputedStyle(pastille).color), p.nom).toEqual(encre);
|
||||
}
|
||||
const loutres = idDe(m, 'Brindelle');
|
||||
const exclue = pastilleDans(cellule(m, loutres, 'exclu'));
|
||||
expect(texteDe(exclue)).toBe(t('participants.grille.exclue'));
|
||||
expect(fondDe(exclue)).toEqual(jeton('--couleur-neutre-fond'));
|
||||
expect(exclue.querySelector('svg')).not.toBeNull();
|
||||
const presente = cellule(m, idDe(m, 'Doucemer'), 'exclu');
|
||||
expect(texteDe(presente)).toBe(t('participants.grille.presente'));
|
||||
expect(pastilleDans(presente)).toBeNull();
|
||||
const titre = pastilleDans(cellule(m, idDe(m, 'Calvaille'), 'titrePressenti'));
|
||||
expect(texteDe(titre)).toBe('hôtesse');
|
||||
expect(fondDe(titre)).toEqual(jeton('--couleur-information-fond'));
|
||||
expect(titre.querySelector('svg')).not.toBeNull();
|
||||
expect(pastilleDans(cellule(m, idDe(m, 'Doucemer'), 'titrePressenti'))).toBeNull();
|
||||
|
||||
// Le filtre ne repeint rien : la teinte se lit sur la liste entière.
|
||||
const grives = idDe(m, 'Doucemer');
|
||||
const avant = fondDe(pastilleDans(cellule(m, grives, 'appartenance')));
|
||||
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Doucemer');
|
||||
await rendu();
|
||||
expect(idsAffiches(m)).toEqual([grives]);
|
||||
expect(fondDe(pastilleDans(cellule(m, grives, 'appartenance')))).toEqual(avant);
|
||||
|
||||
// En sombre, la pastille prend la teinte sombre du même rang.
|
||||
await sousTheme('sombre', async () => {
|
||||
expect(fondDe(pastilleDans(cellule(m, grives, 'appartenance')))).toEqual(jeton(`--couleur-appartenance-${teintes.get('Cercle des Grives')}-fond`));
|
||||
});
|
||||
expect(RANGS_APPARTENANCE).toBe(6);
|
||||
});
|
||||
|
||||
test('les actions de ligne sont des boutons secondaires : fond de la carte, filet de la page, rayon de 8 px', async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT.slice(0, 2) });
|
||||
const id = idDe(m, 'Quenouillet');
|
||||
for (const nom of ['fiche', 'voirSurLePlan', 'exclureParticipant', 'supprimerParticipant']) {
|
||||
const bouton = commandeDeLigne(m, id, nom);
|
||||
const style = getComputedStyle(bouton);
|
||||
expect(fondDe(bouton), nom).toEqual(jeton('--couleur-carte-fond'));
|
||||
expect(composantes(style.borderTopColor), nom).toEqual(jeton('--couleur-page-filet'));
|
||||
expect(style.borderTopLeftRadius, nom).toBe('8px');
|
||||
}
|
||||
});
|
||||
|
||||
test('la fiche est une carte : fond de la carte, filet de la carte, rayon de 12 px', async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT.slice(0, 2) });
|
||||
m.ui.ouvrirFiche(idDe(m, 'Quenouillet'));
|
||||
await rendu();
|
||||
const style = getComputedStyle(fiche(m));
|
||||
expect(fondDe(fiche(m))).toEqual(jeton('--couleur-carte-fond'));
|
||||
expect(composantes(style.borderTopColor)).toEqual(jeton('--couleur-carte-filet'));
|
||||
expect(style.borderTopLeftRadius).toBe('12px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’habillage dans le cadre : rien ne déborde, rien ne se cache sous le bord', () => {
|
||||
// Pose le cadre largeur × hauteur le temps de mesure, puis rend celui d'avant.
|
||||
async function dansLeCadre(largeur, hauteur, mesure) {
|
||||
const avant = { largeur: innerWidth, hauteur: innerHeight };
|
||||
await fixerCadre(largeur, hauteur);
|
||||
try {
|
||||
await mesure();
|
||||
} finally {
|
||||
await fixerCadre(avant.largeur, avant.hauteur);
|
||||
}
|
||||
}
|
||||
|
||||
for (const largeur of [1366, 1024]) {
|
||||
test(`à ${largeur} × 700 : la page ne défile pas en largeur, la carte tient dans la fenêtre, la grille dans la carte`, async () => {
|
||||
await dansLeCadre(largeur, 700, async () => {
|
||||
const m = await evenementOuvert({ personnes: SEPT });
|
||||
m.ui.ouvrirFiche(idDe(m, 'Calvaille'));
|
||||
await rendu();
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
|
||||
const region = carte(m).getBoundingClientRect();
|
||||
expect(region.right).toBeLessThanOrEqual(innerWidth);
|
||||
expect(fiche(m).getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
|
||||
// Le conteneur de défilement tient dans la carte, et le tableau dans
|
||||
// le conteneur : aucune colonne, celle des actions de ligne comprise,
|
||||
// ne se cache au-delà de son bord.
|
||||
const defilement = grille(m).parentElement;
|
||||
expect(defilement.getBoundingClientRect().right).toBeLessThanOrEqual(region.right);
|
||||
expect(defilement.scrollWidth, 'le tableau défile en largeur dans son conteneur').toBeLessThanOrEqual(defilement.clientWidth);
|
||||
for (const li of legende(m).querySelectorAll('li')) expect(li.getBoundingClientRect().right).toBeLessThanOrEqual(region.right);
|
||||
// Chaque action de ligne tient sur une ligne : les boîtes de son
|
||||
// texte partagent un même haut.
|
||||
for (const rangee of lignes(m)) {
|
||||
for (const bouton of rangee.lastElementChild.querySelectorAll('button')) {
|
||||
const etendue = document.createRange();
|
||||
etendue.selectNodeContents(bouton);
|
||||
const hauts = new Set([...etendue.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top)));
|
||||
expect(hauts.size, `${rangee.dataset.participant} : ${bouton.textContent.trim()}`).toBe(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('dans une fenêtre basse, le signal d’orthographe et le refus du nom viennent dans la fenêtre, à côté de leur champ', async () => {
|
||||
await dansLeCadre(1024, 420, async () => {
|
||||
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
|
||||
await saisir(champ(m, 'appartenance'), 'club des merles');
|
||||
const signal = formulaire(m).querySelector('[data-avertissement]');
|
||||
expect(visible(signal)).toBe(true);
|
||||
await cliquer(signal.querySelector('[data-commande="reprendreOrthographe"]'));
|
||||
// Le champ du nom au bord bas de la fenêtre : Entrée y pose le refus
|
||||
// dessous, que le défilement amène dans la fenêtre.
|
||||
await saisir(champ(m, 'nom'), ' ');
|
||||
champ(m, 'nom').scrollIntoView({ block: 'end' });
|
||||
champ(m, 'nom').focus({ preventScroll: true });
|
||||
await touche('Enter');
|
||||
await vi.waitFor(() => expect(champ(m, 'nom').getAttribute('aria-describedby')).not.toBeNull());
|
||||
await rendu();
|
||||
const message = document.getElementById(champ(m, 'nom').getAttribute('aria-describedby'));
|
||||
expect(visible(message)).toBe(true);
|
||||
expect(participants(m)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : le
|
||||
// formulaire d'ajout, le filtre, le compte, l'export, la grille et la
|
||||
// fiche ouverte. Les lignes de la grille sont celles de lignesGrille
|
||||
// (grille.js) sur la liste de la séance, le tri et le filtre de la
|
||||
// La section des participants (§ 2.1 étape 3, § 4, § 10.2) : une carte
|
||||
// titrée, dont l'en-tête porte la ligne de comptes (comptesEntete,
|
||||
// entete.js) et l'export ; dessous, le formulaire d'ajout en bande de tête,
|
||||
// puis la barre du filtre, du groupement, du compte et de la légende des
|
||||
// appartenances, puis la grille ; à côté, la fiche ouverte, ou dessous
|
||||
// quand la ligne ne tient pas les deux. Les lignes de la grille sont
|
||||
// celles de lignesGrille (grille.js) sur la liste de la séance, le tri et le filtre de la
|
||||
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
|
||||
// en lecture. La fiche ouverte est celle de l'état de navigation, ui.fiche.
|
||||
//
|
||||
|
|
@ -33,6 +36,11 @@
|
|||
// écrit. Sans groupement, ou sans placement montré, aucun conflit ne se
|
||||
// lit.
|
||||
//
|
||||
// Les teintes d'appartenance (teintesAppartenances) se calculent une fois
|
||||
// sur la liste entière, exclues comprises, et la légende comme la grille
|
||||
// les lisent : un filtre ne repeint rien. La légende nomme chaque
|
||||
// appartenance avec le nombre de personnes qui la portent.
|
||||
//
|
||||
// Supprimer une personne qui porte des réservations ouvre la fenêtre à
|
||||
// confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
|
||||
// déjà tout geste modifiant, la commande part et la séance répond. L'export
|
||||
|
|
@ -41,7 +49,10 @@
|
|||
import { getContext } from 'svelte';
|
||||
import { cleNormalisee, exporterParticipants } from '../../application/csv.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { teintesAppartenances } from '../appartenances.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Fiche from './Fiche.svelte';
|
||||
import Formulaire from './Formulaire.svelte';
|
||||
|
|
@ -49,6 +60,7 @@
|
|||
import { statuts } from '../../application/places.js';
|
||||
import { conflitsDuPlacement } from '../../application/conflits_placement.js';
|
||||
import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js';
|
||||
import { comptesEntete, legendeAppartenances } from './entete.js';
|
||||
import {
|
||||
choisirGroupement,
|
||||
cleDeGroupement,
|
||||
|
|
@ -78,6 +90,9 @@
|
|||
|
||||
const charge = $derived($seance.charge);
|
||||
const participants = $derived(charge?.participants ?? []);
|
||||
const teintes = $derived(teintesAppartenances(participants));
|
||||
const comptes = $derived(comptesEntete(participants));
|
||||
const legende = $derived(legendeAppartenances(participants, teintes));
|
||||
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
|
||||
const toutes = $derived(lignesGrille(participants, { tri, filtre: '' }, cleNormalisee));
|
||||
const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
|
||||
|
|
@ -121,55 +136,65 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<section data-section="participants">
|
||||
<h2>{t('coquille.section.participants')}</h2>
|
||||
|
||||
<Formulaire {participants} />
|
||||
|
||||
<div class="outils">
|
||||
<div class="filtre">
|
||||
<label for={`${uid}-filtre`}>{t('participants.grille.filtre')}</label>
|
||||
<input id={`${uid}-filtre`} type="search" data-champ="filtre" bind:value={filtre} />
|
||||
</div>
|
||||
{#each niveaux as rang (rang)}
|
||||
<div class="filtre">
|
||||
<label for={`${uid}-grouper-${rang + 1}`}>
|
||||
{rang === 0 ? t('participants.groupe.grouperPar') : t('participants.groupe.puisPar', { niveau: rang + 1 })}
|
||||
</label>
|
||||
<select
|
||||
id={`${uid}-grouper-${rang + 1}`}
|
||||
data-champ="grouper"
|
||||
data-niveau={rang + 1}
|
||||
value={groupement[rang] ?? ''}
|
||||
onchange={(evenement) => choisirNiveau(rang, evenement.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t(rang === 0 ? 'participants.groupe.aucun' : 'participants.groupe.aucunSousGroupe')}</option>
|
||||
{#each colonnesOffertes(groupement, rang) as colonne (colonne)}
|
||||
<option value={colonne}>{t(cleDeGroupement(colonne))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
{#if groupes !== null}
|
||||
<button type="button" data-commande="toutReplier" onclick={() => (replies = new Set(clesPremierNiveau(groupesEntiers)))}>
|
||||
{t('participants.groupe.toutReplier')}
|
||||
</button>
|
||||
<button type="button" data-commande="toutDeplier" onclick={() => (replies = new Set())}>
|
||||
{t('participants.groupe.toutDeplier')}
|
||||
</button>
|
||||
{/if}
|
||||
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
|
||||
<Commande cle="participants.commande.exporter" nom="exporter" executer={exporter} />
|
||||
</div>
|
||||
|
||||
<div class="section" data-section="participants">
|
||||
<div class="corps">
|
||||
<div class="liste">
|
||||
<Grille {lignes} {groupes} bind:replies bind:tri {supprimer} conflits={conflitsParGroupe} {enConflit} />
|
||||
{#if participants.length === 0}
|
||||
<p>{t('participants.grille.vide')}</p>
|
||||
{:else if lignes.length === 0}
|
||||
<p>{t('participants.grille.aucunResultat')}</p>
|
||||
{/if}
|
||||
<Carte titre={t('coquille.section.participants')} sousTitre={t('participants.entete.comptes', comptes)} plein>
|
||||
{#snippet actions()}
|
||||
<Commande cle="participants.commande.exporter" nom="exporter" executer={exporter} />
|
||||
{/snippet}
|
||||
|
||||
<Formulaire {participants} />
|
||||
|
||||
<div class="outils">
|
||||
<div class="filtre">
|
||||
<label for={`${uid}-filtre`}>{t('participants.grille.filtre')}</label>
|
||||
<input id={`${uid}-filtre`} type="search" data-champ="filtre" bind:value={filtre} />
|
||||
</div>
|
||||
{#each niveaux as rang (rang)}
|
||||
<div class="filtre">
|
||||
<label for={`${uid}-grouper-${rang + 1}`}>
|
||||
{rang === 0 ? t('participants.groupe.grouperPar') : t('participants.groupe.puisPar', { niveau: rang + 1 })}
|
||||
</label>
|
||||
<select
|
||||
id={`${uid}-grouper-${rang + 1}`}
|
||||
data-champ="grouper"
|
||||
data-niveau={rang + 1}
|
||||
value={groupement[rang] ?? ''}
|
||||
onchange={(evenement) => choisirNiveau(rang, evenement.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t(rang === 0 ? 'participants.groupe.aucun' : 'participants.groupe.aucunSousGroupe')}</option>
|
||||
{#each colonnesOffertes(groupement, rang) as colonne (colonne)}
|
||||
<option value={colonne}>{t(cleDeGroupement(colonne))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
{#if groupes !== null}
|
||||
<button type="button" data-commande="toutReplier" onclick={() => (replies = new Set(clesPremierNiveau(groupesEntiers)))}>
|
||||
{t('participants.groupe.toutReplier')}
|
||||
</button>
|
||||
<button type="button" data-commande="toutDeplier" onclick={() => (replies = new Set())}>
|
||||
{t('participants.groupe.toutDeplier')}
|
||||
</button>
|
||||
{/if}
|
||||
<output>{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}</output>
|
||||
{#if legende.length > 0}
|
||||
<ul class="legende" aria-label={t('participants.legende.titre')}>
|
||||
{#each legende as { nom, rang, compte } (nom)}
|
||||
<li><Pastille sorte="appartenance" valeur={rang} libelle={t('participants.legende.element', { nom, compte })} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Grille {lignes} {groupes} {teintes} bind:replies bind:tri {supprimer} conflits={conflitsParGroupe} {enConflit} />
|
||||
{#if participants.length === 0}
|
||||
<p class="vide">{t('participants.grille.vide')}</p>
|
||||
{:else if lignes.length === 0}
|
||||
<p class="vide">{t('participants.grille.aucunResultat')}</p>
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
{#if personneFiche !== null}
|
||||
<Fiche participant={personneFiche} {charge} />
|
||||
|
|
@ -185,55 +210,63 @@
|
|||
surAnnuler={() => (aSupprimer = null)}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
section {
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
/* La barre sous le formulaire : filtre, groupement, compte, puis la
|
||||
légende, poussée à droite tant que la ligne la tient. */
|
||||
.outils {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-3);
|
||||
gap: var(--espace-3) var(--espace-4);
|
||||
padding: var(--espace-3) var(--espace-5);
|
||||
}
|
||||
|
||||
.filtre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
box-sizing: border-box;
|
||||
min-height: 2.5rem;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.outils > button {
|
||||
min-height: 2.5rem;
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.outils > button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
.outils > button:focus-visible {
|
||||
|
|
@ -242,7 +275,25 @@
|
|||
}
|
||||
|
||||
output {
|
||||
font-size: var(--taille-texte);
|
||||
align-self: center;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.legende {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-self: center;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
min-width: 0;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.legende li {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.corps {
|
||||
|
|
@ -252,12 +303,17 @@
|
|||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
/* La liste réclame le plancher du tableau (Grille, 59rem) et le cadre de
|
||||
sa carte : quand la ligne ne tient plus la liste et la fiche côte à
|
||||
côte, la fiche passe dessous, et le tableau garde toute sa largeur. */
|
||||
.liste {
|
||||
flex: 1 1 32rem;
|
||||
flex: 1 1 60rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--espace-2) 0;
|
||||
.vide {
|
||||
margin: 0;
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
51
src/interface/participants/entete.js
Normal file
51
src/interface/participants/entete.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'en-tête de la liste des participants, pur : la ligne de comptes de la
|
||||
// carte et la légende des appartenances. Les deux se lisent sur la liste
|
||||
// entière, exclues comprises — la population de l'attribution des teintes
|
||||
// (teintesAppartenances, appartenances.js) — : un filtre ne change ni un
|
||||
// compte ni une teinte. Aucune fonction ne modifie ce qu'elle reçoit.
|
||||
|
||||
/**
|
||||
* La ligne de comptes de la carte des participants.
|
||||
*
|
||||
* @param {Array<{appartenance: string|null, exclu: boolean}>} participants
|
||||
* tous ceux de l'événement
|
||||
* @returns {{personnes: number, aPlacer: number, exclues: number, appartenances: number}}
|
||||
* les personnes, celles que le placement assoit — les non exclues —, les
|
||||
* exclues, et les appartenances distinctes par égalité de chaîne, comme
|
||||
* le moteur les distingue
|
||||
*/
|
||||
export function comptesEntete(participants) {
|
||||
const appartenances = new Set();
|
||||
let exclues = 0;
|
||||
for (const { appartenance, exclu } of participants) {
|
||||
if (exclu) exclues += 1;
|
||||
if (appartenance !== null) appartenances.add(appartenance);
|
||||
}
|
||||
return { personnes: participants.length, aPlacer: participants.length - exclues, exclues, appartenances: appartenances.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* La légende des appartenances : une entrée par appartenance de teintes,
|
||||
* dans l'ordre de la Map, avec le nombre de personnes de participants qui
|
||||
* la portent. Une appartenance que personne ne porte dans participants n'y
|
||||
* figure pas.
|
||||
*
|
||||
* @param {Array<{appartenance: string|null}>} participants
|
||||
* @param {Map<string, number|null>} teintes celles de teintesAppartenances,
|
||||
* calculées sur la liste entière
|
||||
* @returns {Array<{nom: string, rang: number|null, compte: number}>}
|
||||
*/
|
||||
export function legendeAppartenances(participants, teintes) {
|
||||
const comptes = new Map();
|
||||
for (const { appartenance } of participants) {
|
||||
if (appartenance !== null) comptes.set(appartenance, (comptes.get(appartenance) ?? 0) + 1);
|
||||
}
|
||||
const legende = [];
|
||||
for (const [nom, rang] of teintes) {
|
||||
if (comptes.has(nom)) legende.push({ nom, rang, compte: comptes.get(nom) });
|
||||
}
|
||||
return legende;
|
||||
}
|
||||
107
src/interface/participants/entete.test.js
Normal file
107
src/interface/participants/entete.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'en-tête de la liste des participants : la ligne de comptes de la carte
|
||||
// et la légende des appartenances, chacune lue sur la liste entière, exclues
|
||||
// comprises, et le texte que les libellés en composent. Les noms d'épreuve
|
||||
// sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { comptesEntete, legendeAppartenances } from './entete.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const personne = (id, appartenance, exclu = false) => ({
|
||||
id,
|
||||
nom: `Brumaille${id}`,
|
||||
prenom: null,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu,
|
||||
});
|
||||
|
||||
// Trois appartenances, dont une portée par une exclue seule, et une
|
||||
// personne sans appartenance.
|
||||
const LISTE = [
|
||||
personne(1, 'Cercle des Grives'),
|
||||
personne(2, 'Atelier Pivoine'),
|
||||
personne(3, 'Cercle des Grives', true),
|
||||
personne(4, null),
|
||||
personne(5, 'Cercle des Grives'),
|
||||
personne(6, 'Banc des Loutres', true),
|
||||
];
|
||||
|
||||
describe('comptesEntete : la ligne de comptes de la carte des participants', () => {
|
||||
test('personnes, à placer, exclues et appartenances distinctes, exclues comprises', () => {
|
||||
assert.deepEqual(comptesEntete(LISTE), { personnes: 6, aPlacer: 4, exclues: 2, appartenances: 3 });
|
||||
});
|
||||
|
||||
test('une liste vide : quatre zéros', () => {
|
||||
assert.deepEqual(comptesEntete([]), { personnes: 0, aPlacer: 0, exclues: 0, appartenances: 0 });
|
||||
});
|
||||
|
||||
test('deux orthographes sont deux appartenances, comme pour le moteur et les teintes', () => {
|
||||
const liste = [personne(1, 'Cercle des Grives'), personne(2, 'cercle des grives')];
|
||||
assert.equal(comptesEntete(liste).appartenances, 2);
|
||||
assert.equal(comptesEntete(liste).appartenances, teintesAppartenances(liste).size);
|
||||
});
|
||||
|
||||
test('le libellé compose la ligne, accordée au nombre', () => {
|
||||
assert.equal(
|
||||
t('participants.entete.comptes', { personnes: 13, aPlacer: 12, exclues: 1, appartenances: 3 }),
|
||||
'13 personnes \u{00B7} 12 à placer \u{00B7} 1 exclue \u{00B7} 3 appartenances',
|
||||
);
|
||||
assert.equal(
|
||||
t('participants.entete.comptes', { personnes: 1, aPlacer: 0, exclues: 2, appartenances: 1 }),
|
||||
'1 personne \u{00B7} 0 à placer \u{00B7} 2 exclues \u{00B7} 1 appartenance',
|
||||
);
|
||||
assert.equal(
|
||||
t('participants.entete.comptes', { personnes: 0, aPlacer: 0, exclues: 0, appartenances: 0 }),
|
||||
'0 personne \u{00B7} 0 à placer \u{00B7} 0 exclue \u{00B7} 0 appartenance',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legendeAppartenances : la légende en pastilles, avec leur compte', () => {
|
||||
test('dans l’ordre des teintes, chaque appartenance avec son rang et le nombre de personnes qui la portent, exclues comprises', () => {
|
||||
assert.deepEqual(legendeAppartenances(LISTE, teintesAppartenances(LISTE)), [
|
||||
{ nom: 'Atelier Pivoine', rang: 1, compte: 1 },
|
||||
{ nom: 'Banc des Loutres', rang: 2, compte: 1 },
|
||||
{ nom: 'Cercle des Grives', rang: 3, compte: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('au-delà des rangs de la palette, le rang est null : la pastille est neutre, le compte reste', () => {
|
||||
const liste = Array.from({ length: RANGS_APPARTENANCE + 2 }, (_, i) => personne(i + 1, `Guilde ${String.fromCodePoint(65 + i)}`));
|
||||
liste.push(personne(99, 'Guilde A'));
|
||||
const legende = legendeAppartenances(liste, teintesAppartenances(liste));
|
||||
assert.equal(legende.length, RANGS_APPARTENANCE + 2);
|
||||
assert.deepEqual(legende[0], { nom: 'Guilde A', rang: 1, compte: 2 });
|
||||
assert.deepEqual(
|
||||
legende.map(({ rang }) => rang),
|
||||
[...Array.from({ length: RANGS_APPARTENANCE }, (_, i) => i + 1), null, null],
|
||||
);
|
||||
});
|
||||
|
||||
test('sans appartenance portée, une légende vide ; une personne sans appartenance n’y entre pas', () => {
|
||||
const liste = [personne(1, null), personne(2, null, true)];
|
||||
assert.deepEqual(legendeAppartenances(liste, teintesAppartenances(liste)), []);
|
||||
});
|
||||
|
||||
test('les rangs sont ceux des teintes reçues, jamais recalculés sur une liste filtrée', () => {
|
||||
const teintes = teintesAppartenances(LISTE);
|
||||
const filtree = LISTE.filter(({ appartenance }) => appartenance === 'Cercle des Grives');
|
||||
assert.deepEqual(legendeAppartenances(filtree, teintes), [{ nom: 'Cercle des Grives', rang: 3, compte: 3 }]);
|
||||
});
|
||||
|
||||
test('le libellé d’un élément : le nom, puis le compte', () => {
|
||||
assert.equal(t('participants.legende.element', { nom: 'Atelier Pivoine', compte: 4 }), 'Atelier Pivoine \u{00B7} 4');
|
||||
assert.equal(t('participants.legende.titre'), 'Appartenances');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,10 @@
|
|||
// champ, qui rend la valeur enregistrée. Un refus de l'événement entier —
|
||||
// moins de 2 places — se pose sur le champ (refusDuChampDefaut).
|
||||
//
|
||||
// L'énoncé se pose sur le fond sourd de la carte ; le refus d'une table
|
||||
// en refus, un avertissement en avis, chacun son signe et un texte de
|
||||
// l'encre de la page.
|
||||
//
|
||||
// La demande tombe quand le défaut l'atteint ; appliquée, elle tombe, et
|
||||
// les avertissements du geste s'affichent tant que la révision qui les a
|
||||
// produits est la courante.
|
||||
|
|
@ -26,6 +30,7 @@
|
|||
import Champ from '../primitives/Champ.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import { actionRemede } from '../primitives/commande.js';
|
||||
import { avertissementsNommes, enonceDefaut, refusDuChampDefaut, surchargesPerdues } from './modele.js';
|
||||
|
||||
|
|
@ -112,6 +117,7 @@
|
|||
{#snippet refusDeTable(entree)}
|
||||
{@const action = actionRemede(entree.details.refus.details.remede ?? null, { seance, ui })}
|
||||
<div class="refus" data-refus={entree.details.refus.code}>
|
||||
<span class="signe"><Icone nom="interdit" /></span>
|
||||
<p><span data-phrase>{t(entree.cle, entree.details)}</span></p>
|
||||
{#if action !== null}
|
||||
<Commande
|
||||
|
|
@ -183,6 +189,7 @@
|
|||
|
||||
{#each avertissements as avertissement, rang (rang)}
|
||||
<div class="avertissement" data-avertissement={avertissement.code} role="status">
|
||||
<span class="signe"><Icone nom="triangle" /></span>
|
||||
{#if avertissement.numero !== null}
|
||||
<p><span>{t('tables.liste.table')}</span> <span>{avertissement.numero}</span></p>
|
||||
{/if}
|
||||
|
|
@ -221,23 +228,37 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
padding: var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.enonce p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Le refus d'une table : le signe à gauche, la phrase et le remède
|
||||
dessous l'un de l'autre, à sa droite. */
|
||||
.refus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
padding-left: var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-refus);
|
||||
color: var(--couleur-refus);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-refus-filet);
|
||||
border-left: 3px solid var(--couleur-refus-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-refus-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.refus > :not(.signe) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.refus .signe {
|
||||
color: var(--couleur-refus-marque);
|
||||
}
|
||||
|
||||
.realignement {
|
||||
|
|
@ -257,13 +278,39 @@
|
|||
}
|
||||
|
||||
.avertissement {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-avis-filet);
|
||||
border-left: 3px solid var(--couleur-avis-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.avertissement > :not(.signe) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.avertissement .signe {
|
||||
color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.signe {
|
||||
display: inline-flex;
|
||||
grid-row: 1 / span 3;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.avertissement p,
|
||||
.avertissement ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refus p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La liste des tables (§ 4, § 6.1, § 6.2) : la création, ronde ou carrée,
|
||||
// puis une ligne par table, par numéro — son numéro, sa forme, son état
|
||||
// (« suit le défaut » ou « surchargée », etatTable), le champ de ses
|
||||
// places, « Réaligner » pour une surchargée, « Supprimer ». Le champ
|
||||
// La liste des tables (§ 4, § 6.1, § 6.2) : une carte, dont l'en-tête
|
||||
// compte les tables et leurs sièges (comptesTables) et porte la création,
|
||||
// ronde ou carrée ; puis une ligne par table, par numéro — son numéro, sa
|
||||
// forme, son état en pastille (« suit le défaut » ou « surchargée »,
|
||||
// etatTable, de ton tonEtatTable), le champ de ses places, « Réaligner »
|
||||
// pour une surchargée, « Supprimer ». Le champ
|
||||
// montre la capacité ; une saisie lue par lireEntier s'exécute par
|
||||
// changerPlacesTable, au champ, et son refus se pose contre lui.
|
||||
//
|
||||
|
|
@ -13,7 +15,9 @@
|
|||
// révision qui l'a vu est la courante : les sièges qu'un refus
|
||||
// SIEGES_RETIRES retire, nommés (siegesRetiresNommes), ou les
|
||||
// avertissements d'un geste accepté, leurs personnes nommées
|
||||
// (avertissementsNommes). Une frappe dans le champ, ou Échap, retire le
|
||||
// (avertissementsNommes) : les sièges retirés en refus, les
|
||||
// avertissements en avis, chacun son signe et un texte de l'encre de la
|
||||
// page. Une frappe dans le champ, ou Échap, retire le
|
||||
// complément d'un refus avec le refus que le champ retire.
|
||||
//
|
||||
// Supprimer une table qui porte une réservation ou un titre ouvre la
|
||||
|
|
@ -24,18 +28,23 @@
|
|||
import { bilanSuppressionTable } from '../../application/bilans.js';
|
||||
import { lireEntier } from '../../application/capacite.js';
|
||||
import { ErreurCommande } from '../../application/erreurs.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { effetDe } from '../raccourcis.js';
|
||||
import {
|
||||
annonceSuppression,
|
||||
avertissementsNommes,
|
||||
cleForme,
|
||||
comptesTables,
|
||||
elementsSuppression,
|
||||
etatTable,
|
||||
siegesRetiresNommes,
|
||||
tablesParNumero,
|
||||
tonEtatTable,
|
||||
} from './modele.js';
|
||||
|
||||
let { charge, revision } = $props();
|
||||
|
|
@ -44,6 +53,7 @@
|
|||
const seance = getContext('seance');
|
||||
|
||||
const tables = $derived(tablesParNumero(charge));
|
||||
const comptes = $derived(comptesTables(charge));
|
||||
|
||||
// Le complément de chaque ligne, par identifiant de table :
|
||||
// { revision, retires, avertissements }. La fenêtre de suppression
|
||||
|
|
@ -119,15 +129,14 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<div class="liste">
|
||||
<h3>{t('tables.liste.titre')}</h3>
|
||||
<div class="creation">
|
||||
<Carte titre={t('tables.liste.titre')} sousTitre={t('tables.liste.comptes', comptes)} niveau={3} plein>
|
||||
{#snippet actions()}
|
||||
<Commande cle="tables.creer.ronde" nom="creerTableRonde" executer={() => seance.executer('creerTable', { forme: 'ronde' })} />
|
||||
<Commande cle="tables.creer.carree" nom="creerTableCarree" executer={() => seance.executer('creerTable', { forme: 'carree' })} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if tables.length === 0}
|
||||
<p>{t('tables.liste.aucune')}</p>
|
||||
<p class="aucune">{t('tables.liste.aucune')}</p>
|
||||
{:else}
|
||||
<ul class="tables">
|
||||
{#each tables as table (table.id)}
|
||||
|
|
@ -136,8 +145,8 @@
|
|||
<li data-table={table.id}>
|
||||
<div class="entete">
|
||||
<span class="numero"><span>{t('tables.liste.table')}</span> <span>{table.numero}</span></span>
|
||||
<span>{t(cleForme(table.forme))}</span>
|
||||
<span class="etat" data-etat-table>{t(etat.cle)}</span>
|
||||
<span class="forme">{t(cleForme(table.forme))}</span>
|
||||
<span class="etat" data-etat-table><Pastille sorte="statut" valeur={tonEtatTable(etat)} libelle={t(etat.cle)} /></span>
|
||||
</div>
|
||||
<div class="saisie" {@attach suivreSaisie(table.id)}>
|
||||
<Champ
|
||||
|
|
@ -148,27 +157,6 @@
|
|||
executer={(sieges) => executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })}
|
||||
/>
|
||||
</div>
|
||||
{#if complement !== null && complement.retires.length > 0}
|
||||
<ul class="retires" data-sieges-retires>
|
||||
{#each complement.retires as retire, rang (rang)}
|
||||
<li>{t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}<span>{retire.nom}</span>{/if}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if complement !== null}
|
||||
{#each complement.avertissements as avertissement, rang (rang)}
|
||||
<div class="avertissement" data-avertissement={avertissement.code} role="status">
|
||||
<p>{t(avertissement.code, avertissement.details)}</p>
|
||||
{#if avertissement.noms.length > 0}
|
||||
<ul>
|
||||
{#each avertissement.noms as nom, rangNom (rangNom)}
|
||||
<li>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="commandes">
|
||||
{#if etat.realignable}
|
||||
<Commande
|
||||
|
|
@ -179,89 +167,156 @@
|
|||
{/if}
|
||||
<Commande cle="tables.liste.supprimer" nom="supprimerTable" executer={() => supprimer(table)} />
|
||||
</div>
|
||||
{#if complement !== null && complement.retires.length > 0}
|
||||
<div class="message refus">
|
||||
<span class="signe"><Icone nom="interdit" /></span>
|
||||
<ul class="retires" data-sieges-retires>
|
||||
{#each complement.retires as retire, rang (rang)}
|
||||
<li>{t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}<span>{retire.nom}</span>{/if}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if complement !== null}
|
||||
{#each complement.avertissements as avertissement, rang (rang)}
|
||||
<div class="message avis" data-avertissement={avertissement.code} role="status">
|
||||
<span class="signe"><Icone nom="triangle" /></span>
|
||||
<div class="texte">
|
||||
<p>{t(avertissement.code, avertissement.details)}</p>
|
||||
{#if avertissement.noms.length > 0}
|
||||
<ul>
|
||||
{#each avertissement.noms as nom, rangNom (rangNom)}
|
||||
<li>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Carte>
|
||||
|
||||
{#if suppression !== null}
|
||||
<Confirmation
|
||||
titre="tables.suppression.confirmation"
|
||||
details={{ numero: suppression.numero }}
|
||||
elements={suppression.elements}
|
||||
surConfirmer={confirmerSuppression}
|
||||
surAnnuler={() => (suppression = null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if suppression !== null}
|
||||
<Confirmation
|
||||
titre="tables.suppression.confirmation"
|
||||
details={{ numero: suppression.numero }}
|
||||
elements={suppression.elements}
|
||||
surConfirmer={confirmerSuppression}
|
||||
surAnnuler={() => (suppression = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
h3 {
|
||||
.aucune {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.creation,
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.tables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Une table par ligne : en-tête, champ et commandes sur une rangée tant
|
||||
qu'elle les tient, les messages dessous, sur toute la largeur. */
|
||||
.tables > li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-2) var(--espace-5);
|
||||
padding: var(--espace-3) var(--espace-5);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
}
|
||||
|
||||
.tables > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-3);
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
min-width: 12rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.numero {
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.forme {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
/* L'état se lit par son texte ; l'italique le détache de la forme. */
|
||||
.etat {
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retires {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
color: var(--couleur-refus);
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.avertissement {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
/* Un message de ligne : son signe, un texte de l'encre de la page, sur
|
||||
le fond de sa sorte. */
|
||||
.message {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid;
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--rayon-2);
|
||||
color: var(--couleur-page-texte);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.avertissement p,
|
||||
.avertissement ul {
|
||||
.signe {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.refus {
|
||||
border-color: var(--couleur-refus-filet);
|
||||
border-left-color: var(--couleur-refus-marque);
|
||||
background-color: var(--couleur-refus-fond);
|
||||
}
|
||||
|
||||
.refus .signe {
|
||||
color: var(--couleur-refus-marque);
|
||||
}
|
||||
|
||||
.avis {
|
||||
border-color: var(--couleur-avis-filet);
|
||||
border-left-color: var(--couleur-avis-marque);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
}
|
||||
|
||||
.avis .signe {
|
||||
color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.retires,
|
||||
.texte ul {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-5);
|
||||
}
|
||||
|
||||
.texte p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : le
|
||||
// nombre de places par défaut et l'énoncé de son changement, le nombre de
|
||||
// tours, puis la liste des tables. Elle lit la charge et la révision dans
|
||||
// l'état observable de la séance et les donne à ses parties ; chaque geste
|
||||
// passe par la séance, et chaque refus se pose là où il a eu lieu.
|
||||
// La section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5) : la
|
||||
// carte des réglages — le nombre de places par défaut et l'énoncé de son
|
||||
// changement, le nombre de tours —, puis la carte de la liste des tables.
|
||||
// Elle lit la charge et la révision dans l'état observable de la séance
|
||||
// et les donne à ses parties ; chaque geste passe par la séance, et
|
||||
// chaque refus se pose là où il a eu lieu.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Defaut from './Defaut.svelte';
|
||||
import ListeTables from './ListeTables.svelte';
|
||||
import Tours from './Tours.svelte';
|
||||
|
|
@ -16,29 +18,27 @@
|
|||
const seance = getContext('seance');
|
||||
</script>
|
||||
|
||||
<section data-section="tables">
|
||||
<h2>{t('coquille.section.tables')}</h2>
|
||||
{#if $seance.charge !== null}
|
||||
<div class="reglages">
|
||||
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
|
||||
<Tours charge={$seance.charge} />
|
||||
</div>
|
||||
<div class="section" data-section="tables">
|
||||
{#if $seance.charge === null}
|
||||
<Carte titre={t('coquille.section.tables')} />
|
||||
{:else}
|
||||
<Carte titre={t('coquille.section.tables')}>
|
||||
<div class="reglages">
|
||||
<Defaut charge={$seance.charge} revision={$seance.evenement.revision} />
|
||||
<Tours charge={$seance.charge} />
|
||||
</div>
|
||||
</Carte>
|
||||
<ListeTables charge={$seance.charge} revision={$seance.evenement.revision} />
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
section {
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.reglages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ import { SUFFIXES } from '../../stockage/noms.js';
|
|||
import { VERSION } from '../../version.genere.js';
|
||||
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
|
||||
import { pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { visible } from '../../../test/navigateur/rendu.js';
|
||||
import { composantes, visible } from '../../../test/navigateur/rendu.js';
|
||||
import { creerEtatUi } from '../etat_ui.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import SectionTables from './SectionTables.svelte';
|
||||
import { comptesTables } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
|
|
@ -165,7 +166,8 @@ const ligne = (m, id) => section(m).querySelector(`[data-table="${id}"]`);
|
|||
const champTable = (m, id) => ligne(m, id).querySelector(`input[data-champ="sieges-${id}"]`);
|
||||
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
|
||||
const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`);
|
||||
const etatDe = (m, id) => ligne(m, id).querySelector('[data-etat-table]').textContent;
|
||||
// L'état se lit dans sa pastille, sans les blancs de bord de son balisage.
|
||||
const etatDe = (m, id) => ligne(m, id).querySelector('[data-etat-table]').textContent.trim();
|
||||
const enonce = (m) => section(m).querySelector('[data-enonce="defaut"]');
|
||||
const phrases = (m) => [...enonce(m).querySelectorAll('[data-phrase]')].map((p) => p.textContent).join(' ');
|
||||
const fenetre = () => document.querySelector('dialog[open]');
|
||||
|
|
@ -734,3 +736,61 @@ describe('en lecture (§ 8.4)', () => {
|
|||
expect(ecritures(m)).toBe(avant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’habillage : cartes, pastilles, messages', () => {
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
const fondDe = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
const pastilleDans = (conteneur) => [...conteneur.querySelectorAll('*')].find((e) => fondDe(e)[3] > 0) ?? null;
|
||||
const regions = (m) => [...section(m).querySelectorAll('section[aria-labelledby]')];
|
||||
const nomDe = (region) => document.getElementById(region.getAttribute('aria-labelledby')).textContent.trim();
|
||||
|
||||
test('« Tables et tours » porte les réglages, « Tables » la liste ; l’en-tête de la liste compte tables et sièges, et suit les gestes', async () => {
|
||||
const m = await preparer(treizeTables());
|
||||
const [reglages, liste] = regions(m);
|
||||
expect(regions(m).map(nomDe)).toEqual([t('coquille.section.tables'), t('tables.liste.titre')]);
|
||||
expect(reglages.contains(champ(m, 'siegesParDefaut'))).toBe(true);
|
||||
expect(reglages.contains(champ(m, 'tours'))).toBe(true);
|
||||
expect(liste.contains(ligne(m, 1))).toBe(true);
|
||||
const comptes = () => [...liste.querySelectorAll('p')].map((p) => p.textContent);
|
||||
expect(comptesTables(charge(m))).toEqual({ tables: 13, sieges: 104 });
|
||||
expect(comptes()).toContain(t('tables.liste.comptes', { tables: 13, sieges: 104 }));
|
||||
// Les créations sont les actions de l'en-tête de la liste, avant la première ligne.
|
||||
const ronde = commande(liste, 'creerTableRonde');
|
||||
expect(ronde.compareDocumentPosition(ligne(m, 1)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await cliquer(m, ronde);
|
||||
expect(comptes()).toContain(t('tables.liste.comptes', { tables: 14, sieges: 112 }));
|
||||
});
|
||||
|
||||
test('l’état d’une table en pastille : neutre quand elle suit le défaut, information surchargée, son libellé de l’encre de la page', async () => {
|
||||
const m = await preparer(treizeTables());
|
||||
const encre = jeton('--couleur-page-texte');
|
||||
for (const [id, teinte] of [[1, '--couleur-neutre-fond'], [3, '--couleur-information-fond']]) {
|
||||
const pastille = pastilleDans(ligne(m, id).querySelector('[data-etat-table]'));
|
||||
expect(pastille, `table ${id}`).not.toBeNull();
|
||||
expect(fondDe(pastille), `table ${id}`).toEqual(jeton(teinte));
|
||||
expect(composantes(getComputedStyle(pastille).color), `table ${id}`).toEqual(encre);
|
||||
expect(etatDe(m, id)).toBe(t(id === 3 ? 'tables.etat.surchargee' : 'tables.etat.suitDefaut'));
|
||||
}
|
||||
});
|
||||
|
||||
test('un avertissement de table est un avis : fond et filet de l’avis, son signe, un texte de l’encre de la page', async () => {
|
||||
const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }));
|
||||
const seuil = constante('SEUIL_SIEGES_LISIBLES');
|
||||
await saisir(m, champTable(m, 1), String(seuil + 1));
|
||||
const avis = ligne(m, 1).querySelector('[data-avertissement="AU_DELA_LISIBLE"]');
|
||||
expect(fondDe(avis)).toEqual(jeton('--couleur-avis-fond'));
|
||||
expect(composantes(getComputedStyle(avis).borderTopColor)).toEqual(jeton('--couleur-avis-filet'));
|
||||
const signe = avis.querySelector('svg');
|
||||
expect(signe).not.toBeNull();
|
||||
expect(composantes(getComputedStyle(signe).color)).toEqual(jeton('--couleur-avis-marque'));
|
||||
expect(composantes(getComputedStyle(avis.querySelector('p')).color)).toEqual(jeton('--couleur-page-texte'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,31 @@ export function etatTable(table, siegesParDefaut) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le ton de la pastille d'un état de table (etatTable) : 'neutre' pour une
|
||||
* table qui suit le défaut, 'information' pour une surchargée. La pastille
|
||||
* porte toujours le libellé de l'état ; le ton le redit.
|
||||
*
|
||||
* @param {{realignable: boolean}} etat
|
||||
* @returns {'neutre'|'information'}
|
||||
*/
|
||||
export function tonEtatTable(etat) {
|
||||
return etat.realignable ? 'information' : 'neutre';
|
||||
}
|
||||
|
||||
/**
|
||||
* L'en-tête de la liste des tables : le nombre de tables et le total de
|
||||
* leurs capacités, celle d'une table qui suit le défaut étant le défaut.
|
||||
*
|
||||
* @param {{tables: Array<{sieges: number|null}>, evenement: {siegesParDefaut: number}}} charge
|
||||
* @returns {{tables: number, sieges: number}}
|
||||
*/
|
||||
export function comptesTables(charge) {
|
||||
let sieges = 0;
|
||||
for (const table of charge.tables) sieges += etatTable(table, charge.evenement.siegesParDefaut).capacite;
|
||||
return { tables: charge.tables.length, sieges };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'énoncé du § 6.1, en clés et détails, depuis bilanChangementDefaut ; ses
|
||||
* phrases se lisent séparées d'une espace. Accepté : la phrase
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
annonceTours,
|
||||
avertissementsNommes,
|
||||
cleForme,
|
||||
comptesTables,
|
||||
elementsSuppression,
|
||||
elementsTours,
|
||||
enonceDefaut,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
siegesRetiresNommes,
|
||||
surchargesPerdues,
|
||||
tablesParNumero,
|
||||
tonEtatTable,
|
||||
} from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
|
@ -330,3 +332,26 @@ describe('refusDeCommande : la commande essayée sans rien écrire', () => {
|
|||
assert.equal(charge.evenement.tours, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comptesTables et tonEtatTable : l’en-tête et la pastille de la liste des tables', () => {
|
||||
test('le nombre de tables et le total de leurs sièges, défaut et surcharges compris', () => {
|
||||
assert.deepEqual(comptesTables(treizeTables()), { tables: 13, sieges: 9 * 8 + 7 + 7 + 6 + 12 });
|
||||
assert.deepEqual(comptesTables(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 3 }], defaut: 6 })), { tables: 2, sieges: 9 });
|
||||
});
|
||||
|
||||
test('sans table : deux zéros', () => {
|
||||
assert.deepEqual(comptesTables(chargeDe()), { tables: 0, sieges: 0 });
|
||||
});
|
||||
|
||||
test('le libellé compose la ligne, accordée au nombre', () => {
|
||||
assert.equal(t('tables.liste.comptes', { tables: 13, sieges: 104 }), '13 tables \u{00B7} 104 sièges');
|
||||
assert.equal(t('tables.liste.comptes', { tables: 1, sieges: 1 }), '1 table \u{00B7} 1 siège');
|
||||
assert.equal(t('tables.liste.comptes', { tables: 0, sieges: 0 }), '0 table \u{00B7} 0 siège');
|
||||
});
|
||||
|
||||
test('la pastille de l’état : neutre pour une table qui suit le défaut, information pour une surchargée', () => {
|
||||
assert.equal(tonEtatTable(etatTable({ sieges: null }, 8)), 'neutre');
|
||||
assert.equal(tonEtatTable(etatTable({ sieges: 5 }, 8)), 'information');
|
||||
assert.equal(tonEtatTable(etatTable({ sieges: 8 }, 8)), 'information');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue