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
@@ -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,
]);
}
}