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
@@ -104,7 +104,7 @@
'&filter[lt][condition][operator]=%3C' +
'&filter[lt][condition][value]=' + end +
'&filter[lt][condition][memberOf]=dateRange';
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee'
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag'
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
}
@@ -147,7 +147,7 @@
async function fetchLignesByNids(nids) {
if (!nids.length) return { data: [], includedMap: new Map() };
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee');
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
params.set('filter[parNid][condition][path]', 'drupal_internal__nid');
params.set('filter[parNid][condition][operator]', 'IN');
nids.forEach((nid) => params.append('filter[parNid][condition][value][]', nid));
@@ -163,7 +163,7 @@
// silently matched everything instead of narrowing the query).
async function fetchChangedSince(tsSeconds) {
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee');
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
params.set('filter[changedFilter][condition][path]', 'changed');
params.set('filter[changedFilter][condition][operator]', '>');
params.set('filter[changedFilter][condition][value]', String(tsSeconds));
@@ -188,6 +188,22 @@
return names.sort();
}
// Same pagination reasoning as fetchClientNames() above -- feeds the
// "Signalement" column's datalist (suggestions only, doesn't restrict
// input: see LedgerActionsController::updateField()'s 'flag' case).
async function fetchFlagNames() {
let url = '/jsonapi/taxonomy_term/flag?sort=name&page[limit]=50';
const names = [];
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean));
url = json.links && json.links.next ? json.links.next.href : null;
}
return names.sort();
}
async function fetchYearsList() {
const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status);
@@ -275,6 +291,7 @@
filterType: params.get('type') ? params.get('type').split(',') : [],
filterYear: params.get('annee') || '',
onlyErrors: params.get('ecarts') === '1',
onlyFlagged: params.get('signale') === '1',
aller: params.get('aller') || '',
};
}
@@ -286,6 +303,7 @@
if (state.filterType.length) params.set('type', state.filterType.join(','));
if (state.filterYear) params.set('annee', state.filterYear);
if (state.onlyErrors) params.set('ecarts', '1');
if (state.onlyFlagged) params.set('signale', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
// already fully describes the year, so don't also carry a stale
// "aller" target into the hash.
@@ -310,6 +328,11 @@
// per linked entrée, even when there's only one or none.
const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
const entreeLieeNodes = entreeLieeRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean);
// Multi-value like field_entree_liee above -- zero, one or several
// free-form "signalement" tags (see LedgerActionsController::
// updateField()'s 'flag' case for how they're written).
const flagRefs = (rels.field_flag && rels.field_flag.data) || [];
const flagTerms = flagRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean);
const parCompte = {};
let somme = 0;
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
@@ -348,6 +371,8 @@
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
flags: flagTerms.map((t) => t.attributes.name),
hasFlag: flagTerms.length > 0,
});
}
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
@@ -362,6 +387,7 @@
rows: [],
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
allClientsList: [],
allFlagsList: [],
allYearsList: [],
ouvertureEcarts: {},
// Arrays -- both are native <select multiple>, so ctrl/cmd+click
@@ -379,6 +405,7 @@
lastJumpYear: '',
groupBy: 'month',
onlyErrors: false,
onlyFlagged: false,
hoverCol: null,
filterEntreeId: null,
// Rows fetched to fill in entrées/sorties that /lignes/api/groupe
@@ -562,6 +589,7 @@
if (this.filterType.length && !this.filterType.includes(r.type)) return false;
if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
if (this.onlyErrors && !r.hasError) return false;
if (this.onlyFlagged && !r.hasFlag) return false;
return true;
});
},
@@ -947,27 +975,41 @@
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
// client/facture/libellé/flag 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;
// field_flag is multi-value -- the row carries it as `flags`
// (array), not `flag`, and the input works with the comma-joined
// text form of it (see the 'flag' case in the template).
const previousValue = field === 'flag' ? item.flags.join(', ') : (item[field] || '');
if (newValue === previousValue) return;
try {
const result = await updateLigneField(item.nid, field, newValue, item.changed);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row[field] = result.value;
if (field === 'flag') {
row.flags = result.value;
row.hasFlag = result.value.length > 0;
} else {
row[field] = result.value;
}
row.changed = result.changed;
}
// A client name with no existing match gets created on the fly
// (server-side) rather than rejected -- reflect it in the
// A client/tag name with no existing match gets created on the
// fly (server-side) rather than rejected -- reflect it in the
// filter dropdown/datalist immediately instead of only after a
// reload picks up the new taxonomy term via fetchClientNames().
// reload picks up the new taxonomy term via fetchClientNames()/
// fetchFlagNames().
if (field === 'client' && result.value && !this.allClientsList.includes(result.value)) {
this.allClientsList = [...this.allClientsList, result.value].sort();
}
if (field === 'flag') {
const newNames = result.value.filter((name) => !this.allFlagsList.includes(name));
if (newNames.length) this.allFlagsList = [...this.allFlagsList, ...newNames].sort();
}
} catch (err) {
this.typeUpdateError = err.message;
if (err.status === 409) this.refreshSingleRow(item.id, item.nid);
@@ -1216,7 +1258,7 @@
// filter with only a few matches a year could search almost forever
// without surfacing more of them.
isFiltering() {
return !!(this.filterCompte.length || this.filterClient || this.filterType.length || this.onlyErrors);
return !!(this.filterCompte.length || this.filterClient || this.filterType.length || this.onlyErrors || this.onlyFlagged);
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
@@ -1430,6 +1472,7 @@
filterType: this.filterType,
filterYear: this.filterYear,
onlyErrors: this.onlyErrors,
onlyFlagged: this.onlyFlagged,
lastJumpYear: this.lastJumpYear,
});
const url = location.pathname + location.search + (hash ? '#' + hash : '');
@@ -1480,6 +1523,10 @@
this.ensureScrollable();
this.syncHash();
},
onlyFlagged() {
this.ensureScrollable();
this.syncHash();
},
},
async mounted() {
// Reproduce whatever the URL hash describes -- reload or a shared
@@ -1493,6 +1540,7 @@
this.filterClient = hashState.filterClient;
this.filterType = hashState.filterType;
this.onlyErrors = hashState.onlyErrors;
this.onlyFlagged = hashState.onlyFlagged;
if (hashState.filterYear) {
this.filterYear = hashState.filterYear;
@@ -1517,6 +1565,7 @@
// Independent of the row window, so the dropdowns don't shrink to
// "whatever happens to be loaded right now".
fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {});
fetchFlagNames().then((names) => { this.allFlagsList = names; }).catch(() => {});
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());