Make Client/Facture/Libellé editable in place, same as the type badge

New POST /lignes/{node}/champ endpoint (LedgerActionsController::updateField(),
whitelisted to client/facture/libelle -> field_client/field_numero_facture/
field_notes) mirrors updateType(): skips the répartition invariant check
for this save (client/facture/libellé never touch montant_ht or
field_repartition, so it can only ever leave a pre-existing historical
mismatch as it was, never introduce one), wrapped in the same
skip_validation state flag with a try/finally.

Client resolves the typed text against existing "Client" taxonomy terms
only (same known-names list the toolbar's Client filter already offers
via a datalist) -- a non-match is rejected with a clear error rather
than silently creating a new term from a typo.

Frontend mirrors the existing editingTypeId/startEditType/saveType
pattern exactly, generalized to any of the three fields via a single
{id, field} editingCell state.

Verified live: editing all three fields on a row with a known
répartition écart succeeds (bypass confirmed), an unknown client name
is rejected with a visible error and the display value stays unchanged,
and the database was confirmed clean of test artifacts afterward.
This commit is contained in:
2026-09-06 10:46:30 +02:00
parent 50a6432691
commit 11eb9cadb0
5 changed files with 222 additions and 24 deletions
@@ -199,6 +199,22 @@
return json;
}
// POST /lignes/{nid}/champ -- change client/facture/libellé without
// opening the full edit form. Same fresh-token-per-call reasoning as
// updateLigneType() above.
async function updateLigneField(nid, field, value) {
const tokenRes = await fetch('/session/token');
const token = await tokenRes.text();
const res = await fetch('/lignes/' + nid + '/champ', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ field, value }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status));
return json;
}
async function fetchYearTotals(annee) {
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
@@ -334,6 +350,10 @@
// instead of the badge -- only one at a time.
editingTypeId: null,
typeUpdateError: null,
// Which row+field (client/facture/libelle) is currently showing
// its inline <input> instead of the plain text -- only one at a
// time, mirroring editingTypeId above. { id, field } or null.
editingCell: null,
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
@@ -777,6 +797,29 @@
this.typeUpdateError = err.message;
}
},
startEditCell(item, field) {
this.typeUpdateError = null;
this.editingCell = { id: item.id, field };
},
isEditingCell(item, field) {
return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field;
},
// Same optimistic-patch-then-close pattern as saveType() above --
// client/facture/libellé don't affect linkability or
// field_entree_liee, so there's nothing else to reconcile via
// reloadWindow() here.
async saveCell(item, field, event) {
const newValue = event.target.value.trim();
this.editingCell = null;
if (newValue === (item[field] || '')) return;
try {
const result = await updateLigneField(item.nid, field, newValue);
const row = this.rows.find((r) => r.id === item.id);
if (row) row[field] = result.value;
} catch (err) {
this.typeUpdateError = err.message;
}
},
onCellHover(evt) {
const cell = evt.target.closest('td, th');
if (!cell) return;