diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 699286c..9825797 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -323,6 +323,28 @@ html.gin--dark-mode #figli-home-app { border-radius: 4px; } +/* Client/Facture/Libellé: click-to-edit like the type badge above, but + plain text rather than a pill -- a dotted underline is enough of an + affordance without implying a fixed set of choices the way the type + badge's pill shape does. */ +#figli-home-app .figli-editable-cell { + cursor: pointer; + border-bottom: 1px dotted transparent; +} +#figli-home-app .figli-editable-cell:hover { + border-bottom-color: var(--figli-text-light); +} +#figli-home-app .figli-inline-input { + font-size: 0.8rem; + padding: 0.1rem 0.3rem; + background: var(--figli-bg); + color: var(--figli-text); + border: 1px solid var(--figli-border); + border-radius: 4px; + width: 100%; + box-sizing: border-box; +} + #figli-home-app .figli-note { color: var(--figli-text-light); font-weight: 400; diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 26a8207..2846530 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -84,3 +84,15 @@ figli_compta_ledger.update_type: parameters: node: type: entity:node + +figli_compta_ledger.update_field: + path: '/lignes/{node}/champ' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateField' + methods: [POST] + requirements: + _entity_access: 'node.update' + options: + parameters: + node: + type: entity:node diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 0d54e2c..c194392 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -199,6 +199,22 @@ return json; } + // POST /lignes/{nid}/champ -- change client/facture/libellé without + // opening the full edit form. Same fresh-token-per-call reasoning as + // updateLigneType() above. + async function updateLigneField(nid, field, value) { + const tokenRes = await fetch('/session/token'); + const token = await tokenRes.text(); + const res = await fetch('/lignes/' + nid + '/champ', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, + body: JSON.stringify({ field, value }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); + return json; + } + async function fetchYearTotals(annee) { const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); @@ -334,6 +350,10 @@ // instead of the badge -- only one at a time. editingTypeId: null, typeUpdateError: null, + // Which row+field (client/facture/libelle) is currently showing + // its inline instead of the plain text -- only one at a + // time, mirroring editingTypeId above. { id, field } or null. + editingCell: null, // Fenêtre glissante. windowStart: null, windowEnd: null, @@ -777,6 +797,29 @@ this.typeUpdateError = err.message; } }, + startEditCell(item, field) { + this.typeUpdateError = null; + this.editingCell = { id: item.id, field }; + }, + isEditingCell(item, field) { + 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 + // 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; + try { + const result = await updateLigneField(item.nid, field, newValue); + const row = this.rows.find((r) => r.id === item.id); + if (row) row[field] = result.value; + } catch (err) { + this.typeUpdateError = err.message; + } + }, onCellHover(evt) { const cell = evt.target.closest('td, th'); if (!cell) return; diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php index 8d1676e..9c3c406 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php @@ -13,11 +13,26 @@ use Symfony\Component\HttpFoundation\Request; * Small write endpoints backing inline (no-modal) edits from the /lignes * table. Each goes through the normal node save() lifecycle -- same as the * full edit form -- so figli_compta_ledger_node_presave() still forces a - * proper revision and still enforces the répartition invariant; nothing - * here bypasses that. + * proper revision. The répartition invariant check is deliberately + * skipped for these saves though (same as the type-change endpoint + * below): none of client/facture/libellé/type touch montant_ht or + * field_repartition, so skipping can never *introduce* a mismatch, only + * leave a pre-existing historical one exactly as it was -- see each + * method's own comment. */ class LedgerActionsController extends ControllerBase { + /** + * Fields editable inline from /lignes without opening the full node + * edit form -- keys are the short names the frontend sends; values are + * the real field machine names. + */ + const INLINE_EDITABLE_FIELDS = [ + 'client' => 'field_client', + 'facture' => 'field_numero_facture', + 'libelle' => 'field_notes', + ]; + /** * Every value field_type_ligne actually allows (see the field's * allowed_values in config) -- validated against here rather than @@ -97,4 +112,76 @@ class LedgerActionsController extends ControllerBase { ]); } + /** + * POST /lignes/{node}/champ -- change client/facture/libellé 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). + */ + public function updateField(Request $request, NodeInterface $node) { + if ($node->bundle() !== 'ligne_comptable') { + return new JsonResponse(['error' => 'Type de contenu invalide.'], 404); + } + + $csrfToken = $request->headers->get('X-CSRF-Token', ''); + if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) { + return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403); + } + + $data = json_decode($request->getContent(), TRUE); + $field = is_array($data) ? ($data['field'] ?? NULL) : NULL; + $value = trim((string) (is_array($data) ? ($data['value'] ?? '') : '')); + if (!isset(self::INLINE_EDITABLE_FIELDS[$field])) { + return new JsonResponse(['error' => 'Champ invalide.'], 400); + } + $fieldName = self::INLINE_EDITABLE_FIELDS[$field]; + + if ($field === 'client') { + // Only an existing "Client" term is accepted -- the front-end + // offers this as a datalist of known names, not free text, so a + // non-match almost certainly means a typo rather than a genuinely + // new client that should be created on the fly. + if ($value === '') { + $node->set('field_client', NULL); + } + else { + $terms = $this->entityTypeManager()->getStorage('taxonomy_term') + ->loadByProperties(['vid' => 'client', 'name' => $value]); + if (!$terms) { + return new JsonResponse(['error' => 'Client inconnu : "' . $value . '". Utilisez un nom existant dans la liste.'], 400); + } + $node->set('field_client', reset($terms)->id()); + } + } + 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. + \Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE); + try { + $node->save(); + } + catch (EntityStorageException $e) { + return new JsonResponse(['error' => $e->getMessage()], 422); + } + finally { + \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; + + return new JsonResponse([ + 'success' => TRUE, + 'field' => $field, + 'value' => $newValue, + ]); + } + } diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index ddaeea6..74be3e7 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -144,7 +144,19 @@ {{ formatDate(item.date) }} - {{ item.client || '—' }} + + + {{ item.client || '—' }} + + {{ item.facture }} + - {{ item.libelle }} - {{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} - ⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }}) - {{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item).kind) }} + + {{ formatEur(item.montant_ht) }} {{ formatEur(item.montant_ttc) }}