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');