diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 328caab..a51f678 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -57,6 +57,12 @@ // more than enough rounds to walk the entire MIN_LOADABLE_DATE.. // MAX_LOADABLE_DATE span at EXTEND_MONTHS per round. const MAX_ENSURE_SCROLLABLE_ROUNDS = 30; + // How often to poll for other users' changes (see pollForChanges()) -- + // a plain setInterval rather than anything push-based (no Socket.io/ + // websocket infra for a 6-person internal tool); this is far more + // frequent than table content actually changes, but the requests are + // cheap and it keeps everyone's view close to live. + const POLL_INTERVAL_MS = 8000; // Absolute bounds on how far the window can extend. Without these, // loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward @@ -148,6 +154,23 @@ return fetchLignes(API_BASE + '?' + params.toString()); } + // Any ligne_comptable changed after `tsSeconds` (a Unix timestamp) -- + // backs the polling in pollForChanges() below, so one user's edit + // shows up in everyone else's table without a manual reload. `changed` + // is stored as a Unix timestamp internally; JSON:API exposes it as an + // ISO 8601 string in *responses* but only accepts the raw timestamp as + // a *filter value* -- confirmed live (an ISO string filter value + // 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('filter[changedFilter][condition][path]', 'changed'); + params.set('filter[changedFilter][condition][operator]', '>'); + params.set('filter[changedFilter][condition][value]', String(tsSeconds)); + params.set('sort', 'changed'); + return fetchLignes(API_BASE + '?' + params.toString()); + } + async function fetchClientNames() { // page[limit]=200 is silently clamped to core's hard cap of 50 by // JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms, @@ -186,32 +209,45 @@ // full edit form. Fetches a fresh CSRF token each time (core's // /session/token) rather than caching one -- this is an occasional // action, not worth the complexity of handling a stale cached token. - async function updateLigneType(nid, type) { + // `changed` (the row's last-known changed timestamp) lets the server + // reject a save-over-a-stale-view instead of silently overwriting + // someone else's concurrent edit -- see LedgerActionsController:: + // checkConflict(). The thrown error's `.status` lets callers tell a + // 409 conflict apart from any other failure. + async function updateLigneType(nid, type, changed) { const tokenRes = await fetch('/session/token'); const token = await tokenRes.text(); const res = await fetch('/lignes/' + nid + '/type', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, - body: JSON.stringify({ type }), + body: JSON.stringify({ type, changed }), }); const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); + if (!res.ok) { + const err = new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); + err.status = res.status; + throw err; + } 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) { + // opening the full edit form. Same fresh-token-per-call and conflict- + // detection reasoning as updateLigneType() above. + async function updateLigneField(nid, field, value, changed) { 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 }), + body: JSON.stringify({ field, value, changed }), }); const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); + if (!res.ok) { + const err = new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); + err.status = res.status; + throw err; + } return json; } @@ -291,6 +327,13 @@ rows.push({ id: node.id, nid: attrs.drupal_internal__nid, + // ISO 8601 string (ok as an opaque token here, only ever + // compared for equality/passed straight back to the server) -- + // sent back on the next inline edit so the server can detect + // "someone else saved this line in between" and reject instead + // of silently overwriting. See LedgerActionsController:: + // checkConflict() and pollForChanges()/mergeChangedRows() below. + changed: attrs.changed, date: attrs.field_date_ligne, type: attrs.field_type_ligne, client: clientTerm ? clientTerm.attributes.name : null, @@ -352,6 +395,9 @@ // group again for every other sortie that happens to link to // the same entrée. resolvedLinkGroups: new Set(), + // Unix timestamp (seconds) -- everything with `changed` after + // this has appeared since the last poll. See pollForChanges(). + lastPollTs: null, // Which row's type badge is currently showing its inline