diff --git a/src/interface/import/Apercu.svelte b/src/interface/import/Apercu.svelte
index 46d4220..ead324f 100644
--- a/src/interface/import/Apercu.svelte
+++ b/src/interface/import/Apercu.svelte
@@ -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');
@@ -51,6 +60,7 @@
{#if global !== null}
+
{t(global.cle, global.details)}
{#if !global.remede.dansLaCause}
{t(global.remede.cle, global.remede.details)}
@@ -125,39 +135,40 @@
{/if}
{#if refuseesMontrees.length > 0}
-
- {t('import.apercu.refusees')}
-
-
- {t('import.apercu.ligne')}
- {t('import.apercu.motif')}
-
-
-
- {#each refuseesMontrees as { ligne, code, details } (ligne)}
-
- {format.nombre(ligne)}
-
- {motif(code, details)}
- {#if details.participants !== undefined}
- {t('import.rapport.designees')}
- {#each nommer(participants, details.participants) as personne (personne.id)}
-
- {#if personne.prenom !== null}{personne.prenom} {/if}
- {personne.nom}
-
- {/each}
- {/if}
-
+
+
+ {t('import.apercu.refusees')}
+
+
+ {t('import.apercu.ligne')}
+ {t('import.apercu.motif')}
- {/each}
-
-
+
+
+ {#each refuseesMontrees as { ligne, code, details } (ligne)}
+
+ {format.nombre(ligne)}
+
+ {motif(code, details)}
+ {#if details.participants !== undefined}
+ {t('import.rapport.designees')}
+ {#each nommer(participants, details.participants) as personne (personne.id)}
+
+ {#if personne.prenom !== null}{personne.prenom} {/if}
+ {personne.nom}
+
+ {/each}
+ {/if}
+
+
+ {/each}
+
+
+
{/if}
{#if modele.fusions.length > 0}
-
- {t('import.apercu.fusions')}
+
{#each modele.fusions as fusion, rang (rang)}
@@ -175,12 +186,11 @@
{/each}
-
+
{/if}
{#if modele.doublons.length > 0}
-
- {t('import.apercu.doublons')}
+
{#each modele.doublons as doublon, rang (rang)}
@@ -200,12 +210,11 @@
{/each}
-
+
{/if}
{#if modele.homonymes.length > 0}
-
- {t('import.apercu.homonymes')}
+
{#each modele.homonymes as homonyme (homonyme.ligne)}
@@ -220,12 +229,11 @@
{/each}
-
+
{/if}
{#if modele.multilignes.length > 0}
-
- {t('import.apercu.multilignes')}
+
{#each modele.multilignes as multiligne (multiligne.ligne)}
@@ -234,7 +242,7 @@
{/each}
-
+
{/if}
@@ -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);
}
diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js
index 8ddc082..c90972a 100644
--- a/src/interface/import/Import.navigateur.test.js
+++ b/src/interface/import/Import.navigateur.test.js
@@ -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'));
+ });
+});
diff --git a/src/interface/import/Message.svelte b/src/interface/import/Message.svelte
new file mode 100644
index 0000000..41b92f6
--- /dev/null
+++ b/src/interface/import/Message.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+ {#if titre !== null}
+
{titre}
+ {/if}
+
+ {@render children?.()}
+
+
+
diff --git a/src/interface/import/Rapport.svelte b/src/interface/import/Rapport.svelte
index 9c16e88..d7295f2 100644
--- a/src/interface/import/Rapport.svelte
+++ b/src/interface/import/Rapport.svelte
@@ -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));
-
- {t('import.rapport.titre')}
- {#if rapport.libelle === null}
- {t('import.rapport.rienEcrit')}
- {/if}
-
- {#each COMPTES as [nom, cle] (nom)}
-
- {format.nombre(rapport.resume[nom])}
- {t(cle, { n: rapport.resume[nom] })}
-
- {/each}
-
-
-
+
+
+
+ {#if rapport.libelle === null}
+
{t('import.rapport.rienEcrit')}
+ {/if}
+
+ {#each COMPTES as [nom, cle] (nom)}
+
+ {format.nombre(rapport.resume[nom])}
+ {t(cle, { n: rapport.resume[nom] })}
+
+ {/each}
+
+
+ {#if commandes !== null}
+
{@render commandes()}
+ {/if}
+
+
+
diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte
index d18ef4d..ce5546f 100644
--- a/src/interface/import/SectionImport.svelte
+++ b/src/interface/import/SectionImport.svelte
@@ -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 @@
-
- {t('coquille.section.import')}
- {t('import.consigne')}
-
-
-
+
+
+ {#snippet actions()}
+
+ {/snippet}
+
{#if source !== null}
{#key source}
-
+
+
+
+ {#if modele.global === null}
+
+ {t('import.mode.titre')}
+ {#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)}
+
+
+ {t(cle)}
+
+ {/each}
+
+ {/if}
+
+
+ {#if grille !== null}
+
+ {/if}
+ {#if modele.global === null}
+ {#key apercu}
+
+ {/key}
+ {/if}
+ {#if modele.refusees.length > 0}
+ enregistrerRefus(exporterRefus(apercu))}
+ />
+ {/if}
+
+
+ {#if enregistre !== null}
+
{t('import.refusEnregistres')} {enregistre}
+ {/if}
+
+
+
{/key}
{/if}
{#if rapport !== null}
-
-
- {#if rapport.resume.refusees.length > 0}
- enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
- />
- {/if}
- (rapport = null)} />
-
- {#if enregistre !== null}
-
{t('import.refusEnregistres')} {enregistre}
- {/if}
+
+ {#snippet commandes()}
+
+ {#if rapport.resume.refusees.length > 0}
+ enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))}
+ />
+ {/if}
+ (rapport = null)} />
+
+ {#if enregistre !== null}
+ {t('import.refusEnregistres')} {enregistre}
+ {/if}
+ {/snippet}
+
{/if}
{#if confirmation !== null}
@@ -257,27 +268,23 @@
surAnnuler={() => (confirmation = null)}
/>
{/if}
-
+
diff --git a/src/interface/import/modele.js b/src/interface/import/modele.js
index 72c2c18..6c6efd6 100644
--- a/src/interface/import/modele.js
+++ b/src/interface/import/modele.js
@@ -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) })));
diff --git a/src/interface/import/modele.test.js b/src/interface/import/modele.test.js
index 0e49482..1f9ee35 100644
--- a/src/interface/import/modele.test.js
+++ b/src/interface/import/modele.test.js
@@ -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'],
+ );
+ });
+});
diff --git a/src/interface/libelles/import.js b/src/interface/libelles/import.js
index 0335d2f..5a7ea9e 100644
--- a/src/interface/libelles/import.js
+++ b/src/interface/libelles/import.js
@@ -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'],
diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js
index 98e0a09..99f9547 100644
--- a/src/interface/libelles/participants.js
+++ b/src/interface/libelles/participants.js
@@ -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'],
diff --git a/src/interface/libelles/tables.js b/src/interface/libelles/tables.js
index 152db9e..05ec5d7 100644
--- a/src/interface/libelles/tables.js
+++ b/src/interface/libelles/tables.js
@@ -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'],
diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte
index f8050c7..7b8b4b0 100644
--- a/src/interface/participants/Fiche.svelte
+++ b/src/interface/participants/Fiche.svelte
@@ -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;
diff --git a/src/interface/participants/Formulaire.svelte b/src/interface/participants/Formulaire.svelte
index 4be701c..784dcca 100644
--- a/src/interface/participants/Formulaire.svelte
+++ b/src/interface/participants/Formulaire.svelte
@@ -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 @@
{t('participants.formulaire.titre')}
{#each CHAMPS as cle (cle)}
-
+
{t(cleDeColonne(cle))}
{#if cle === 'nom'}
{#if refusNom !== null}
-
(refusNom = null)} />
+
+ (refusNom = null)} />
+
{/if}
{:else if cle === 'appartenance'}
{#if orthographe !== null}
-
+
+
{t('participants.formulaire.orthographe')} {orthographe}
{t('participants.formulaire.reprendre')}
@@ -169,36 +188,49 @@
{/if}
{/each}
-
-
-
{t('participants.formulaire.ajouter')}
- {#if refusAjout !== null}
-
(refusAjout = null)} />
- {/if}
+
+
{t('participants.formulaire.ajouter')}
+ {#if refusAjout !== null}
+
+ (refusAjout = null)} />
+
+ {/if}
+
diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte
index 5e90c0a..3acb7a6 100644
--- a/src/interface/participants/Grille.svelte
+++ b/src/interface/participants/Grille.svelte
@@ -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'}
-
- {t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')}
+ {#if personne.exclu}
+
+ {:else}
+ {t('participants.grille.presente')}
+ {/if}
+ {:else if colonne === 'appartenance' && personne.appartenance !== null}
+
+
+
+ {:else if colonne === 'titrePressenti' && personne.titrePressenti !== null}
+
+
{:else}
{personne[colonne] ?? ''}
@@ -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;
}
diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js
index 6edb346..61b28bc 100644
--- a/src/interface/participants/Participants.navigateur.test.js
+++ b/src/interface/participants/Participants.navigateur.test.js
@@ -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);
+ });
+ });
+});
diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte
index 4555efd..770e843 100644
--- a/src/interface/participants/SectionParticipants.svelte
+++ b/src/interface/participants/SectionParticipants.svelte
@@ -2,10 +2,13 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
- {t('coquille.section.participants')}
-
-
-
-
-
- {t('participants.grille.filtre')}
-
-
- {#each niveaux as rang (rang)}
-
-
- {rang === 0 ? t('participants.groupe.grouperPar') : t('participants.groupe.puisPar', { niveau: rang + 1 })}
-
- choisirNiveau(rang, evenement.currentTarget.value)}
- >
- {t(rang === 0 ? 'participants.groupe.aucun' : 'participants.groupe.aucunSousGroupe')}
- {#each colonnesOffertes(groupement, rang) as colonne (colonne)}
- {t(cleDeGroupement(colonne))}
- {/each}
-
-
- {/each}
- {#if groupes !== null}
-
(replies = new Set(clesPremierNiveau(groupesEntiers)))}>
- {t('participants.groupe.toutReplier')}
-
-
(replies = new Set())}>
- {t('participants.groupe.toutDeplier')}
-
- {/if}
-
{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}
-
-
-
+
-
- {#if participants.length === 0}
-
{t('participants.grille.vide')}
- {:else if lignes.length === 0}
-
{t('participants.grille.aucunResultat')}
- {/if}
+
+ {#snippet actions()}
+
+ {/snippet}
+
+
+
+
+
+ {t('participants.grille.filtre')}
+
+
+ {#each niveaux as rang (rang)}
+
+
+ {rang === 0 ? t('participants.groupe.grouperPar') : t('participants.groupe.puisPar', { niveau: rang + 1 })}
+
+ choisirNiveau(rang, evenement.currentTarget.value)}
+ >
+ {t(rang === 0 ? 'participants.groupe.aucun' : 'participants.groupe.aucunSousGroupe')}
+ {#each colonnesOffertes(groupement, rang) as colonne (colonne)}
+ {t(cleDeGroupement(colonne))}
+ {/each}
+
+
+ {/each}
+ {#if groupes !== null}
+
(replies = new Set(clesPremierNiveau(groupesEntiers)))}>
+ {t('participants.groupe.toutReplier')}
+
+
(replies = new Set())}>
+ {t('participants.groupe.toutDeplier')}
+
+ {/if}
+
{t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}
+ {#if legende.length > 0}
+
+ {#each legende as { nom, rang, compte } (nom)}
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if participants.length === 0}
+ {t('participants.grille.vide')}
+ {:else if lignes.length === 0}
+ {t('participants.grille.aucunResultat')}
+ {/if}
+
{#if personneFiche !== null}
@@ -185,55 +210,63 @@
surAnnuler={() => (aSupprimer = null)}
/>
{/if}
-
+
diff --git a/src/interface/participants/entete.js b/src/interface/participants/entete.js
new file mode 100644
index 0000000..b671e0a
--- /dev/null
+++ b/src/interface/participants/entete.js
@@ -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
} 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;
+}
diff --git a/src/interface/participants/entete.test.js b/src/interface/participants/entete.test.js
new file mode 100644
index 0000000..bb38808
--- /dev/null
+++ b/src/interface/participants/entete.test.js
@@ -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');
+ });
+});
diff --git a/src/interface/tables/Defaut.svelte b/src/interface/tables/Defaut.svelte
index 0eaeb95..643e00f 100644
--- a/src/interface/tables/Defaut.svelte
+++ b/src/interface/tables/Defaut.svelte
@@ -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 })}
+
{t(entree.cle, entree.details)}
{#if action !== null}
+
{#if avertissement.numero !== null}
{t('tables.liste.table')} {avertissement.numero}
{/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;
+ }
diff --git a/src/interface/tables/ListeTables.svelte b/src/interface/tables/ListeTables.svelte
index e01e930..af42b37 100644
--- a/src/interface/tables/ListeTables.svelte
+++ b/src/interface/tables/ListeTables.svelte
@@ -2,10 +2,12 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
-
{t('tables.liste.titre')}
-
+
+ {#snippet actions()}
seance.executer('creerTable', { forme: 'ronde' })} />
seance.executer('creerTable', { forme: 'carree' })} />
-
+ {/snippet}
{#if tables.length === 0}
-
{t('tables.liste.aucune')}
+
{t('tables.liste.aucune')}
{:else}
{#each tables as table (table.id)}
@@ -136,8 +145,8 @@
{t('tables.liste.table')} {table.numero}
-
{t(cleForme(table.forme))}
-
{t(etat.cle)}
+
{t(cleForme(table.forme))}
+
executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })}
/>
- {#if complement !== null && complement.retires.length > 0}
-
- {#each complement.retires as retire, rang (rang)}
- {t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}{retire.nom} {/if}
- {/each}
-
- {/if}
- {#if complement !== null}
- {#each complement.avertissements as avertissement, rang (rang)}
-
-
{t(avertissement.code, avertissement.details)}
- {#if avertissement.noms.length > 0}
-
- {#each avertissement.noms as nom, rangNom (rangNom)}
- {nom}
- {/each}
-
- {/if}
-
- {/each}
- {/if}
{#if etat.realignable}
supprimer(table)} />
+ {#if complement !== null && complement.retires.length > 0}
+
+
+
+ {#each complement.retires as retire, rang (rang)}
+ {t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}{retire.nom} {/if}
+ {/each}
+
+
+ {/if}
+ {#if complement !== null}
+ {#each complement.avertissements as avertissement, rang (rang)}
+
+
+
+
{t(avertissement.code, avertissement.details)}
+ {#if avertissement.noms.length > 0}
+
+ {#each avertissement.noms as nom, rangNom (rangNom)}
+ {nom}
+ {/each}
+
+ {/if}
+
+
+ {/each}
+ {/if}
{/each}
{/if}
+
- {#if suppression !== null}
-
(suppression = null)}
- />
- {/if}
-
+{#if suppression !== null}
+ (suppression = null)}
+ />
+{/if}
diff --git a/src/interface/tables/SectionTables.svelte b/src/interface/tables/SectionTables.svelte
index 7021175..cee9ade 100644
--- a/src/interface/tables/SectionTables.svelte
+++ b/src/interface/tables/SectionTables.svelte
@@ -2,12 +2,14 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
- {t('coquille.section.tables')}
- {#if $seance.charge !== null}
-
-
-
-
+
+ {#if $seance.charge === null}
+
+ {:else}
+
+
+
+
+
+
{/if}
-
+