diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 617bd66..332d081 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -228,13 +228,13 @@ html.gin--dark-mode #figli-home-app { #figli-home-app th:nth-child(6), #figli-home-app td.figli-libelle { - width: 15%; + width: 10%; white-space: normal; } #figli-home-app th:nth-child(7), #figli-home-app td.figli-flag-cell { - width: 8%; + width: 6%; white-space: normal; } @@ -424,7 +424,7 @@ html.gin--dark-mode #figli-home-app { scaled down to fit. */ #figli-home-app th:nth-child(2), #figli-home-app td:nth-child(2) { - width: 6%; + width: 5%; white-space: nowrap; } #figli-home-app th:nth-child(3), @@ -437,15 +437,15 @@ html.gin--dark-mode #figli-home-app { } #figli-home-app th:nth-child(5), #figli-home-app td:nth-child(5) { - width: 5.5%; + width: 5%; white-space: nowrap; } #figli-home-app .amount:not(.compte-col) { - width: 5.5%; + width: 6.2%; white-space: nowrap; } #figli-home-app .amount.compte-col { - width: 3.5%; + width: 4.3%; white-space: nowrap; } @@ -556,6 +556,54 @@ html.gin--dark-mode #figli-home-app { border-color: var(--figli-border); color: var(--figli-text); } +/* Signalement modal -- much smaller than the entrées/sorties drill-down + above (a short list of tags, not a table), so it gets its own narrow + width instead of the 96vw default. */ +#figli-home-app .figli-flag-modal { + width: 24rem; + max-width: 90vw; +} +#figli-home-app .figli-flag-modal-body { + padding: 0.75rem 1rem 1rem; +} +#figli-home-app .figli-flag-modal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0; + border-bottom: 1px solid var(--figli-border); +} +#figli-home-app .figli-flag-remove { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--figli-text-light); + cursor: pointer; + font-size: 0.8rem; + padding: 0.1rem 0.4rem; + flex: none; +} +#figli-home-app .figli-flag-remove:hover { + background: var(--figli-bg-alt); + border-color: var(--figli-border); + color: var(--figli-error); +} +#figli-home-app .figli-flag-modal-add { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} +#figli-home-app .figli-flag-modal-add input { + flex: 1; + font-size: 0.85rem; + padding: 0.3rem 0.5rem; + border: 1px solid var(--figli-border); + border-radius: 4px; + background: var(--figli-bg); + color: var(--figli-text); +} + #figli-home-app .figli-modal-body { overflow-y: auto; overflow-x: hidden; diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index fc012ed..8098060 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -433,6 +433,12 @@ // its inline instead of the plain text -- only one at a // time, mirroring editingTypeId above. { id, field } or null. editingCell: null, + // Row currently open in the signalement modal (see + // openFlagModal()) -- a small standalone object, not the row + // itself, and the text of a not-yet-added tag typed into that + // modal's own input. + flagModalItem: null, + flagModalNewTag: '', // Fenêtre glissante. windowStart: null, windowEnd: null, @@ -895,6 +901,13 @@ setInterval(() => this.pollForChanges(), POLL_INTERVAL_MS); }, async pollForChanges() { + // setInterval doesn't wait for a previous callback's promise to + // settle before scheduling the next tick -- without this guard, + // a poll that takes longer than POLL_INTERVAL_MS to respond + // (slow network, tab was backgrounded and just resumed) would + // overlap with the next one, both merging into `rows` at once. + if (this._pollInFlight) return; + this._pollInFlight = true; try { const { data, includedMap } = await fetchChangedSince(this.lastPollTs); if (data.length) { @@ -903,6 +916,8 @@ this.lastPollTs = Math.floor(Date.now() / 1000); } catch (err) { // silent -- see comment above. + } finally { + this._pollInFlight = false; } }, // Patches already-loaded rows in place (mutates each row's own @@ -975,46 +990,84 @@ return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field; }, // Same optimistic-patch-then-close pattern as saveType() above -- - // client/facture/libellé/flag don't affect linkability or + // client/facture/libellé don't affect linkability or // field_entree_liee, so there's nothing else to reconcile via - // reloadWindow() here. + // reloadWindow() here. Signalement (field_flag) isn't handled here + // -- it's multi-value and edited through its own modal, see + // openFlagModal()/saveFlagList() below. async saveCell(item, field, event) { const newValue = event.target.value.trim(); this.editingCell = null; - // 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; + if (newValue === (item[field] || '')) return; try { const result = await updateLigneField(item.nid, field, newValue, item.changed); const row = this.rows.find((r) => r.id === item.id); if (row) { - if (field === 'flag') { - row.flags = result.value; - row.hasFlag = result.value.length > 0; - } else { - row[field] = result.value; - } + row[field] = result.value; row.changed = result.changed; } - // A client/tag name with no existing match gets created on the - // fly (server-side) rather than rejected -- reflect it in the + // A client 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()/ - // fetchFlagNames(). + // reload picks up the new taxonomy term via fetchClientNames(). 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); } }, + // Signalement modal: one tag per line (add/remove), not a single + // comma-separated text field -- easier to see what's already there + // and remove just one without retyping the rest. flagModalItem is + // its own small reactive object (not aliased to the row in `rows`) + // so the modal keeps working even if the underlying row gets + // trimmed out of the sliding window while it's open; saveFlagList() + // updates both explicitly. + openFlagModal(item) { + this.typeUpdateError = null; + this.flagModalItem = { id: item.id, nid: item.nid, date: item.date, client: item.client, changed: item.changed, flags: item.flags.slice() }; + this.flagModalNewTag = ''; + }, + closeFlagModal() { + this.flagModalItem = null; + this.flagModalNewTag = ''; + }, + async addFlagTag() { + const name = this.flagModalNewTag.trim(); + if (!name || this.flagModalItem.flags.includes(name)) { + this.flagModalNewTag = ''; + return; + } + this.flagModalNewTag = ''; + await this.saveFlagList([...this.flagModalItem.flags, name]); + }, + async removeFlagTag(name) { + await this.saveFlagList(this.flagModalItem.flags.filter((f) => f !== name)); + }, + async saveFlagList(newFlags) { + const item = this.flagModalItem; + try { + const result = await updateLigneField(item.nid, 'flag', newFlags.join(', '), item.changed); + item.flags = result.value; + item.changed = result.changed; + const row = this.rows.find((r) => r.id === item.id) || this.groupExtraRows.find((r) => r.id === item.id); + if (row) { + row.flags = result.value; + row.hasFlag = result.value.length > 0; + row.changed = result.changed; + } + 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); + this.closeFlagModal(); + } + } + }, // 409 conflict from saveType()/saveCell() above -- someone else // saved this exact line in between. Re-fetch just this one node // and patch it in place, so the row reflects their change right @@ -1175,7 +1228,12 @@ this.rows = [...newRows, ...this.rows]; await this.$nextTick(); // Prepending pushes existing content down -- keep whatever - // the user was looking at in the same visual spot. + // the user was looking at in the same visual spot. This + // write fires its own native 'scroll' event, re-entering + // checkEdges() -- harmless: loadingOlder is still true at + // this point (reset only in the `finally` below), so that + // event finds the guard there already blocking a redundant + // call, no separate suppression needed. if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight; } @@ -1246,8 +1304,21 @@ // waiting for a scroll that can never happen with nothing to // scroll. const notScrollable = wrap.scrollHeight <= wrap.clientHeight; - if (notScrollable || wrap.scrollTop < threshold) this.loadOlder(); - if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer(); + // Guarded by loadingOlder/loadingNewer, not just called + // unconditionally: loadOlder()/loadNewer() queue instead of + // dropping a call that arrives while one's already running (see + // _queueWindowOp) -- correct for the *distinct* triggers that fix + // was about (several filters changing in the same tick), but a + // single continuous scroll gesture fires many native 'scroll' + // events, and checkEdges() runs on every one of them. Without + // this guard, every one of those events near an edge queued its + // own loadOlder()/loadNewer() call, and every queued call did a + // real fetch + scroll compensation regardless of whether the + // very first one already moved the window away from the edge -- + // that pileup is what looked like the same request firing over + // and over, dragging the scroll position around unpredictably. + if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder(); + if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer(); }, // Whether compte/client/type/écarts thin filteredRows down from // whatever's actually loaded -- loadOlder()/loadNewer() use this to 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 34bb269..da5948a 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 @@ -219,18 +219,7 @@
Aucun signalement pour cette ligne.
+