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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user