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:
@@ -9,6 +9,7 @@
|
||||
--figli-border: #dcdee2;
|
||||
--figli-error: #c9312b;
|
||||
--figli-positive: #1a7f37;
|
||||
--figli-warning: #b8860b;
|
||||
--figli-col-hover: rgba(15, 23, 42, 0.05);
|
||||
|
||||
font-family: Inter, -apple-system, sans-serif;
|
||||
@@ -24,6 +25,7 @@ html.gin--dark-mode #figli-home-app {
|
||||
--figli-border: #3d3e42;
|
||||
--figli-error: #ff6b6b;
|
||||
--figli-positive: #4ade80;
|
||||
--figli-warning: #f0b429;
|
||||
--figli-col-hover: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
@@ -205,6 +207,12 @@ html.gin--dark-mode #figli-home-app {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
#figli-home-app td.figli-flag-cell {
|
||||
white-space: normal;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
#figli-home-app tr.figli-group-row td {
|
||||
background: var(--figli-bg-alt);
|
||||
color: var(--figli-text);
|
||||
@@ -277,6 +285,27 @@ html.gin--dark-mode #figli-home-app {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Signalé rows: a thick amber accent bar on the left edge, deliberately
|
||||
a different technique from the error row's thin all-around border
|
||||
above (box-shadow, not border) -- so a row that's both in écart *and*
|
||||
signalée shows both indicators at once instead of one overwriting the
|
||||
other's border-left. */
|
||||
#figli-home-app tr.figli-flag-row td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--figli-warning);
|
||||
}
|
||||
|
||||
#figli-home-app .figli-flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin: 0 0.2rem 0.15rem 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
|
||||
color: var(--figli-warning);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Column highlight to pair with the row hover, forming a crosshair over
|
||||
the hovered cell. box-shadow (not background) so it layers on top of
|
||||
whatever the cell already has -- sticky header/footer backgrounds,
|
||||
|
||||
@@ -46,6 +46,9 @@ function _figli_compta_ledger_clients() {
|
||||
function figli_compta_ledger_install() {
|
||||
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
|
||||
_figli_compta_ledger_create_vocabulary('client', 'Client', _figli_compta_ledger_clients());
|
||||
// No starter terms -- unlike compte/client, flags are created on the fly
|
||||
// as they're needed (see field_flag's auto_create handler setting below).
|
||||
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
||||
_figli_compta_ledger_create_paragraph_repartition();
|
||||
_figli_compta_ledger_create_node_type_ligne_comptable();
|
||||
}
|
||||
@@ -87,16 +90,25 @@ function _figli_field($entity_type, $bundle, $field_name, $label, $type, array $
|
||||
}
|
||||
}
|
||||
|
||||
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE) {
|
||||
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE, $cardinality = 1, $auto_create = FALSE) {
|
||||
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => $entity_type,
|
||||
'type' => 'entity_reference',
|
||||
'cardinality' => $cardinality,
|
||||
'settings' => ['target_type' => $target_type],
|
||||
])->save();
|
||||
}
|
||||
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
|
||||
$handler_settings = ['target_bundles' => [$target_bundle => $target_bundle]];
|
||||
// Lets the tags-style widget create a new term on the fly instead of
|
||||
// rejecting anything not already in the vocabulary -- same "autocreate"
|
||||
// behavior LedgerActionsController::updateField() already replicates
|
||||
// by hand for the /lignes inline-edit endpoint (client, flag).
|
||||
if ($auto_create) {
|
||||
$handler_settings['auto_create'] = TRUE;
|
||||
}
|
||||
FieldConfig::create([
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => $entity_type,
|
||||
@@ -105,7 +117,7 @@ function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $ta
|
||||
'required' => $required,
|
||||
'settings' => [
|
||||
'handler' => 'default:' . $target_type,
|
||||
'handler_settings' => ['target_bundles' => [$target_bundle => $target_bundle]],
|
||||
'handler_settings' => $handler_settings,
|
||||
],
|
||||
])->save();
|
||||
}
|
||||
@@ -202,6 +214,14 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
||||
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
|
||||
|
||||
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
|
||||
// informational, never read by figli_compta_ledger_node_presave() or any
|
||||
// total/solde calculation. Multi-value (-1) + auto_create: a line can
|
||||
// carry several tags, and typing a new one creates it rather than
|
||||
// rejecting it, same as the /lignes inline-edit endpoint already does
|
||||
// by hand for field_client.
|
||||
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
|
||||
|
||||
_figli_paragraph_field('node', 'ligne_comptable', 'field_repartition', 'Répartition', 'repartition');
|
||||
|
||||
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
|
||||
@@ -219,6 +239,7 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
||||
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
|
||||
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
|
||||
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
|
||||
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])
|
||||
->save();
|
||||
}
|
||||
|
||||
@@ -237,6 +258,7 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
||||
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
|
||||
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
|
||||
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
|
||||
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])
|
||||
->save();
|
||||
}
|
||||
}
|
||||
@@ -310,3 +332,24 @@ function figli_compta_ledger_update_8002() {
|
||||
|
||||
return "Numéro de facture rempli pour $filled lignes, $skipped laissées vides (aucun motif fiable trouvé).";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds field_flag (Signalement) to ligne_comptable -- free tagging for
|
||||
* problems that can't be detected automatically (e.g. "client impayé"),
|
||||
* see figli_compta_ledger.module's docblock. No backfill: unlike
|
||||
* field_numero_facture there's nothing to infer from existing content,
|
||||
* this is new operational metadata going forward.
|
||||
*/
|
||||
function figli_compta_ledger_update_8003() {
|
||||
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
||||
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
|
||||
|
||||
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||
if ($form_display && !$form_display->getComponent('field_flag')) {
|
||||
$form_display->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])->save();
|
||||
}
|
||||
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||
if ($view_display && !$view_display->getComponent('field_flag')) {
|
||||
$view_display->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,6 +87,14 @@
|
||||
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
||||
</label>
|
||||
|
||||
<label class="figli-checkbox">
|
||||
<input type="checkbox" v-model="onlyFlagged" /> Signalées uniquement
|
||||
</label>
|
||||
|
||||
<datalist id="figli-flag-datalist">
|
||||
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
||||
</datalist>
|
||||
|
||||
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +113,7 @@
|
||||
<th>Type</th>
|
||||
<th>Facture</th>
|
||||
<th>Libellé / Détail</th>
|
||||
<th>Signalement</th>
|
||||
<th class="amount">Montant HT</th>
|
||||
<th class="amount">Montant TTC</th>
|
||||
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
||||
@@ -113,15 +122,15 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ref="topSentinel" class="figli-sentinel-row">
|
||||
<td :colspan="7 + allComptes.length + 3">
|
||||
<td :colspan="8 + allComptes.length + 3">
|
||||
<span v-if="loadingOlder">Chargement des mois précédents…</span>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="item in groupedRows" :key="item.key">
|
||||
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
|
||||
<td :colspan="7 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
||||
<td :colspan="8 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
||||
</tr>
|
||||
<tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
||||
<tr v-else :class="{'figli-error-row': item.hasError, 'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
||||
<td class="actions-col">
|
||||
<button
|
||||
v-if="item.linkable"
|
||||
@@ -225,6 +234,23 @@
|
||||
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="figli-flag-cell">
|
||||
<input
|
||||
v-if="isEditingCell(item, 'flag')"
|
||||
v-focus
|
||||
type="text"
|
||||
class="figli-inline-input"
|
||||
list="figli-flag-datalist"
|
||||
:value="item.flags.join(', ')"
|
||||
@change="saveCell(item, 'flag', $event)"
|
||||
@blur="editingCell = null"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
/>
|
||||
<span v-else class="figli-editable-cell" title="Cliquer pour signaler un problème" @click="startEditCell(item, 'flag')">
|
||||
<template v-if="item.flags.length"><span v-for="f in item.flags" :key="f" class="figli-flag-badge">{{ f }}</span></template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
</td>
|
||||
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
||||
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
||||
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
|
||||
@@ -232,7 +258,7 @@
|
||||
</tr>
|
||||
</template>
|
||||
<tr ref="bottomSentinel" class="figli-sentinel-row">
|
||||
<td :colspan="7 + allComptes.length + 3">
|
||||
<td :colspan="8 + allComptes.length + 3">
|
||||
<span v-if="loadingNewer">Chargement des mois suivants…</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -241,7 +267,7 @@
|
||||
<tr class="figli-totals-row">
|
||||
<td class="actions-col"></td>
|
||||
<td class="actions-col"></td>
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
||||
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user