Add signalement (flag) tags for problem lines on /lignes

New field_flag: free-tagging taxonomy reference (multi-value,
auto-create) on ligne_comptable, for marking a line with an
unstructured problem description (e.g. "client impayé") that can't be
detected automatically the way the répartition écart already is.

- New "Signalement" column, inline-editable like Client/Facture/Libellé
  (comma-separated tags, datalist autocomplete, server-side auto-create
  of unknown tags -- same pattern LedgerActionsController already used
  for Client, now shared via findOrCreateTerm()).
- New "Signalées uniquement" filter, mirroring "Écarts uniquement".
- Flagged rows get a distinct amber left-edge accent (box-shadow, not
  border) so a row that's both in écart and signalée shows both
  indicators without one overwriting the other.
- Native node add/edit form gets the field for free via core's
  entity_reference_autocomplete_tags widget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 14:55:46 +02:00
co-authored by Claude Sonnet 5
parent ceaf301bf0
commit 34b1082991
10 changed files with 284 additions and 46 deletions
@@ -32,6 +32,7 @@ class LedgerActionsController extends ControllerBase {
'client' => 'field_client',
'facture' => 'field_numero_facture',
'libelle' => 'field_notes',
'flag' => 'field_flag',
];
/**
@@ -119,8 +120,8 @@ class LedgerActionsController extends ControllerBase {
}
/**
* POST /lignes/{node}/champ -- change client/facture/libellé inline,
* for clicking directly on those cells in the table. Body:
* POST /lignes/{node}/champ -- change client/facture/libellé/signalement
* 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).
*/
@@ -147,35 +148,26 @@ class LedgerActionsController extends ControllerBase {
}
if ($field === 'client') {
if ($value === '') {
$node->set('field_client', NULL);
}
else {
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'client', 'name' => $value]);
if ($terms) {
$node->set('field_client', reset($terms)->id());
}
else {
// No existing term matches -- create one rather than reject,
// same "autocreate" behavior as a standard Drupal entity
// reference autocomplete widget. The front-end's datalist only
// *suggests* known names, it doesn't restrict input to them.
$term = Term::create(['vid' => 'client', 'name' => $value]);
$term->save();
$node->set('field_client', $term->id());
}
}
$node->set('field_client', $value === '' ? NULL : $this->findOrCreateTerm('client', $value)->id());
}
elseif ($field === 'flag') {
// Comma-separated like a native "tags" widget -- one or more
// free-form tags, each matched against an existing term or
// auto-created (same reasoning as client above). Order/dedup
// doesn't matter here, this is a display list, not a répartition.
$names = array_unique(array_filter(array_map('trim', explode(',', $value)), fn ($n) => $n !== ''));
$tids = array_map(fn ($name) => $this->findOrCreateTerm('flag', $name)->id(), $names);
$node->set('field_flag', $tids);
}
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.
// Same reasoning as updateType() above: only client/facture/libellé/
// signalement 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();
@@ -187,9 +179,16 @@ class LedgerActionsController extends ControllerBase {
\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;
if ($field === 'client') {
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
}
elseif ($field === 'flag') {
$newValue = array_map(fn ($item) => $item->entity ? $item->entity->label() : NULL, iterator_to_array($node->get('field_flag')));
$newValue = array_values(array_filter($newValue));
}
else {
$newValue = $node->get($fieldName)->value;
}
return new JsonResponse([
'success' => TRUE,
@@ -199,6 +198,24 @@ class LedgerActionsController extends ControllerBase {
]);
}
/**
* Finds an existing term by name in $vid, or creates one -- shared by
* the client and flag (signalement) inline-edit cases above. Same
* "autocreate" behavior as a standard Drupal entity reference
* autocomplete/tags widget: the front-end's datalist only *suggests*
* known names, it doesn't restrict input to them.
*/
private function findOrCreateTerm(string $vid, string $name): Term {
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => $vid, 'name' => $name]);
if ($terms) {
return reset($terms);
}
$term = Term::create(['vid' => $vid, 'name' => $name]);
$term->save();
return $term;
}
/**
* Optimistic-locking guard shared by both endpoints above: the
* frontend sends the `changed` timestamp of the row it last saw (see