@mostajs/flotte-ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ // @mostajs/flotte-ui — les formulaires d'écriture (planches B à F) : rendu POST et LECTEURS. Fonctions PURES.
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ //
4
+ // Le module n'écrit rien. Un lecteur transforme le corps d'un formulaire en ARGUMENTS de l'API de @mostajs/flotte et
5
+ // signale les champs mal formés (requis absent, nombre ou date illisible) ; les RÈGLES restent au moteur (motif de
6
+ // plaque, doublon, recul du compteur, transition permise). L'application appelle le moteur ; s'il refuse, elle rend
7
+ // le formulaire REMPLI avec `refus` : erreurDeFlotte place l'erreur sous son champ.
8
+ import { esc } from '@mostajs/app-shell-ui';
9
+ import { CODES, TRANSITIONS, regles as R } from '@mostajs/flotte';
10
+ import { contexte, lienFiche, capacite, badgeStatut, bandeauKo, texteRefus, bouton } from './commun.js';
11
+
12
+ const gele = (o) => { Object.values(o).forEach((v) => v && typeof v === 'object' && gele(v)); return Object.freeze(o); };
13
+
14
+ // ── Lecture des valeurs saisies ──
15
+ const brut = (v) => (v === undefined || v === null ? '' : typeof v === 'string' ? v.trim() : v);
16
+ /** « 18 500,00 » → 18500 ; NaN si illisible. */
17
+ export function nombre(s) {
18
+ const t = String(s).replace(/[\s  ]/g, '').replace(',', '.');
19
+ return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : NaN;
20
+ }
21
+ /** « 2026-03-15 » ou « 15/03/2026 » → « 2026-03-15 » ; null si ce n'est pas un jour réel. */
22
+ export function jour(s) {
23
+ const t = String(s).trim();
24
+ const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(t);
25
+ const iso = m ? `${m[3]}-${m[2]}-${m[1]}` : t;
26
+ return R.estJour(iso) ? iso : null;
27
+ }
28
+ const vrai = (v) => v === true || ['on', '1', 'true', 'oui'].includes(String(v).trim().toLowerCase());
29
+
30
+ /** Lit un champ selon sa forme ; rend [valeur] ou [undefined, codeErreur] ; un champ vide non requis est omis. */
31
+ function lireChamp(forme, v, requis) {
32
+ if (forme === 'booleen') return [vrai(v)];
33
+ const b = brut(v);
34
+ if (b === '' || (forme === 'fichier' && typeof b === 'object' && !b.nom)) return requis ? [undefined, 'requis'] : [undefined];
35
+ if (forme === 'nombre') { const n = nombre(b); return Number.isFinite(n) ? [n] : [undefined, 'nombre']; }
36
+ if (forme === 'date') { const d = jour(b); return d ? [d] : [undefined, 'date']; }
37
+ return [b];
38
+ }
39
+
40
+ // ── Les refus du moteur, placés sous leur champ ──
41
+ const CHAMP_DE = Object.freeze({
42
+ IMMATRICULATION: 'immatriculation', DOUBLON: 'immatriculation', TYPE: 'type', ATTRIBUT: 'attributs', RECUL: 'km', MOTIF: 'motif', CONDUCTEUR_OCCUPE: 'conducteur',
43
+ });
44
+ /** Le champ où rendre un refus du moteur : { champ, code } ; champ null = bandeau général ; code null = pas une ErreurFlotte. */
45
+ export function erreurDeFlotte(e) {
46
+ if (!e || e.name !== 'ErreurFlotte' || typeof e.code !== 'string') return { champ: null, code: null };
47
+ return { champ: CHAMP_DE[e.code] ?? null, code: e.code };
48
+ }
49
+ /** Les erreurs à afficher : celles du lecteur, plus le refus du moteur ; et le refus qui n'a pas de champ. */
50
+ function erreursEtRefus(erreurs = {}, refus) {
51
+ const e = { ...erreurs };
52
+ if (!refus) return { e, general: null };
53
+ const { champ, code } = erreurDeFlotte(refus);
54
+ if (champ) { e[champ] = code; return { e, general: null }; }
55
+ return { e, general: refus };
56
+ }
57
+
58
+ // ── Le rendu d'un champ : libellé, contrôle, aide, erreur SOUS le champ ──
59
+ function texteErreur(x, code) {
60
+ return ['requis', 'nombre', 'date', 'choix'].includes(code) ? x.tx(`formulaire.${code}`) : x.lib('erreur', code);
61
+ }
62
+ function champ(x, { nom, libelle, forme = 'texte', valeur, requis = false, erreur, options = [], aide, suffixe, attrs = '', cache = false }) {
63
+ const id = `fl-${nom.replace(/[^\w-]/g, '-')}`;
64
+ const aria = erreur ? ` aria-invalid="true" aria-describedby="${id}-err"` : '';
65
+ const req = requis ? ' required' : '';
66
+ const v = valeur === undefined || valeur === null ? '' : String(valeur);
67
+ let ctrl;
68
+ if (forme === 'choix') {
69
+ ctrl = `<select name="${esc(nom)}" id="${id}"${req}${aria}${attrs}>${options.map(([val, l]) => `<option value="${esc(val)}"${String(val) === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
70
+ } else if (forme === 'long') {
71
+ ctrl = `<textarea name="${esc(nom)}" id="${id}" rows="3"${req}${aria}${attrs}>${esc(v)}</textarea>`;
72
+ } else if (forme === 'booleen') {
73
+ ctrl = `<input type="checkbox" name="${esc(nom)}" id="${id}" value="on"${vrai(valeur) ? ' checked' : ''}${aria}${attrs}>`;
74
+ } else if (forme === 'fichier') {
75
+ ctrl = `<input type="file" name="${esc(nom)}" id="${id}"${req}${aria}${attrs}>`;
76
+ } else {
77
+ const type = forme === 'date' ? 'date' : 'text';
78
+ const im = forme === 'nombre' ? ' inputmode="decimal"' : '';
79
+ ctrl = `<input type="${type}" name="${esc(nom)}" id="${id}" value="${esc(v)}"${im}${req}${aria}${attrs}>`;
80
+ }
81
+ if (suffixe) ctrl = `<span class="fl-suffixe">${ctrl}<span>${esc(suffixe)}</span></span>`;
82
+ const etiquette = `<label for="${id}"${cache ? ' class="fl-sr"' : ''}>${esc(libelle)}${requis ? ' <span class="fl-requis" aria-hidden="true">*</span>' : ''}</label>`;
83
+ return `<div class="fl-champ${forme === 'booleen' ? ' fl-case' : ''}${erreur ? ' fl-erreur-champ' : ''}">${forme === 'booleen' ? `${ctrl} ${etiquette}` : `${etiquette}${ctrl}`}`
84
+ + `${erreur ? `<p class="fl-erreur" id="${id}-err">${x.k.icone('alerte', 14)} ${esc(texteErreur(x, erreur))}</p>` : ''}${aide ? `<p class="fl-aide">${esc(aide)}</p>` : ''}</div>`;
85
+ }
86
+ const groupe = (n, titre, corps, classe = '') => `<fieldset class="fl-groupe ${classe}"><legend><span class="fl-num">${n}</span> ${esc(titre)}</legend>${corps}</fieldset>`;
87
+ const boutons = (x, annuler, libelle, classe = '') => `<div class="fl-boutons">${annuler ? `<a class="${bouton(x, 'fl-secondaire')}" href="${esc(annuler)}">${esc(x.tx('action.annuler'))}</a>` : ''}`
88
+ + `<button type="submit" class="${bouton(x, classe)}">${esc(libelle)}</button></div>`;
89
+ const recap = (x, f) => (f ? `<div class="fl-recap"><span class="fl-vignette">${x.k.icone('camion', 32)}</span><div><b dir="ltr">${esc(f.plaque)}</b><br>${esc(x.lib('type', f.vehicule.type))} · ${esc(capacite(x, f.capacite))}</div><div>${badgeStatut(x, f.vehicule.statut)}</div></div>` : '');
90
+
91
+ // ── Véhicule : nouveau et modifier (16.39, planche F) ───────────────────────────────────────────────────────
92
+ /** Les attributs déclarés, par type ; et l'union (le formulaire sans script les montre tous, le lecteur ne garde que ceux du type). */
93
+ const attributsDe = (declarations, type) => Object.entries(declarations.types?.[type]?.attributs || {});
94
+ const tousAttributs = (declarations) => {
95
+ const m = new Map();
96
+ for (const t of Object.values(declarations.types || {})) for (const [a, forme] of Object.entries(t.attributs || {})) if (!m.has(a)) m.set(a, forme);
97
+ return [...m];
98
+ };
99
+ const libelleAttribut = (x, a) => { const l = x.tx(`formulaire.attribut.${a}`); return l === `formulaire.attribut.${a}` ? a : l; };
100
+
101
+ /**
102
+ * Le formulaire « Nouveau véhicule » ou « Modifier » (multipart : il porte les pièces). Rend { titre, sousTitre, corps }.
103
+ * vm : { mode: 'nouveau'|'modifier', vehicule? (lu par flotte.lire), declarations, sites, valeurs?, erreurs?, refus? }
104
+ * opts : ceux du rendu, et liens.enregistrer (l'action POST), liens.base (Annuler).
105
+ */
106
+ export function rendreFormulaireVehicule(vm, opts = {}) {
107
+ const x = contexte({ ...opts, declarations: vm.declarations || opts.declarations });
108
+ const D = vm.declarations || {}, sites = vm.sites || {}, nouveau = vm.mode !== 'modifier', v = vm.vehicule || null;
109
+ const base = nouveau ? { mode: 'propre' } : {
110
+ immatriculation: v.immatriculation, chassis: v.chassis ?? '', miseEnService: v.miseEnService ?? '', site: v.site ?? '', mode: v.mode ?? 'propre',
111
+ ...Object.fromEntries(Object.entries(v.attributs || {}).map(([a, n]) => [`attributs.${a}`, n])),
112
+ };
113
+ const val = { ...base, ...(vm.valeurs || {}) };
114
+ const { e, general } = erreursEtRefus(vm.erreurs, vm.refus);
115
+ const C = (o) => champ(x, { ...o, valeur: val[o.nom], erreur: e[o.nom] });
116
+ const types = Object.keys(D.types || {});
117
+ const typeChoisi = nouveau ? val.type : v.type;
118
+ const categories = types.map((t) => `${x.lib('type', t)} : ${D.types[t].categoriePermis ?? '—'}`).join(' · ');
119
+ const attributs = nouveau ? tousAttributs(D) : attributsDe(D, v.type);
120
+ const ident = [
121
+ C({ nom: 'immatriculation', libelle: x.tx('formulaire.immatriculation'), requis: true, attrs: ' dir="ltr" autocomplete="off"' }),
122
+ nouveau ? C({ nom: 'type', libelle: x.tx('formulaire.type'), forme: 'choix', requis: true, options: [['', x.tx('formulaire.choisir')], ...types.map((t) => [t, x.lib('type', t)])] })
123
+ : `<div class="fl-champ"><span class="fl-etiquette">${esc(x.tx('formulaire.type'))}</span><b>${esc(x.lib('type', v.type))}</b></div>`,
124
+ `<div class="fl-champ"><span class="fl-etiquette">${esc(x.tx('formulaire.categorie'))}</span><output>${esc(typeChoisi ? D.types?.[typeChoisi]?.categoriePermis ?? '—' : '—')}</output>`
125
+ + `<p class="fl-aide">${esc(x.tx('formulaire.categorieAide', { liste: categories }))}</p></div>`,
126
+ ...attributs.map(([a, forme]) => (forme === 'booleen'
127
+ ? C({ nom: `attributs.${a}`, libelle: libelleAttribut(x, a), forme: 'booleen' })
128
+ : C({ nom: `attributs.${a}`, libelle: libelleAttribut(x, a), forme: forme === 'nombre' ? 'nombre' : 'texte' }))),
129
+ C({ nom: 'chassis', libelle: x.tx('info.chassis'), attrs: ' dir="ltr"' }),
130
+ C({ nom: 'miseEnService', libelle: x.tx('info.miseEnService'), forme: 'date' }),
131
+ ].join('');
132
+ const regions = [...new Set(Object.values(sites).map((s) => s.region).filter(Boolean))].sort();
133
+ const regionChoisie = val.region || sites[val.site]?.region || '';
134
+ const stations = `<div class="fl-champ${e.site ? ' fl-erreur-champ' : ''}"><label for="fl-site">${esc(x.tx('formulaire.station'))} <span class="fl-requis" aria-hidden="true">*</span></label>`
135
+ + `<select name="site" id="fl-site" required${e.site ? ' aria-invalid="true" aria-describedby="fl-site-err"' : ''}><option value="">${esc(x.tx('formulaire.choisir'))}</option>`
136
+ + regions.map((r) => `<optgroup label="${esc(r)}">${Object.entries(sites).filter(([, s]) => s.region === r).map(([code, s]) => `<option value="${esc(code)}"${code === val.site ? ' selected' : ''}>${esc(code)} — ${esc(s.libelle ?? code)}</option>`).join('')}</optgroup>`).join('')
137
+ + Object.entries(sites).filter(([, s]) => !s.region).map(([code, s]) => `<option value="${esc(code)}"${code === val.site ? ' selected' : ''}>${esc(code)} — ${esc(s.libelle ?? code)}</option>`).join('')
138
+ + `</select>${e.site ? `<p class="fl-erreur" id="fl-site-err">${x.k.icone('alerte', 14)} ${esc(texteErreur(x, e.site))}</p>` : ''}</div>`;
139
+ const modes = `<fieldset class="fl-champ"><legend>${esc(x.tx('formulaire.mode'))}</legend>${CODES.mode.map((m) => `<label class="fl-radio"><input type="radio" name="mode" value="${esc(m)}"${m === (val.mode || 'propre') ? ' checked' : ''}> ${esc(x.lib('mode', m))}</label>`).join('')}</fieldset>`;
140
+ const rattachement = `${champ(x, { nom: 'region', libelle: x.tx('formulaire.region'), forme: 'choix', valeur: regionChoisie, options: [['', x.tx('formulaire.choisir')], ...regions.map((r) => [r, r])] })}${stations}${modes}`;
141
+ let suite = '';
142
+ if (nouveau) {
143
+ const compteur = C({ nom: 'kmDepart', libelle: x.tx('formulaire.kmDepart'), forme: 'nombre', requis: true, suffixe: 'km', aide: x.tx('formulaire.kmDepartAide') });
144
+ const lignes = Object.entries(D.echeances || {}).map(([t, d]) => {
145
+ const n = (k) => `echeance.${t}.${k}`;
146
+ const cell = (k, forme) => champ(x, { nom: n(k), libelle: `${x.lib('echeance', t)} — ${x.tx(`formulaire.champ.${k}`)}`, forme, valeur: val[n(k)], erreur: e[n(k)], cache: true });
147
+ return `<tr><th scope="row">${esc(x.lib('echeance', t))}${d.obligatoire ? ` ${x.k.badge(x.tx('documents.obligatoire'), 'rouge')}` : ''}</th><td>${cell('debut', 'date')}</td><td>${cell('fin', 'date')}</td><td>${cell('piece', 'fichier')}</td></tr>`;
148
+ }).join('');
149
+ const docs = lignes ? `<div class="fl-defile"><table class="${esc(x.c.tableau)} fl-docs-form"><thead><tr><th scope="col">${esc(x.tx('formulaire.document'))}</th><th scope="col">${esc(x.tx('formulaire.champ.debut'))}</th><th scope="col">${esc(x.tx('formulaire.champ.fin'))}</th><th scope="col">${esc(x.tx('formulaire.champ.piece'))}</th></tr></thead><tbody>${lignes}</tbody></table></div>` : '';
150
+ suite = `<div class="fl-form-bas">${groupe(3, x.tx('formulaire.compteur'), compteur)}${groupe(4, x.tx('formulaire.documents'), docs, 'fl-large-groupe')}</div>`;
151
+ }
152
+ const action = (x.liens.enregistrer ?? '');
153
+ const annuler = nouveau ? x.liens.base : (x.liens.fiche ? x.liens.fiche(v.id, '') : x.liens.base);
154
+ const corps = `<div class="fl-ecran fl-formulaire">${general ? bandeauKo(x, esc(texteRefus(x, general))) : ''}${e.attributs ? bandeauKo(x, esc(texteErreur(x, e.attributs))) : ''}`
155
+ + `<form method="post" action="${esc(action)}" enctype="multipart/form-data" novalidate><div class="fl-form-haut">${groupe(1, x.tx('formulaire.identification'), ident)}${groupe(2, x.tx('formulaire.rattachement'), rattachement)}</div>`
156
+ + `${suite}${boutons(x, annuler, nouveau ? x.tx('formulaire.inscrire') : x.tx('action.enregistrer'))}</form></div>`;
157
+ return {
158
+ titre: nouveau ? x.tx('formulaire.nouveau') : x.tx('formulaire.modifier', { plaque: v.immatriculation }),
159
+ sousTitre: nouveau ? x.tx('formulaire.nouveauSous') : x.tx('formulaire.modifierSous'), corps,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Lit « Nouveau véhicule ». Rend { donnees, erreurs } : donnees = { code, immatriculation, type, attributs, chassis,
165
+ * miseEnService, site, mode, kmDepart, echeances: [{ type, debut, fin?, piece? }] } — inscrire(donnees), puis
166
+ * relever(kmDepart) et poserEcheance pour chaque échéance. Le code est celui saisi, sinon la plaque sans espaces.
167
+ */
168
+ export function lireFormulaireVehicule(corps = {}, { declarations = {} } = {}) {
169
+ const erreurs = {}, d = {};
170
+ const imm = brut(corps.immatriculation);
171
+ if (!imm) erreurs.immatriculation = 'requis'; else d.immatriculation = String(imm).replace(/\s+/g, ' ');
172
+ const type = brut(corps.type);
173
+ const declare = type && declarations.types?.[type];
174
+ if (!declare) erreurs.type = 'requis'; else d.type = type;
175
+ d.code = brut(corps.code) || (d.immatriculation ? d.immatriculation.replace(/[\s-]/g, '') : undefined);
176
+ if (d.code === undefined) delete d.code;
177
+ d.attributs = {};
178
+ for (const [a, forme] of declare ? attributsDe(declarations, type) : []) {
179
+ const [val, err] = lireChamp(forme === 'nombre' ? 'nombre' : forme === 'booleen' ? 'booleen' : 'texte', corps[`attributs.${a}`], false);
180
+ if (err) erreurs[`attributs.${a}`] = err; else if (val !== undefined) d.attributs[a] = val;
181
+ }
182
+ d.chassis = brut(corps.chassis) || null;
183
+ const [mes, errMes] = lireChamp('date', corps.miseEnService, false);
184
+ if (errMes) erreurs.miseEnService = errMes; else d.miseEnService = mes ?? null;
185
+ const site = brut(corps.site);
186
+ if (!site) erreurs.site = 'requis'; else d.site = site;
187
+ const mode = brut(corps.mode) || 'propre';
188
+ if (!CODES.mode.includes(mode)) erreurs.mode = 'choix'; else d.mode = mode;
189
+ const [km, errKm] = lireChamp('nombre', corps.kmDepart, true);
190
+ if (errKm) erreurs.kmDepart = errKm; else d.kmDepart = km;
191
+ d.echeances = [];
192
+ for (const t of Object.keys(declarations.echeances || {})) {
193
+ const n = (k) => corps[`echeance.${t}.${k}`];
194
+ if (brut(n('debut')) === '') continue;
195
+ const e = { type: t };
196
+ for (const [k, forme] of [['debut', 'date'], ['fin', 'date'], ['piece', 'fichier']]) {
197
+ const [val, err] = lireChamp(forme, n(k), k === 'debut');
198
+ if (err) erreurs[`echeance.${t}.${k}`] = err; else if (val !== undefined) e[k] = val;
199
+ }
200
+ d.echeances.push(e);
201
+ }
202
+ return { donnees: d, erreurs };
203
+ }
204
+
205
+ /**
206
+ * Lit « Modifier » : seuls les champs PRÉSENTS dans le corps et DIFFÉRENTS du véhicule (lu par flotte.lire), au
207
+ * format de flotte.modifier(id, champs). Les attributs changés seuls (modifier les fusionne). { donnees, erreurs }.
208
+ */
209
+ export function lireModification(corps = {}, vehicule, { declarations = {} } = {}) {
210
+ const erreurs = {}, d = {};
211
+ const a = (k) => Object.prototype.hasOwnProperty.call(corps, k);
212
+ if (a('immatriculation')) {
213
+ const imm = String(brut(corps.immatriculation)).replace(/\s+/g, ' ');
214
+ if (!imm) erreurs.immatriculation = 'requis'; else if (imm.toUpperCase() !== vehicule.immatriculation) d.immatriculation = imm;
215
+ }
216
+ const attributs = {};
217
+ for (const [nom, forme] of attributsDe(declarations, vehicule.type)) {
218
+ if (!a(`attributs.${nom}`)) continue;
219
+ const [val, err] = lireChamp(forme === 'nombre' ? 'nombre' : forme === 'booleen' ? 'booleen' : 'texte', corps[`attributs.${nom}`], false);
220
+ if (err) erreurs[`attributs.${nom}`] = err; else if (val !== undefined && val !== vehicule.attributs?.[nom]) attributs[nom] = val;
221
+ }
222
+ if (Object.keys(attributs).length) d.attributs = attributs;
223
+ if (a('chassis')) { const c = brut(corps.chassis) || null; if (c !== (vehicule.chassis ?? null)) d.chassis = c; }
224
+ if (a('miseEnService')) {
225
+ const [m, err] = lireChamp('date', corps.miseEnService, false);
226
+ if (err) erreurs.miseEnService = err; else if ((m ?? null) !== (vehicule.miseEnService ?? null)) d.miseEnService = m ?? null;
227
+ }
228
+ if (a('site')) { const s = brut(corps.site); if (!s) erreurs.site = 'requis'; else if (s !== vehicule.site) d.site = s; }
229
+ if (a('mode')) { const m = brut(corps.mode); if (!CODES.mode.includes(m)) erreurs.mode = 'choix'; else if (m !== vehicule.mode) d.mode = m; }
230
+ return { donnees: d, erreurs };
231
+ }
232
+
233
+ // ── Changement de statut et réforme (16.36) ─────────────────────────────────────────────────────────────────
234
+ /**
235
+ * Le formulaire d'une transition, pour la fiche `f` (construireFiche). Motif obligatoire si la transition l'exige ;
236
+ * la réforme passe par une confirmation (définitif). Une transition impossible depuis le statut courant : refus.
237
+ * opts : ceux du rendu, plus { valeurs?, erreurs?, refus?, action? } ; à défaut d'action, liens.statut(id, transition).
238
+ */
239
+ export function rendreFormulaireStatut(f, transition, opts = {}) {
240
+ const x = contexte(opts), id = f.vehicule.id;
241
+ const t = f.transitions.find((y) => y.id === transition);
242
+ const titre = x.tx('formulaire.statutTitre', { transition: x.lib('transition', transition), plaque: f.plaque });
243
+ if (!t) return { titre, corps: `<div class="fl-ecran fl-formulaire">${bandeauKo(x, esc(x.lib('erreur', 'TRANSITION')))}${recap(x, f)}</div>` };
244
+ const { e, general } = erreursEtRefus(opts.erreurs, opts.refus);
245
+ const val = opts.valeurs || {};
246
+ const action = opts.action ?? (x.liens.statut ? x.liens.statut(id, transition) : '');
247
+ const reforme = transition === 'reformer';
248
+ const dernier = [...(f.vehicule.historique || [])].reverse().find((h) => h.vers === f.vehicule.statut && h.motif);
249
+ const confirmer = reforme ? `<div class="fl-confirmer" role="note">${x.k.icone('alerte', 24)}<div><b>${esc(x.tx('formulaire.reformerQuestion'))}</b><p class="fl-retard">${esc(x.tx('formulaire.definitif'))}</p>`
250
+ + `<p class="fl-doux">${esc(x.tx('formulaire.definitifAide'))}</p></div></div>` : '';
251
+ const actuel = `<div class="fl-champ"><span class="fl-etiquette">${esc(x.tx('formulaire.statutActuel'))}</span>${badgeStatut(x, f.vehicule.statut)}${dernier ? `<p class="fl-doux">« ${esc(dernier.motif)} »</p>` : ''}</div>`;
252
+ const motif = champ(x, { nom: 'motif', libelle: x.tx('formulaire.motif'), forme: 'long', requis: t.motif, valeur: val.motif, erreur: e.motif, aide: x.tx('formulaire.motifAide') });
253
+ const corps = `<div class="fl-ecran fl-formulaire">${general ? bandeauKo(x, esc(texteRefus(x, general))) : ''}${confirmer}`
254
+ + `<form method="post" action="${esc(action)}" class="fl-carte">${recap(x, f)}${actuel}${motif}${reforme ? '<input type="hidden" name="confirme" value="1">' : ''}`
255
+ + `${boutons(x, lienFiche(x, id), x.lib('transition', transition), reforme ? 'fl-danger' : '')}</form></div>`;
256
+ return { titre, corps };
257
+ }
258
+
259
+ /** Lit un changement de statut : { donnees: { motif, confirme }, erreurs } ; le motif est requis si la transition l'exige. */
260
+ export function lireStatut(corps = {}, transition) {
261
+ const motif = brut(corps.motif) || null;
262
+ const donnees = { motif, confirme: vrai(corps.confirme ?? '') };
263
+ const t = TRANSITIONS.find((y) => y.id === transition);
264
+ const erreurs = {};
265
+ if (t?.motif && !motif) erreurs.motif = 'requis';
266
+ if (transition === 'reformer' && !donnees.confirme) erreurs.confirme = 'requis';
267
+ return { donnees, erreurs };
268
+ }
269
+
270
+ // ── Les formulaires de saisie, sur une table déclarative ────────────────────────────────────────────────────
271
+ /**
272
+ * Chaque formulaire : ses champs, dans l'ordre, au format de l'argument de la fonction du moteur :
273
+ * plan → planifierEntretien, intervention → ouvrirIntervention, cloture → cloreIntervention, plein → saisirPlein,
274
+ * releve → relever, echeance → poserEcheance. `choix` : nature (CODES.nature), plans (ceux du véhicule), echeances (déclarées).
275
+ */
276
+ export const FORMULAIRES = gele({
277
+ plan: [{ nom: 'libelle', forme: 'texte', requis: true }, { nom: 'tousLesKm', forme: 'nombre' }, { nom: 'tousLesJours', forme: 'nombre' }, { nom: 'toleranceKm', forme: 'nombre' }, { nom: 'toleranceJours', forme: 'nombre' }],
278
+ intervention: [{ nom: 'nature', forme: 'choix', requis: true, choix: 'nature' }, { nom: 'plan', forme: 'choix', choix: 'plans' }, { nom: 'prestataire', forme: 'texte' }, { nom: 'description', forme: 'long' }, { nom: 'ouverteLe', forme: 'date' }],
279
+ cloture: [{ nom: 'km', forme: 'nombre' }, { nom: 'cout', forme: 'nombre' }, { nom: 'le', forme: 'date' }],
280
+ plein: [{ nom: 'le', forme: 'date' }, { nom: 'km', forme: 'nombre', requis: true }, { nom: 'litres', forme: 'nombre', requis: true }, { nom: 'montant', forme: 'nombre', requis: true }, { nom: 'complet', forme: 'booleen' }, { nom: 'piece', forme: 'fichier', libelle: 'ticket' }],
281
+ releve: [{ nom: 'km', forme: 'nombre', requis: true }, { nom: 'le', forme: 'date' }],
282
+ echeance: [{ nom: 'type', forme: 'choix', requis: true, choix: 'echeances' }, { nom: 'debut', forme: 'date', requis: true }, { nom: 'fin', forme: 'date' }, { nom: 'reference', forme: 'texte' }, { nom: 'organisme', forme: 'texte' }, { nom: 'cout', forme: 'nombre' }, { nom: 'piece', forme: 'fichier' }],
283
+ });
284
+
285
+ /** Lit un formulaire de FORMULAIRES : { donnees, erreurs } ; un champ vide non requis est omis (le moteur prend son défaut). */
286
+ export function lireFormulaire(nom, corps = {}, { declarations } = {}) {
287
+ const champs = FORMULAIRES[nom];
288
+ if (!champs) throw new Error(`flotte-ui : formulaire inconnu : ${nom}`);
289
+ const donnees = {}, erreurs = {};
290
+ for (const c of champs) {
291
+ const [val, err] = lireChamp(c.forme === 'long' || c.forme === 'choix' ? 'texte' : c.forme, corps[c.nom], c.requis);
292
+ if (err) { erreurs[c.nom] = err; continue; }
293
+ if (val === undefined) continue;
294
+ if (c.choix === 'nature' && !CODES.nature.includes(val)) { erreurs[c.nom] = 'choix'; continue; }
295
+ if (c.choix === 'echeances' && declarations && !declarations.echeances?.[val]) { erreurs[c.nom] = 'choix'; continue; }
296
+ donnees[c.nom] = val;
297
+ }
298
+ return { donnees, erreurs };
299
+ }
300
+
301
+ /**
302
+ * Rend un formulaire de FORMULAIRES pour la fiche `fiche` (construireFiche). Rend { titre, corps }.
303
+ * vm : { fiche, declarations?, valeurs?, erreurs?, refus? } ; opts : ceux du rendu, plus { action } (POST).
304
+ */
305
+ export function rendreFormulaire(nom, vm = {}, opts = {}) {
306
+ const champs = FORMULAIRES[nom];
307
+ if (!champs) throw new Error(`flotte-ui : formulaire inconnu : ${nom}`);
308
+ const D = vm.declarations || opts.declarations || {};
309
+ const x = contexte({ ...opts, declarations: D });
310
+ const f = vm.fiche || null, val = vm.valeurs || {};
311
+ const { e, general } = erreursEtRefus(vm.erreurs, vm.refus);
312
+ const options = {
313
+ nature: CODES.nature.map((n) => [n, x.lib('nature', n)]),
314
+ plans: [['', x.tx('formulaire.champ.aucunPlan')], ...(f?.plans || []).map((p) => [p.id, p.libelle])],
315
+ echeances: Object.keys(D.echeances || {}).map((t) => [t, x.lib('echeance', t)]),
316
+ };
317
+ const fichier = champs.some((c) => c.forme === 'fichier');
318
+ const html = champs.map((c) => champ(x, {
319
+ nom: c.nom, libelle: x.tx(`formulaire.champ.${c.libelle || c.nom}`), forme: c.forme, requis: Boolean(c.requis), valeur: val[c.nom], erreur: e[c.nom],
320
+ options: c.choix ? options[c.choix] : [], suffixe: c.nom === 'km' ? 'km' : c.nom === 'litres' ? 'L' : (c.nom === 'montant' || c.nom === 'cout') && x.devise ? x.devise : undefined,
321
+ aide: c.nom === 'km' && f && f.km !== null && f.km !== undefined ? x.tx('formulaire.dernierReleve', { km: x.nf(f.km) }) : undefined,
322
+ })).join('');
323
+ const action = opts.action ?? '';
324
+ const corps = `<div class="fl-ecran fl-formulaire">${general ? bandeauKo(x, esc(texteRefus(x, general))) : ''}`
325
+ + `<form method="post" action="${esc(action)}"${fichier ? ' enctype="multipart/form-data"' : ''} class="fl-carte" novalidate>${recap(x, f)}${html}`
326
+ + `${boutons(x, f ? lienFiche(x, f.vehicule.id, nom === 'plein' || nom === 'releve' ? 'pleins' : nom === 'echeance' ? 'documents' : 'entretiens') : x.liens.base, x.tx('action.enregistrer'))}</form></div>`;
327
+ return { titre: x.tx(`formulaire.${nom}`), corps };
328
+ }
package/src/index.js CHANGED
@@ -3,7 +3,13 @@
3
3
  //
4
4
  // Deux temps : CONSTRUIRE (async, lit l'API de flotte → vue-modèle de codes) puis RENDRE (pur, vue-modèle → HTML,
5
5
  // avec le kit et la langue de l'application). L'application garde son gabarit, son menu, ses routes et ses droits.
6
- export { construireTableau, construireFiche, ONGLETS } from './vue.js';
7
- export { rendreTableau, rendreFiche, exporterCsv } from './rendu.js';
6
+ export { construireTableau, construireFiche, construireMonVehicule, construireAptitudes, periodeDe, ONGLETS } from './vue.js';
7
+ export {
8
+ rendreTableau, rendreFiche, rendrePageVehicule, exporterCsv, rendreMonVehicule, rendreChoixVehicule, rendreRefusDepart, rendreDisponibles,
9
+ } from './rendu.js';
10
+ export {
11
+ rendreFormulaireVehicule, lireFormulaireVehicule, lireModification, rendreFormulaireStatut, lireStatut, FORMULAIRES, rendreFormulaire, lireFormulaire,
12
+ erreurDeFlotte, nombre, jour,
13
+ } from './formulaires.js';
8
14
  export { TEXTES, texte } from './textes.js';
9
15
  export { KIT_DEFAUT, FLOTTE_CSS, COULEURS } from './kit.js';
package/src/kit.js CHANGED
@@ -25,6 +25,8 @@ const ICONES = {
25
25
  horloge: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 10.4 3.6 2.1-.8 1.3L11 13V6h2z"/>',
26
26
  fleche: '<path d="M4 11h12.2l-5.6-5.6L12 4l8 8-8 8-1.4-1.4 5.6-5.6H4z"/>',
27
27
  liste: '<path d="M3 5h2v2H3zm4 0h14v2H7zM3 11h2v2H3zm4 0h14v2H7zM3 17h2v2H3zm4 0h14v2H7z"/>',
28
+ cadenas: '<path d="M7 10V7a5 5 0 0 1 10 0v3h2v12H5V10zm2 0h6V7a3 3 0 0 0-6 0z"/>',
29
+ pieces: '<path d="M9 4c-3.9 0-7 1.3-7 3v3c0 1.7 3.1 3 7 3s7-1.3 7-3V7c0-1.7-3.1-3-7-3zm6 10.5c-1.6.9-3.8 1.5-6 1.5s-4.4-.6-6-1.5V17c0 1.7 3.1 3 7 3 1 0 2-.1 2.9-.3A6 6 0 0 1 15 14.5zM18 11a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/>',
28
30
  menu: '<path d="M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>',
29
31
  };
30
32
 
@@ -115,4 +117,38 @@ export const FLOTTE_CSS = `
115
117
  .fl-rapides{display:grid;grid-template-columns:1fr 1fr;gap:10px}.fl-rapide{display:flex;align-items:center;gap:10px;min-height:44px;padding:8px 14px;border-radius:8px;text-decoration:none;font-weight:500;font-size:14px}.fl-rapide.plein{background:var(--fl-bleu);color:#fff}
116
118
  .fl-vide{color:var(--fl-doux);margin:8px 0}
117
119
  .fl-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
120
+ .fl-k6{display:grid;gap:16px;grid-template-columns:repeat(6,minmax(0,1fr))}.fl-entete-fiche b{white-space:nowrap}.fl-boutons-fiche{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;margin-top:10px}.fl-k4{display:grid;gap:12px;grid-template-columns:repeat(4,minmax(0,1fr));margin:12px 0}.fl-k2{display:grid;gap:12px;grid-template-columns:repeat(2,minmax(0,1fr))}
121
+ @media (max-width:1600px){.fl-k6{grid-template-columns:repeat(3,minmax(0,1fr))}.fl-k4{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:900px){.fl-k6,.fl-k4{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:560px){.fl-k6,.fl-k4,.fl-k2{grid-template-columns:1fr}}
122
+ .fl-puces{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}.fl-puce{padding:6px 14px;border:1px solid var(--fl-ligne);border-radius:8px;text-decoration:none;color:inherit;font-size:13.5px;background:var(--fl-carte)}.fl-puce.actif{background:var(--fl-bleu);border-color:var(--fl-bleu);color:#fff}.fl-puce.actif .fl-n{color:#fff}
123
+ .fl-mini{display:inline-grid;place-items:center;width:30px;height:24px;margin-inline-end:8px;border-radius:5px;background:#eef3fb;color:#5a7194;vertical-align:middle}
124
+ .fl-empile{display:grid;gap:10px;font-size:13.5px}.fl-ligne-empile{display:grid;grid-template-columns:minmax(90px,auto) 24px 1fr;gap:10px;align-items:center}
125
+ .fl-piste{display:flex;height:12px;border-radius:6px;background:var(--fl-ligne);overflow:hidden}.fl-piste i{display:block;height:100%}.fl-jauge .fl-piste i{background:var(--fl-bleu);border-radius:6px}
126
+ .fl-legende-ligne{display:flex;flex-wrap:wrap;gap:14px;font-size:12.5px;color:var(--fl-doux);margin:10px 0 0}
127
+ .fl-page{gap:16px}.fl-tete-page{display:flex;flex-wrap:wrap;gap:18px;align-items:center}.fl-grande{font-size:24px}.fl-ident{display:grid;gap:4px}.fl-qui{display:flex;gap:8px;align-items:center}.fl-actions{display:flex;gap:8px;margin-inline-start:auto;align-items:center}
128
+ .fl-statut[open]>div{min-width:220px}.fl-statut>summary{display:inline-flex;font-size:inherit;font-weight:500;line-height:normal}
129
+ .fl-secondaire{background:transparent!important;color:var(--fl-bleu)!important;border:1px solid currentColor!important}.fl-danger{background:var(--fl-rouge)!important;color:#fff!important}.fl-bloc{display:flex;justify-content:center;width:100%;margin-top:8px}
130
+ .fl-titre-section{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:16px 0 10px}.fl-titre-section h3{margin:0}.fl-titre-section>:last-child:not(h3){margin-inline-start:auto}
131
+ .fl-docs,.fl-plans{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
132
+ .fl-doc,.fl-plan{border:1px solid var(--fl-ligne);border-radius:12px;padding:14px;background:var(--fl-carte);display:grid;gap:10px;align-content:start}.fl-doc.fl-ko{border-color:#f3b4b4;background:#fff6f6}
133
+ .fl-doc>header,.fl-plan>header{display:flex;gap:10px;align-items:flex-start}.fl-pastille-doc{display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:#eef3fb;color:var(--fl-bleu);flex:none}
134
+ .fl-piece{display:flex;gap:8px;align-items:flex-start;padding:8px;border:1px solid var(--fl-ligne);border-radius:8px}.fl-replie summary{cursor:pointer;color:var(--fl-doux)}
135
+ .fl-jauges{display:grid;gap:8px}.fl-jauge{display:grid;grid-template-columns:1fr auto;gap:4px 8px;align-items:center;font-size:13px}.fl-jauge .fl-piste{grid-column:1/-1}.fl-jauge>span:last-child{grid-column:1/-1}
136
+ .fl-prochaine{display:flex;gap:8px;font-size:13.5px}.fl-cadenas{display:inline-flex;gap:4px;align-items:center;padding:2px 9px;border-radius:6px;background:#e5e9ee;font-size:12.5px}
137
+ .fl-actuel{display:flex;gap:12px;align-items:center;border:1px solid var(--fl-ligne);border-radius:10px;padding:12px}
138
+ .fl-frise{list-style:none;margin:0;padding:0;border-inline-start:2px solid var(--fl-ligne)}.fl-frise li{position:relative;padding:6px 0 12px;padding-inline-start:18px;display:grid;gap:3px}.fl-frise li::before{content:"";position:absolute;inset-inline-start:-7px;top:10px;width:12px;height:12px;border-radius:50%;background:#8ea3c0}.fl-frise li.actif::before{background:var(--fl-bleu)}
139
+ .fl-panneau{border:1px solid var(--fl-ligne);border-radius:12px;padding:14px;margin-top:16px}.fl-boutons{display:flex;gap:10px;justify-content:flex-end;margin-top:14px}.fl-en-ligne{display:inline}
140
+ .fl-avert{color:var(--fl-orange);font-weight:500}
141
+ .fl-colonnes{display:flex;gap:14px;align-items:flex-end;height:200px;padding-top:18px}.fl-colonne{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%;font-size:12px}.fl-pile-col{flex:1;width:60%;display:flex;flex-direction:column-reverse}.fl-pile-col i{display:block;width:100%}.fl-total{font-weight:600}
142
+ tr.choisi td{background:#e3f0fe}
143
+ .fl-formulaire form{display:grid;gap:16px}.fl-form-haut{display:grid;gap:16px;grid-template-columns:minmax(0,2fr) minmax(0,1fr)}.fl-form-bas{display:grid;gap:16px;grid-template-columns:minmax(0,1fr) minmax(0,2fr)}@media (max-width:900px){.fl-form-haut,.fl-form-bas{grid-template-columns:1fr}}
144
+ .fl-groupe{border:1px solid var(--fl-ligne);border-radius:12px;padding:16px;margin:0;background:var(--fl-carte);display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));align-content:start}.fl-groupe>legend{font-weight:700;font-size:17px;padding:0 6px}
145
+ .fl-num{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--fl-bleu);color:#fff;font-size:14px}
146
+ .fl-champ{display:grid;gap:4px;align-content:start;justify-items:start;border:0;padding:0;margin:0}.fl-champ>.fl-suffixe{width:100%}.fl-groupe.fl-large-groupe{grid-template-columns:1fr}.fl-champ.fl-case{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.fl-formulaire>form.fl-carte{max-width:760px}.fl-docs-form td{vertical-align:top;min-width:150px}.fl-courbe{display:block;max-height:240px}.fl-champ label,.fl-etiquette,.fl-champ legend{font-weight:500;font-size:13.5px}.fl-champ input:not([type=radio]):not([type=checkbox]),.fl-champ select,.fl-champ textarea{min-height:40px;border:1px solid var(--fl-ligne);border-radius:8px;padding:6px 10px;font:inherit;background:var(--fl-carte);color:inherit;width:100%;box-sizing:border-box}
147
+ .fl-erreur-champ input,.fl-erreur-champ select,.fl-erreur-champ textarea{border-color:var(--fl-rouge)!important}.fl-erreur{color:var(--fl-rouge);font-size:12.5px;margin:0}.fl-aide{color:var(--fl-doux);font-size:12px;margin:0}.fl-requis{color:var(--fl-rouge)}
148
+ .fl-suffixe{display:flex}.fl-suffixe>span{display:grid;place-items:center;padding:0 10px;border:1px solid var(--fl-ligne);border-inline-start:0;border-radius:0 8px 8px 0;color:var(--fl-doux)}.fl-radio{display:flex;gap:8px;align-items:center;font-weight:400}
149
+ .fl-recap{display:flex;gap:12px;align-items:center}.fl-confirmer{display:flex;gap:12px;padding:14px;border-radius:10px;background:#fdecec;color:#9b1c1c}.fl-confirmer p{margin:4px 0 0}
150
+ .fl-mobile{max-width:480px}.fl-liste-infos{list-style:none;margin:0;padding:0;display:grid;gap:10px}.fl-liste-infos li{display:flex;gap:10px;align-items:flex-start}.fl-boutons-pleins{display:grid;gap:4px}
151
+ .fl-choix{display:grid;gap:6px}.fl-option{display:grid;grid-template-columns:auto auto 1fr auto auto auto;gap:12px;align-items:center;padding:10px;border:1px solid var(--fl-ligne);border-radius:10px;cursor:pointer}.fl-option.ko{background:#fff6f6}.fl-option:has(input:checked){border-color:var(--fl-bleu)}
152
+ @media (max-width:560px){.fl-option{grid-template-columns:auto auto 1fr}}
153
+ .fl-refus{display:flex;gap:12px;padding:16px;border-radius:12px;background:#fdecec;color:#9b1c1c;border:1px solid #f3b4b4}.fl-refus ul{margin:8px 0;padding-inline-start:18px;color:var(--fl-texte)}
118
154
  `;