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
@@ -6,6 +6,7 @@ dependencies:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
@@ -54,6 +55,16 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
field_flag:
type: entity_reference_autocomplete_tags
weight: 8
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
field_montant_ht:
type: number
weight: 3
@@ -6,6 +6,7 @@ dependencies:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
@@ -48,6 +49,14 @@ content:
third_party_settings: { }
weight: 2
region: content
field_flag:
type: entity_reference_label
label: above
settings:
link: true
third_party_settings: { }
weight: 8
region: content
field_montant_ht:
type: number_decimal
label: above
@@ -0,0 +1,25 @@
uuid: c89c4d76-167a-4b60-b5f1-50aa3b349b80
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_flag
- node.type.ligne_comptable
- taxonomy.vocabulary.flag
id: node.ligne_comptable.field_flag
field_name: field_flag
entity_type: node
bundle: ligne_comptable
label: Signalement
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
flag: flag
auto_create: true
field_type: entity_reference
@@ -0,0 +1,20 @@
uuid: 0921a265-d874-487c-bf0a-09a389322d6b
langcode: en
status: true
dependencies:
module:
- node
- taxonomy
id: node.field_flag
field_name: field_flag
entity_type: node
type: entity_reference
settings:
target_type: taxonomy_term
module: core
locked: false
cardinality: -1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
+9
View File
@@ -0,0 +1,9 @@
uuid: 01c508f8-ba10-40d7-8d8b-5c2c362bd761
langcode: en
status: true
dependencies: { }
name: Signalement
vid: flag
description: null
weight: 0
new_revision: false
@@ -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>