gestion_table_tournante_libre/src/interface/tables/modele.js
Mathieu Benoit 134e7850b7 [ADD] interface: event list, open event, participants, import, plan
The working screens of an evening. The home screen lists the events of
the working folder, asks its question, loads demos, copies, renames,
imports and deletes, and shows what the trash holds. An open event frames
its mode, offers Modifier, Débloquer and a confirmed lock takeover, and
shows its warnings. Participants get a grid, a form and a file; an import
goes through a preview and a report; tables, the default and tours have
their screens; the plan draws tables, chairs, empty seats and names.
Checked: 2342 node, 356 browser, 60 node-long; the Electron start check.

--- FR ---

[ADD] interface : liste, événement, participants, import, tables, plan

Les écrans de travail d'une soirée. L'accueil liste les événements du
dossier de travail, pose sa question, charge les démonstrations, copie,
renomme, importe et supprime, et montre ce que tient la corbeille. Un
événement ouvert encadre son mode, offre Modifier, Débloquer et une
reprise de verrou confirmée, et montre ses avertissements. Les
participants ont une grille, un formulaire et une fiche ; un import passe
par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ;
le plan dessine tables, chaises, sièges vides et noms.
Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron.

Assisted-by: Claude Opus 5.5
2026-10-07 08:12:27 -04:00

267 lines
11 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de la section des tables et des tours (§ 4, § 6, § 2.1 étapes 4
// et 5), pur : les composants de tables/ le câblent. Il lit la charge et les
// bilans d'A4 — bilanChangementDefaut, bilanTours, bilanSuppressionTable —
// et rend des clés de libellé et leurs détails, jamais un texte : la
// traduction les compose. Il ne refait aucun contrôle : un refus vient d'un
// bilan ou de la commande elle-même, essayée sur la charge sans rien écrire.
// Aucune fonction ne modifie ce qu'elle reçoit.
import { appliquerCommande } from '../../application/commandes.js';
import { nomAffiche } from '../../application/personnes.js';
const parNumero = (a, b) => a.numero - b.numero || a.id - b.id;
// Le numéro affiché de la table d'identifiant id parmi tables ; une table
// inconnue est une faute de l'appelant.
function numeroDe(tables, id) {
const table = tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table.numero;
}
// Le nom affiché du participant d'identifiant id ; une personne inconnue
// est une faute de l'appelant.
function nomDe(charge, id) {
const personne = charge.participants.find((candidate) => candidate.id === id);
if (personne === undefined) throw new TypeError(`participant inconnu : ${JSON.stringify(id)}`);
return nomAffiche(personne);
}
// La clé du nom de chaque forme de table (§ 4).
const FORMES = new Map([
['ronde', 'tables.forme.ronde'],
['carree', 'tables.forme.carree'],
]);
/** La clé du nom d'une forme de table ; RangeError pour une forme inconnue. */
export function cleForme(forme) {
if (!FORMES.has(forme)) throw new RangeError(`forme inconnue : ${JSON.stringify(forme)}`);
return FORMES.get(forme);
}
/** Les tables de la charge par numéro affiché croissant, à identifiant égal du plus petit. */
export function tablesParNumero(charge) {
return [...charge.tables].sort(parNumero);
}
/**
* L'état d'une table en texte : « suit le défaut » ou « surchargée », et sa
* capacité ; realignable, vrai pour une table surchargée, offre
* « Réaligner » (§ 6.1). Une table surchargée à la valeur du défaut a la
* même capacité qu'une table qui le suit, et un autre état.
*
* @param {{sieges: number|null}} table
* @param {number} siegesParDefaut
* @returns {{cle: string, capacite: number, realignable: boolean}}
*/
export function etatTable(table, siegesParDefaut) {
const surchargee = table.sieges !== null;
return {
cle: surchargee ? 'tables.etat.surchargee' : 'tables.etat.suitDefaut',
capacite: surchargee ? table.sieges : siegesParDefaut,
realignable: surchargee,
};
}
/**
* 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
* d'application — les tables qui suivent le défaut et ce qu'elles
* deviennent, puis les surchargées, qui ne changent pas — et celle du total.
* Refusé : une phrase d'en-tête, puis chaque refus de bilan.refus, sa table
* nommée par son numéro (numero null pour un refus de l'événement entier),
* et aucune forme d'application. Avec realigner, le bilan est celui de
* bilanChangementDefaut(…, {realigner: true}) : l'en-tête d'un refus dit
* que le réalignement est refusé, le changement seul restant possible.
*
* @param {ReturnType<import('../../application/bilans.js').bilanChangementDefaut>} bilan
* @param {Array<{id: number, numero: number}>} [tables] celles de la charge,
* qui donnent son numéro à une table qui refuse
* @param {{realigner?: boolean}} [options]
* @returns {Array<{cle: string, details: Object}>}
* @throws {TypeError} une table de bilan.refus absente de tables
*/
export function enonceDefaut(bilan, tables = [], { realigner = false } = {}) {
const { avant, apres } = bilan;
if (bilan.refus.length > 0) {
return [
{ cle: realigner ? 'tables.defaut.realignementRefuse' : 'tables.defaut.refusee', details: { avant, apres } },
...bilan.refus.map(({ table, code, details }) => ({
cle: 'tables.defaut.refusTable',
details: { numero: table === null ? null : numeroDe(tables, table), refus: { code, details } },
})),
];
}
return [
{ cle: 'tables.defaut.application', details: { suivent: bilan.suivent, avant, apres, surchargees: bilan.surchargees } },
{
cle: 'tables.defaut.total',
details: { totalAvant: bilan.totalAvant, totalApres: bilan.totalApres, participants: bilan.participants },
},
];
}
/**
* Le refus qu'un nouveau défaut reçoit sur son champ : celui du bilan qui
* ne nomme aucune table — moins de 2 places refusent pour l'événement
* entier —, ou null. Les refus d'une table s'énoncent (enonceDefaut).
*
* @param {{refus: Array<{table: number|null, code: string, details: Object}>}} bilan
* @returns {{code: string, details: Object}|null}
*/
export function refusDuChampDefaut(bilan) {
const refus = bilan.refus.find(({ table }) => table === null);
return refus === undefined ? null : { code: refus.code, details: refus.details };
}
/**
* Ce qu'un réalignement de toutes les tables efface : la surcharge de
* chaque table surchargée, par numéro de table (§ 6.1).
*
* @param {Object} charge
* @returns {Array<{cle: string, details: {numero: number, sieges: number}}>}
*/
export function surchargesPerdues(charge) {
return tablesParNumero(charge)
.filter(({ sieges }) => sieges !== null)
.map(({ numero, sieges }) => ({ cle: 'tables.defaut.surchargePerdue', details: { numero, sieges } }));
}
/**
* Vrai quand le changement de avant à apres tours détruit de la saisie :
* une réservation « tour désigné » au-delà, ou, pour une baisse, les tours
* retirés d'un placement engendré (§ 2.1 étape 5). Une hausse ne détruit
* rien : les placements qu'elle laisse en dérive restent entiers, et la
* séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9).
*
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @param {number} avant le nombre de tours enregistré
* @param {number} apres le nombre de tours demandé
* @returns {boolean}
*/
export const annonceTours = (bilan, avant, apres) =>
bilan.reservationsDetruites.length > 0 ||
(apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche));
// Les placements qu'un changement laisse en dérive : chaque proposition,
// puis le retenu.
const touches = (bilan) => [
...bilan.propositionsTouchees.map((id) => ({ cle: 'tables.touchee.proposition', details: { id } })),
...(bilan.retenuTouche ? [{ cle: 'tables.touchee.retenu', details: {} }] : []),
];
/**
* Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5) : chaque
* réservation détruite, sa personne et le numéro de sa table, dans l'ordre
* du bilan, puis les placements touchés.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanTours>} bilan
* @returns {Array<{cle: string, details: Object}>}
*/
export function elementsTours(charge, bilan) {
return [
...bilan.reservationsDetruites.map(({ participant, table, tour }) => ({
cle: 'tables.tours.reservation',
details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour },
})),
...touches(bilan),
];
}
/** Vrai quand supprimer la table détruit une réservation ou un titre. */
export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0;
/**
* Ce que nomme la fenêtre de la suppression d'une table (§ 6.2) : ses
* réservations, leur personne, leur portée et leur siège, puis ses titres,
* dans l'ordre du bilan ; puis les placements touchés. Rien pour une table
* nue, que la suppression ne demande pas de confirmer.
*
* @param {Object} charge
* @param {ReturnType<import('../../application/bilans.js').bilanSuppressionTable>} bilan
* @returns {Array<{cle: string, details: Object}>}
*/
export function elementsSuppression(charge, bilan) {
if (!annonceSuppression(bilan)) return [];
return [
...bilan.reservations.map(({ participant, portee, tour, siege }) => ({
cle: 'tables.suppression.reservation',
details: { nom: nomDe(charge, participant), portee, tour, siege },
})),
...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })),
...touches(bilan),
];
}
/**
* Les sièges qu'un refus SIEGES_RETIRES retire, nommés : chaque siège dans
* l'ordre du refus, son titre, ou le nom de la personne qui le réserve —
* le texte du refus désigne les personnes par identifiant. Sous des sièges
* non attribués, le refus compte ses titres sans les lister : ils ne le
* sont pas ici non plus. Rien pour un autre refus.
*
* @param {Object} charge
* @param {{code: string, details: Object}} refus
* @returns {Array<{cle: string, details: Object, nom: string|null}>}
*/
export function siegesRetiresNommes(charge, refus) {
if (refus.code !== 'SIEGES_RETIRES') return [];
const comptes = refus.details.titres !== undefined;
return refus.details.sieges
.filter(({ cause }) => !(comptes && cause === 'SIEGE_TITRE'))
.map((retire) =>
retire.cause === 'SIEGE_TITRE'
? { cle: 'tables.siegeRetire.titre', details: { siege: retire.siege, libelle: retire.libelle }, nom: null }
: {
cle: 'tables.siegeRetire.reserve',
details: { siege: retire.siege, suspendue: retire.suspendue },
nom: nomDe(charge, retire.participant),
},
);
}
/**
* Les avertissements d'un geste, nommés : numero, celui de la table qu'ils
* désignent — details.table, ou table pour un avertissement d'un bilan —,
* null sinon ; noms, les personnes de details.personnes dans leur ordre.
*
* @param {Object} charge
* @param {Array<{code: string, details: Object, table?: number}>} avertissements
* @returns {Array<{code: string, details: Object, numero: number|null, noms: string[]}>}
*/
export function avertissementsNommes(charge, avertissements) {
return avertissements.map(({ code, details, table }) => {
const id = details.table ?? table ?? null;
return {
code,
details,
numero: id === null ? null : numeroDe(charge.tables, id),
noms: (details.personnes ?? []).map((personne) => nomDe(charge, personne)),
};
});
}
/**
* Le refus que la commande nom lèverait sur la charge, essayée sans rien
* écrire, ou null quand elle passe. Les fenêtres l'appellent avant de
* s'ouvrir : une saisie que la commande refuse reçoit son refus sur le
* champ, et aucune fenêtre n'annonce ce qui ne s'appliquera pas.
*
* @param {Object} charge
* @param {string} nom
* @param {Object} args
* @returns {{code: string, details: Object}|null}
* @throws {TypeError} une commande inconnue, un argument de forme fausse
*/
export function refusDeCommande(charge, nom, args) {
try {
appliquerCommande(nom, charge, args);
return null;
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
return { code: erreur.code, details: erreur.details };
}
}