From d8e7272800e1724cce91b171b0ba6ef4e82685b3 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Fri, 23 Oct 2026 11:15:24 +0000 Subject: [PATCH] fix(admin): show lead refresh progress and prevent stale responses --- internal/api/admin_inbox.go | 1 + internal/api/admin_leads_refresh_test.go | 20 ++++++ internal/api/adminui/index.html | 7 +- internal/api/adminui/leads.css | 8 +++ internal/api/adminui/leads.js | 47 ++++++++++++-- web/scripts/test-admin-leads-refresh.mjs | 82 ++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 internal/api/admin_leads_refresh_test.go create mode 100644 web/scripts/test-admin-leads-refresh.mjs diff --git a/internal/api/admin_inbox.go b/internal/api/admin_inbox.go index df27c8f..fa85a91 100644 --- a/internal/api/admin_inbox.go +++ b/internal/api/admin_inbox.go @@ -29,6 +29,7 @@ func pageParams(r *http.Request) (limit, offset int) { // ---------- Заявки ---------- func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + w.Header().Set("Cache-Control", "no-store") limit, offset := pageParams(r) q := r.URL.Query() var from, to *time.Time diff --git a/internal/api/admin_leads_refresh_test.go b/internal/api/admin_leads_refresh_test.go new file mode 100644 index 0000000..cac1426 --- /dev/null +++ b/internal/api/admin_leads_refresh_test.go @@ -0,0 +1,20 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminLeadsResponseIsNotCacheable(t *testing.T) { + // Invalid dates return before storage access; no database or patient data needed. + a := &API{} + w := httptest.NewRecorder() + a.adminLeads(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/leads?from=invalid", nil), nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } +} diff --git a/internal/api/adminui/index.html b/internal/api/adminui/index.html index 66fe08e..2c33c5f 100644 --- a/internal/api/adminui/index.html +++ b/internal/api/adminui/index.html @@ -6,7 +6,7 @@ DANA — админка - + @@ -93,8 +93,9 @@
- + +

@@ -2408,7 +2409,7 @@ function debounce(fn, ms) { - + diff --git a/internal/api/adminui/leads.css b/internal/api/adminui/leads.css index 3e6837e..acc2eee 100644 --- a/internal/api/adminui/leads.css +++ b/internal/api/adminui/leads.css @@ -43,6 +43,14 @@ body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs .ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .ld-export:disabled { opacity: .5; cursor: not-allowed; } .ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; } +.ld-refresh span { display: inline-block; } +.ld-refresh[aria-busy="true"] span { animation: ld-refresh-spin .8s linear infinite; } +.ld-refresh:disabled { opacity: .65; cursor: wait; } +@keyframes ld-refresh-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .ld-refresh[aria-busy="true"] span { animation: none; } } +.ld-updated { margin: -9px 0 13px; color: #637891; font-size: 12px; } +.ld-updated:empty { display: none; } +.ld-updated--error { padding: 10px 12px; color: #92291f; background: #ffe3df; border-radius: 9px; } .ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; } .ld-table { min-width: 830px; } .ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; } diff --git a/internal/api/adminui/leads.js b/internal/api/adminui/leads.js index 18d3174..30e042d 100644 --- a/internal/api/adminui/leads.js +++ b/internal/api/adminui/leads.js @@ -2,17 +2,52 @@ Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */ const Leads = { - offset: 0, limit: 30, total: 0, loaded: false, visible: [], + offset: 0, limit: 30, total: 0, loaded: false, visible: [], requestId: 0, controller: null, - async load() { + async load({ notify = false } = {}) { + const requestId = ++Leads.requestId; + Leads.controller?.abort(); + const controller = new AbortController(); + Leads.controller = controller; + const refresh = $('#ld-refresh'), updated = $('#ld-updated'); + refresh.disabled = true; + refresh.setAttribute('aria-busy', 'true'); + $('#ld-body').setAttribute('aria-busy', 'true'); + updated.classList.remove('ld-updated--error'); + updated.textContent = 'Обновляем заявки…'; + let timedOut = false; + const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20000); const status = $('#ld-status').value; const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset }); if ($('#ld-from').value) params.set('from', $('#ld-from').value); if ($('#ld-to').value) params.set('to', $('#ld-to').value); - let r; try { - r = await api(`/admin/leads?${params}`); - } catch (e) { toast(e.message, true); return; } + const r = await api(`/admin/leads?${params}`, { signal: controller.signal, cache: 'no-store' }); + if (requestId !== Leads.requestId || controller.signal.aborted) return false; + Leads.render(r, status); + Leads.loaded = true; + updated.textContent = 'Обновлено в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + if (notify) toast('Список заявок обновлён'); + return true; + } catch (e) { + if (requestId !== Leads.requestId) return false; + const message = timedOut ? 'Истекло время ожидания. Попробуйте обновить заявки ещё раз.' : e.message; + updated.classList.add('ld-updated--error'); + updated.textContent = `Не удалось обновить заявки. ${message}${Leads.loaded ? ' Показаны предыдущие данные.' : ''}`; + toast(message, true); + return false; + } finally { + clearTimeout(timeout); + if (requestId === Leads.requestId) { + Leads.controller = null; + refresh.disabled = false; + refresh.setAttribute('aria-busy', 'false'); + $('#ld-body').setAttribute('aria-busy', 'false'); + } + } + }, + + render(r, status) { Leads.total = r.meta?.total || 0; const counts = r.meta?.counts || {}; for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0], @@ -148,7 +183,7 @@ $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget $('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); }; $('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300)); for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); }; -$('#ld-refresh').onclick = () => Leads.load(); +$('#ld-refresh').onclick = () => { Leads.offset = 0; return Leads.load({ notify: true }); }; $('#ld-export').onclick = () => Leads.exportVisible(); $('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); }; $('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); }; diff --git a/web/scripts/test-admin-leads-refresh.mjs b/web/scripts/test-admin-leads-refresh.mjs new file mode 100644 index 0000000..497eb0e --- /dev/null +++ b/web/scripts/test-admin-leads-refresh.mjs @@ -0,0 +1,82 @@ +// Embedded admin UI with a fake API and fictional leads; never connects to a server. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import { parseHTML } from 'linkedom'; +const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8'); +const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8'); +const { document } = parseHTML(html); +const $ = (selector) => document.querySelector(selector); +const select = $('#ld-status'); +Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] }); +function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } } +selectStatus('new'); +const pending = [], notifications = [], timers = new Map(); +let timerId = 0; +const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console, + el: (tag, props = {}) => Object.assign(document.createElement(tag), props), + api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })), + toast: (message, bad) => notifications.push({ message, bad }), + debounce: (fn) => fn, plural: () => 'заявок', + LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] }, + setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; }, + clearTimeout: (id) => timers.delete(id), +}); +vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context); +const leads = context.testLeads; +const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } }); +leads.offset = 60; +const first = $('#ld-refresh').onclick(); +assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state'); +assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true'); +assert.match($('#ld-updated').textContent, /Обновляем/); +assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results'); +assert.equal(pending[0].options.cache, 'no-store'); +pending[0].resolve(response()); +await first; +assert.match($('#ld-body').textContent, /Тестовая заявка/); +assert.equal($('#ld-refresh').disabled, false); +assert.match($('#ld-updated').textContent, /Обновлено/); +assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён')); +assert.equal(timers.size, 0); + +// Filters win over an older request even if a transport ignores cancellation. +const older = leads.load(); +selectStatus('done'); +const newer = leads.load(); +assert.equal(pending[1].options.signal.aborted, true); +pending[2].resolve(response('Свежий ответ')); +await newer; +pending[1].resolve(response('УСТАРЕВШИЙ ответ')); +await older; +assert.match($('#ld-body').textContent, /Свежий ответ/); +assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/); + +// Preserve filters, but reset pagination for an explicit refresh. +$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23'; +leads.offset = 30; +const refresh = $('#ld-refresh').onclick(); +const params = new URLSearchParams(pending[3].path.split('?')[1]); +assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка'); +assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23'); +pending[3].reject(new Error('Тестовая ошибка сервера')); +await refresh; +assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button'); +assert.match($('#ld-updated').textContent, /Не удалось/); +assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results'); +assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка'))); + +const retry = $('#ld-refresh').onclick(); +pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } }); +await retry; +assert.match($('#ld-body').textContent, /Заявки не найдены/); +assert.equal($('#ld-refresh').disabled, false); + +const timeout = $('#ld-refresh').onclick(); +for (const fn of [...timers.values()]) fn(); +pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); +await timeout; +assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck'); +assert.match($('#ld-updated').textContent, /время ожидания/i); +assert.equal(timers.size, 0); +console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');
ДатаИмяТелефонФормаСообщениеСтатусДействие