The operator now reaches tags, goals and priorities from the screens. A person's card adds and removes tags, the participant list carries a tag legend; each table takes an evening goal or one per round with its rule; the generation settings hold a keyboard-operable priority editor (move, join, split, restore, weights), the two optional criteria and the goal mode; the proposals table and its ranking sentence follow the order; the import screen reads a participants or a goals file. Removing a tag or a goal returns focus to a neighbouring control. Checked: node 4590, navigateur 942, node-long 124; captures republished. --- FR --- [ADD] écrans : étiquettes, objectifs des tables, priorités, import L'opérateur atteint étiquettes, objectifs et priorités depuis les écrans. La fiche ajoute et retire les étiquettes, la liste porte leur légende ; chaque table reçoit un objectif de soirée ou par tour avec sa règle ; les réglages de génération portent un éditeur des priorités utilisable au clavier (monter, joindre, séparer, rétablir, poids), les deux critères facultatifs et le mode des objectifs ; le tableau des propositions et sa phrase suivent l'ordre ; l'import lit un fichier de personnes ou d'objectifs. Retirer une étiquette ou un objectif rend le focus à côté. Vérifié : node 4590, navigateur 942, longues 124 ; captures republiées. Assisted-by: Claude Opus 5.5
395 lines
15 KiB
Svelte
395 lines
15 KiB
Svelte
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||
|
||
<script>
|
||
// La section d'un import, § 10.1, § 10.2, § 10.4 et § 2.1 étape 2 : des
|
||
// participants, ou des objectifs de table, selon la nature que
|
||
// l'opérateur coche avant l'aperçu — Participants par défaut. La nature
|
||
// ne se devine jamais de l'en-tête : un fichier lu sous la mauvaise se
|
||
// refuse, et l'opérateur la change. Un fichier choisi par le dialogue de
|
||
// la façade, un fichier déposé sur la fenêtre, ou un texte que le collage
|
||
// de la grille remet par ui.ouvrirImport, ouvre l'aperçu ; rien ne
|
||
// s'importe hors de lui. Le collage vient de la grille des participants,
|
||
// et coche leur nature.
|
||
//
|
||
// L'aperçu se calcule par apercevoirSelon (modele.js) contre la charge,
|
||
// sous la nature, le mode choisi et le choix des colonnes ambiguës : il
|
||
// n'écrit rien, et s'ouvre en lecture comme sur un plan bloqué. Changer
|
||
// de nature recalcule l'aperçu du même texte, le choix des colonnes et le
|
||
// mode repartant de rien. Seule « Importer » écrit, par seance.executer —
|
||
// importerParticipants ou importerObjectifs —, et son refus se pose à
|
||
// côté d'elle ; « Remplacer » passe d'abord par la fenêtre à confirmer,
|
||
// qui porte les quatre comptes de bilanRemplacement pour des
|
||
// participants, les deux de bilanRemplacementObjectifs — objectifs et
|
||
// tables — pour des objectifs, 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, et dont le contenu
|
||
// est le choix de la nature ; 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
|
||
// d'encodage ou de séparateur ne se force pas : l'opérateur ferme l'aperçu
|
||
// et corrige le fichier.
|
||
import { getContext, untrack } from 'svelte';
|
||
import { appliquerCommande } from '../../application/commandes.js';
|
||
import { bilanRemplacement, bilanRemplacementObjectifs, 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';
|
||
import Rapport from './Rapport.svelte';
|
||
import {
|
||
NATURES_D_IMPORT,
|
||
NATURE_PAR_DEFAUT,
|
||
apercevoirSelon,
|
||
avecEntetesDeLaGrille,
|
||
modeleApercu,
|
||
modePropose,
|
||
modesDImport,
|
||
} from './modele.js';
|
||
|
||
// La commande qu'applique « Importer », par nature.
|
||
const COMMANDES_DES_NATURES = new Map([
|
||
['participants', 'importerParticipants'],
|
||
['objectifs', 'importerObjectifs'],
|
||
]);
|
||
|
||
const t = getContext('t');
|
||
const seance = getContext('seance');
|
||
const facade = getContext('facade');
|
||
const ui = getContext('ui');
|
||
const uid = $props.id();
|
||
|
||
// La source de l'aperçu : { origine: 'fichier'|'collage', nom, encodage,
|
||
// texte, refusDecodage } ; texte null sous un refus de decoder.
|
||
let source = $state.raw(null);
|
||
// La nature cochée : ce que le fichier importe.
|
||
let nature = $state(NATURE_PAR_DEFAUT);
|
||
// { champ: rang } qui tranche une ambiguïté de l'en-tête.
|
||
let choix = $state.raw({});
|
||
let mode = $state('ajouter');
|
||
// Le dernier import appliqué : { nature, modele, source, apercu, resume, libelle }.
|
||
let rapport = $state.raw(null);
|
||
// La fenêtre de « Remplacer » ouverte : { titre, nature, apercu, depuis, elements }.
|
||
let confirmation = $state.raw(null);
|
||
// Le chemin où le CSV des refus vient d'être enregistré.
|
||
let enregistre = $state(null);
|
||
|
||
const charge = $derived($seance.charge ?? null);
|
||
const participants = $derived(charge?.participants ?? []);
|
||
const apercu = $derived(
|
||
source === null || source.refusDecodage !== null || charge === null
|
||
? null
|
||
: apercevoirSelon(nature, source.texte, charge, { choix, mode }),
|
||
);
|
||
const modele = $derived(
|
||
source === null ? null : modeleApercu({ nature, encodage: source.encodage, apercu, refusDecodage: source.refusDecodage }),
|
||
);
|
||
// Le texte relu dans l'ordre de la grille, offert à un collage seul.
|
||
const grille = $derived(
|
||
source?.origine === 'collage' && apercu !== null ? avecEntetesDeLaGrille(source.texte, apercu, nature) : null,
|
||
);
|
||
|
||
// Ce qui suit un aperçu neuf : le choix des colonnes repart de rien, le
|
||
// mode proposé suit la nature et la liste, le rapport précédent se
|
||
// retire.
|
||
function repartir() {
|
||
choix = {};
|
||
mode = modePropose(untrack(() => participants), untrack(() => nature));
|
||
rapport = null;
|
||
confirmation = null;
|
||
enregistre = null;
|
||
}
|
||
|
||
function ouvrirSource(nouvelle) {
|
||
source = nouvelle;
|
||
repartir();
|
||
}
|
||
|
||
// Une autre nature : l'aperçu ouvert se recalcule sur le même texte.
|
||
function choisirNature(nouvelle) {
|
||
nature = nouvelle;
|
||
repartir();
|
||
}
|
||
|
||
// Les octets d'un fichier : leur texte décodé, ou le refus de decoder,
|
||
// qui tient lieu d'aperçu. Toute autre erreur est une faute, qui remonte.
|
||
function lireFichier({ nom, octets }) {
|
||
try {
|
||
const { texte, encodage } = decoder(octets);
|
||
ouvrirSource({ origine: 'fichier', nom, encodage, texte, refusDecodage: null });
|
||
} catch (erreur) {
|
||
if (typeof erreur?.code !== 'string') throw erreur;
|
||
ouvrirSource({ origine: 'fichier', nom, encodage: null, texte: null, refusDecodage: { code: erreur.code, details: erreur.details } });
|
||
}
|
||
}
|
||
|
||
async function choisirFichier() {
|
||
const choisi = await facade.choisirCsv();
|
||
if (choisi !== null) lireFichier(choisi);
|
||
}
|
||
|
||
// Un fichier déposé sur la fenêtre ouvre son aperçu, comme un fichier
|
||
// choisi ; un glissement sans fichier n'est pas retenu.
|
||
function survoler(evenement) {
|
||
if (evenement.dataTransfer?.types?.includes('Files')) evenement.preventDefault();
|
||
}
|
||
|
||
async function deposer(evenement) {
|
||
const fichier = evenement.dataTransfer?.files?.[0];
|
||
if (fichier === undefined) return;
|
||
evenement.preventDefault();
|
||
lireFichier({ nom: fichier.name, octets: new Uint8Array(await fichier.arrayBuffer()) });
|
||
}
|
||
|
||
// Le texte que le collage de la grille remet : pris une fois, il ouvre
|
||
// l'aperçu d'un collage, sans encodage.
|
||
$effect(() => {
|
||
if ($ui.importEnAttente === null) return;
|
||
untrack(() => {
|
||
const pris = ui.prendreImport();
|
||
if (pris === null) return;
|
||
nature = 'participants';
|
||
ouvrirSource({ origine: 'collage', nom: null, encodage: null, texte: pris.texte, refusDecodage: null });
|
||
});
|
||
});
|
||
|
||
function choisirColonne(champ, rang) {
|
||
choix = { ...choix, [champ]: rang };
|
||
}
|
||
|
||
function lireDansLOrdreDeLaGrille() {
|
||
source = { ...source, texte: grille };
|
||
choix = {};
|
||
}
|
||
|
||
function fermerApercu() {
|
||
source = null;
|
||
enregistre = null;
|
||
}
|
||
|
||
// L'aperçu appliqué par la séance, par la commande de sa nature, dans son
|
||
// mode ; le rapport lit le résumé rendu, et l'aperçu se ferme.
|
||
async function appliquer(applique, sonMode, depuis, saNature) {
|
||
const { libelle, resume } = await seance.executer(COMMANDES_DES_NATURES.get(saNature), { apercu: applique, mode: sonMode });
|
||
rapport = {
|
||
nature: saNature,
|
||
modele: modeleApercu({ nature: saNature, encodage: depuis.encodage, apercu: applique }),
|
||
source: { origine: depuis.origine, nom: depuis.nom },
|
||
apercu: applique,
|
||
resume,
|
||
libelle,
|
||
};
|
||
source = null;
|
||
enregistre = null;
|
||
}
|
||
|
||
// « Importer » : l'aperçu montré, dans le mode choisi. « Remplacer »
|
||
// ouvre d'abord sa fenêtre, sauf quand le geste serait refusé : le refus
|
||
// de la séance, celui de son état, puis celui de la commande essayée sur
|
||
// la charge sans rien écrire (appliquerCommande, le calcul même que
|
||
// seance.executer applique) — une ambiguïté non tranchée, AMBIGUITE —
|
||
// se posent à côté de la commande, et la fenêtre n'annonce jamais une
|
||
// destruction qui n'aura pas lieu. Le refus vaut pour l'aperçu qui l'a
|
||
// reçu : un choix de colonne ou de mode, qui en fait un autre, le retire
|
||
// (la commande se recrée sous {#key apercu}).
|
||
async function importer() {
|
||
const applique = apercu;
|
||
const sonMode = mode;
|
||
const depuis = source;
|
||
const saNature = nature;
|
||
if (sonMode !== 'remplacer') {
|
||
await appliquer(applique, sonMode, depuis, saNature);
|
||
return;
|
||
}
|
||
const { refus, charge: courante } = seance.etat();
|
||
if (refus !== null) throw refus;
|
||
appliquerCommande(COMMANDES_DES_NATURES.get(saNature), courante, { apercu: applique, mode: sonMode });
|
||
confirmation = { nature: saNature, apercu: applique, depuis, ...annonceDuRemplacement(saNature, courante) };
|
||
}
|
||
|
||
// Le titre de la fenêtre de « Remplacer » et ce qu'elle nomme détruit :
|
||
// les personnes, réservations, titres pourvus et propositions pour des
|
||
// participants ; les objectifs et les tables qui en portent pour des
|
||
// objectifs.
|
||
function annonceDuRemplacement(saNature, courante) {
|
||
if (saNature === 'objectifs') {
|
||
const bilan = bilanRemplacementObjectifs(courante);
|
||
return {
|
||
titre: 'import.remplacer.titreObjectifs',
|
||
elements: [t('import.remplacer.objectifs', { n: bilan.objectifs }), t('import.remplacer.tables', { n: bilan.tables })],
|
||
};
|
||
}
|
||
const bilan = bilanRemplacement(courante);
|
||
return {
|
||
titre: 'import.remplacer.titre',
|
||
elements: [
|
||
t('import.remplacer.participants', { n: bilan.participants }),
|
||
t('import.remplacer.reservations', { n: bilan.reservations }),
|
||
t('import.remplacer.titres', { n: bilan.titresPourvus }),
|
||
t('import.remplacer.propositions', { n: bilan.propositions }),
|
||
],
|
||
};
|
||
}
|
||
|
||
async function confirmerRemplacement() {
|
||
await appliquer(confirmation.apercu, 'remplacer', confirmation.depuis, confirmation.nature);
|
||
confirmation = null;
|
||
}
|
||
|
||
// Le CSV des refus, remis à la façade sous ‹base›-refus.csv.
|
||
async function enregistrerRefus(octets) {
|
||
enregistre = await facade.enregistrerSous(`${seance.etat().evenement.base}-refus.csv`, octets);
|
||
}
|
||
</script>
|
||
|
||
<svelte:window ondragover={survoler} ondrop={deposer} />
|
||
|
||
<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}
|
||
<fieldset class="natures" data-nature-import>
|
||
<legend>{t('import.nature.titre')}</legend>
|
||
{#each NATURES_D_IMPORT as { nature: valeur, cle } (valeur)}
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name={`${uid}-nature`}
|
||
value={valeur}
|
||
checked={nature === valeur}
|
||
onchange={() => choisirNature(valeur)}
|
||
/>
|
||
<span>{t(cle)}</span>
|
||
</label>
|
||
{/each}
|
||
</fieldset>
|
||
</Carte>
|
||
|
||
{#if source !== null}
|
||
{#key source}
|
||
<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 modesDImport(nature) 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}>
|
||
{#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}
|
||
<Confirmation
|
||
titre={confirmation.titre}
|
||
elements={confirmation.elements}
|
||
surConfirmer={confirmerRemplacement}
|
||
surAnnuler={() => (confirmation = null)}
|
||
/>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.import {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--espace-4);
|
||
}
|
||
|
||
.contenu {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--espace-3);
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
|
||
.commandes {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
gap: var(--espace-2);
|
||
}
|
||
|
||
.modes,
|
||
.natures {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--espace-3);
|
||
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;
|
||
}
|
||
|
||
/* Le choix de la nature et celui du mode se font aussi au clavier : le
|
||
bouton radio qui a le focus porte l'anneau de l'application. */
|
||
input:focus-visible {
|
||
outline: 2px solid var(--couleur-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
</style>
|