diff --git a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml index 57fb30b..0bacc0e 100644 --- a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml @@ -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 diff --git a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml index 3ec06a2..a015afe 100644 --- a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml @@ -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 diff --git a/config/sync/field.field.node.ligne_comptable.field_flag.yml b/config/sync/field.field.node.ligne_comptable.field_flag.yml new file mode 100644 index 0000000..214d4f5 --- /dev/null +++ b/config/sync/field.field.node.ligne_comptable.field_flag.yml @@ -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 diff --git a/config/sync/field.storage.node.field_flag.yml b/config/sync/field.storage.node.field_flag.yml new file mode 100644 index 0000000..b1e3fbf --- /dev/null +++ b/config/sync/field.storage.node.field_flag.yml @@ -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 diff --git a/config/sync/taxonomy.vocabulary.flag.yml b/config/sync/taxonomy.vocabulary.flag.yml new file mode 100644 index 0000000..6eedf25 --- /dev/null +++ b/config/sync/taxonomy.vocabulary.flag.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 7c61261..0ea1214 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -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, diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install index b0efdce..a43b1a2 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -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(); + } +} diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 6783188..fc012ed 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -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 Écarts uniquement + + + + + + {{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }} @@ -105,6 +113,7 @@ Type Facture Libellé / Détail + Signalement Montant HT Montant TTC {{ c }} @@ -113,15 +122,15 @@ - + Chargement des mois précédents… + + + + + + + {{ formatEur(item.montant_ht) }} {{ formatEur(item.montant_ttc) }} {{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }} @@ -232,7 +258,7 @@ - + Chargement des mois suivants… @@ -241,7 +267,7 @@ - + Solde {{ currentYear || '…' }} (créditeur / débiteur) chargement…