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
@@ -323,6 +323,28 @@ html.gin--dark-mode #figli-home-app {
border-radius: 4px;
}
/* Client/Facture/Libellé: click-to-edit like the type badge above, but
plain text rather than a pill -- a dotted underline is enough of an
affordance without implying a fixed set of choices the way the type
badge's pill shape does. */
#figli-home-app .figli-editable-cell {
cursor: pointer;
border-bottom: 1px dotted transparent;
}
#figli-home-app .figli-editable-cell:hover {
border-bottom-color: var(--figli-text-light);
}
#figli-home-app .figli-inline-input {
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
#figli-home-app .figli-note {
color: var(--figli-text-light);
font-weight: 400;
@@ -84,3 +84,15 @@ figli_compta_ledger.update_type:
parameters:
node:
type: entity:node
figli_compta_ledger.update_field:
path: '/lignes/{node}/champ'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateField'
methods: [POST]
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
@@ -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;
@@ -13,11 +13,26 @@ use Symfony\Component\HttpFoundation\Request;
* Small write endpoints backing inline (no-modal) edits from the /lignes
* table. Each goes through the normal node save() lifecycle -- same as the
* full edit form -- so figli_compta_ledger_node_presave() still forces a
* proper revision and still enforces the répartition invariant; nothing
* here bypasses that.
* proper revision. The répartition invariant check is deliberately
* skipped for these saves though (same as the type-change endpoint
* below): none of client/facture/libellé/type touch montant_ht or
* field_repartition, so skipping can never *introduce* a mismatch, only
* leave a pre-existing historical one exactly as it was -- see each
* method's own comment.
*/
class LedgerActionsController extends ControllerBase {
/**
* Fields editable inline from /lignes without opening the full node
* edit form -- keys are the short names the frontend sends; values are
* the real field machine names.
*/
const INLINE_EDITABLE_FIELDS = [
'client' => 'field_client',
'facture' => 'field_numero_facture',
'libelle' => 'field_notes',
];
/**
* Every value field_type_ligne actually allows (see the field's
* allowed_values in config) -- validated against here rather than
@@ -97,4 +112,76 @@ class LedgerActionsController extends ControllerBase {
]);
}
/**
* POST /lignes/{node}/champ -- change client/facture/libellé inline,
* for clicking directly on those cells in the table. Body:
* {"field": "client", "value": "EPAU / POPSU"}. An empty value clears
* the field (e.g. a structural charge with no client).
*/
public function updateField(Request $request, NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
}
$csrfToken = $request->headers->get('X-CSRF-Token', '');
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
}
$data = json_decode($request->getContent(), TRUE);
$field = is_array($data) ? ($data['field'] ?? NULL) : NULL;
$value = trim((string) (is_array($data) ? ($data['value'] ?? '') : ''));
if (!isset(self::INLINE_EDITABLE_FIELDS[$field])) {
return new JsonResponse(['error' => 'Champ invalide.'], 400);
}
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
if ($field === 'client') {
// Only an existing "Client" term is accepted -- the front-end
// offers this as a datalist of known names, not free text, so a
// non-match almost certainly means a typo rather than a genuinely
// new client that should be created on the fly.
if ($value === '') {
$node->set('field_client', NULL);
}
else {
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'client', 'name' => $value]);
if (!$terms) {
return new JsonResponse(['error' => 'Client inconnu : "' . $value . '". Utilisez un nom existant dans la liste.'], 400);
}
$node->set('field_client', reset($terms)->id());
}
}
else {
$node->set($fieldName, $value !== '' ? $value : NULL);
}
// Same reasoning as updateType() above: only client/facture/libellé
// changes here, montant_ht and field_repartition are untouched, so
// skipping the répartition check for this save can never introduce a
// mismatch -- it can only leave a pre-existing historical one exactly
// as it was.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
try {
$node->save();
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
$newValue = $field === 'client'
? ($node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL)
: $node->get($fieldName)->value;
return new JsonResponse([
'success' => TRUE,
'field' => $field,
'value' => $newValue,
]);
}
}
@@ -144,7 +144,19 @@
</button>
</td>
<td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td>
<td>
<input
v-if="isEditingCell(item, 'client')"
type="text"
class="figli-inline-input"
list="figli-client-datalist"
:value="item.client"
@change="saveCell(item, 'client', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'client')">{{ item.client || '—' }}</span>
</td>
<td>
<select
v-if="editingTypeId === item.id"
@@ -163,28 +175,50 @@
@click="startEditType(item)"
>{{ typeLabel(item.type) }}</span>
</td>
<td>{{ item.facture }}</td>
<td>
<input
v-if="isEditingCell(item, 'facture')"
type="text"
class="figli-inline-input"
:value="item.facture"
@change="saveCell(item, 'facture', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'facture')">{{ item.facture }}</span>
</td>
<td class="figli-libelle">
{{ item.libelle }}
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge is-clickable"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
<span
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
class="figli-recon-badge is-anomalie"
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
<span
v-if="linkStatus(item)"
class="figli-recon-badge is-clickable"
:class="linkStatusClasses(linkStatus(item).kind)"
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0]) : openLinkForm(item.nid)"
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item).kind) }}</span>
<input
v-if="isEditingCell(item, 'libelle')"
type="text"
class="figli-inline-input"
:value="item.libelle"
@change="saveCell(item, 'libelle', $event)"
@blur="editingCell = null"
@keyup.enter="$event.target.blur()"
/>
<template v-else>
<span class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'libelle')">{{ item.libelle }}</span>
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge is-clickable"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
<span
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
class="figli-recon-badge is-anomalie"
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
<span
v-if="linkStatus(item)"
class="figli-recon-badge is-clickable"
:class="linkStatusClasses(linkStatus(item).kind)"
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0]) : openLinkForm(item.nid)"
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item).kind) }}</span>
</template>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>