Списки: период, сортировка, подгрузка; анкета участника — строкой исходящих

«Исходящие», «Входящие», «Журнал обмена»: быстрые периоды (сегодня, 7, 30
дней, всё время), даты с–по, сортировка (время, инвестор/контрагент,
направление, статус), первые 50 строк и подгрузка при прокрутке
(part=rows&offset=N). Раньше показывались только последние 200/100.

Анкета участника — исходящий пакет в НРД: строка в «Исходящих» и
«Журнале» с ответом НРД (принята / отклонена с кодом / ждёт), вместо
плашки над списком. Отправки копятся в participant-regs.json (прежний
participant-reg.json подхватывается). userTemplate — функции текущего
пользователя и для порций строк.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-22 10:50:43 +03:00
parent 890e575d3d
commit 60b4d46277
13 changed files with 522 additions and 92 deletions
@@ -1,13 +1,31 @@
{{define "content"}}
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
{{participantRegHTML}}
<div class="card">
<h2>Журнал заявок ({{len .Items}})</h2>
{{if .Items}}
<h2>Журнал заявок <span class="muted">({{.List.Total}})</span></h2>
{{template "listTools" .List}}
<table>
<thead><tr><th>Создана</th><th>ID</th><th>GUID M2M</th><th>Инвестор</th><th>ЦБ</th><th>Передающий</th><th>Принимающий</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tbody id="claims-body">
{{range .Rows}}{{template "claimrow" .}}{{end}}
</tbody>
</table>
{{if not .Rows}}<p class="muted">{{if .List.Q.FromS}}За выбранный период ничего нет.{{else}}Пусто.{{end}}</p>{{end}}
{{template "listMore" .List}}
</div>
{{end}}
{{/* Строка «Исходящих»: заявка на перевод или анкета участника M2M (исходящий пакет в НРД и ответ на него). */}}
{{define "claimrow"}}{{if .R}}{{with .R}}<tr>
<td>{{.SentAt.Format "02.01 15:04:05"}}</td>
<td><code>{{.PkgID}}</code></td>
<td><span class="muted">—</span></td>
<td><strong>Анкета участника M2M</strong>{{if .Name}}<br><span style="font-size:11px">{{.Name}}</span>{{end}}</td>
<td><span class="muted">—</span></td>
<td><span style="font-size:11px">{{index orgEff "Name"}}</span></td>
<td><span style="font-size:11px">НРД</span></td>
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if .RespText}}<div class="live live-{{if eq .StatusClass "rejected"}}bad{{else}}ok{{end}}">{{.RespAt.Format "02.01 15:04"}} · {{.RespText}}</div>{{end}}</td>
<td><a href="/admin/setup/participant-form">анкета →</a></td>
</tr>{{end}}{{else}}{{with .C}}
<tr>
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
<td><code>{{short 8 .ID}}</code></td>
@@ -18,12 +36,4 @@
<td>{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}<span style="font-size:11px">{{$t}}</span><br>{{end}}<code style="font-size:11px">{{.ReceivingDepositoryINN}}</code></td>
<td>{{$l := liveOut .}}<span class="badge {{.Status}}">{{ruState .Status}}</span><div class="live live-{{$l.Kind}}" data-live="out:{{.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a>{{if and (or (eq .Status "draft") (eq .Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.ID}}/delete-draft" style="margin:4px 0 0" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">Удалить</button></form>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Пусто.</p>
{{end}}
</div>
{{end}}
</tr>{{end}}{{end}}{{end}}
@@ -5,26 +5,17 @@
</div>
<div class="card">
<h2>Список ({{len .Items}})</h2>
{{if .Items}}
<h2>Список <span class="muted">({{.List.Total}})</span></h2>
{{template "listTools" .List}}
<table>
<thead><tr><th>Получен</th><th>GUID</th><th>От кого</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{msk .ReceivedAt}}</td>
<td><code>{{short 8 .GUID}}</code></td>
<td>{{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}<strong>{{$nm}}</strong><br>{{end}}<code>{{.SenderCode}}</code>{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}</td>
<td>{{len .Securities}}</td>
<td>{{$s := inStatus .}}<span class="badge {{$s.Class}}">{{$s.Text}}</span>{{if $s.Outcome}} <span class="badge {{if eq $s.Outcome "Принято"}}confirmed{{else if eq $s.Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{$s.Outcome}}</span>{{end}}{{if .DepoSummary}}<br><span class="badge {{if eq .DepoRecommend "confirm"}}confirmed{{else if eq .DepoRecommend "reject"}}rejected{{else}}awaiting_decision{{end}}" style="font-size:11px;margin-top:4px" title="Сверка с DEPO — {{if devOn}}эмуляция ответа{{else}}рекомендация{{end}}, ничего не отправлено">сверка: {{.DepoSummary}}</span>{{end}}{{$l := liveIn .}}<div class="live live-{{$l.Kind}}" data-live="in:{{.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/incoming/{{.ID}}">{{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →</a></td>
</tr>
{{end}}
<tbody id="in-body">
{{range .Rows}}{{template "inrow" .}}{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.</p>
{{end}}
{{if not .Rows}}{{if .List.Q.FromS}}<p class="muted">За выбранный период ничего нет.</p>{{else}}
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.</p>{{end}}{{end}}
{{template "listMore" .List}}
<p class="muted" style="font-size:12px;margin-top:10px">🔄 Страница обновляется автоматически при появлении нового входящего.</p>
</div>
<script>
@@ -49,3 +40,12 @@
})();
</script>
{{end}}
{{define "inrow"}}<tr>
<td>{{msk .ReceivedAt}}</td>
<td><code>{{short 8 .GUID}}</code></td>
<td>{{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}<strong>{{$nm}}</strong><br>{{end}}<code>{{.SenderCode}}</code>{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}</td>
<td>{{len .Securities}}</td>
<td>{{$s := inStatus .}}<span class="badge {{$s.Class}}">{{$s.Text}}</span>{{if $s.Outcome}} <span class="badge {{if eq $s.Outcome "Принято"}}confirmed{{else if eq $s.Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{$s.Outcome}}</span>{{end}}{{if .DepoSummary}}<br><span class="badge {{if eq .DepoRecommend "confirm"}}confirmed{{else if eq .DepoRecommend "reject"}}rejected{{else}}awaiting_decision{{end}}" style="font-size:11px;margin-top:4px" title="Сверка с DEPO — {{if devOn}}эмуляция ответа{{else}}рекомендация{{end}}, ничего не отправлено">сверка: {{.DepoSummary}}</span>{{end}}{{$l := liveIn .}}<div class="live live-{{$l.Kind}}" data-live="in:{{.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/incoming/{{.ID}}">{{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →</a></td>
</tr>{{end}}
@@ -1,14 +1,16 @@
{{define "content"}}
<div class="card">
<h2>Журнал обмена <span class="muted">(<span id="journal-count">{{len .Rows}}</span>)</span></h2>
<h2>Журнал обмена <span class="muted">(<span id="journal-count">{{.List.Total}}</span>)</span></h2>
<p class="muted">Все переводы в одной ленте — входящие и исходящие. Отдельные виды: <a href="/admin/claims">Исходящие</a> · <a href="/admin/incoming">Входящие</a>. <span class="muted" id="journal-live-hint"><span class="dot"></span>новые заявки появляются автоматически</span></p>
{{template "listTools" .List}}
<table>
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody id="journal-body">
{{range .Rows}}{{template "journalrow" .}}{{end}}
<tr id="journal-empty"{{if .Rows}} hidden{{end}}><td colspan="6" class="muted">Переводов пока нет.</td></tr>
<tr id="journal-empty"{{if .Rows}} hidden{{end}}><td colspan="6" class="muted">{{if .List.Q.FromS}}За выбранный период ничего нет.{{else}}Переводов пока нет.{{end}}</td></tr>
</tbody>
</table>
{{template "listMore" .List}}
</div>
{{end}}
@@ -184,6 +184,13 @@ tbody tr:hover td { background:var(--accent-weak); }
.badge.awaiting_decision, .badge.manual_approval { background:var(--warn-weak); color:var(--warn); }
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background:var(--ok-weak); color:var(--ok); }
.badge.rejected, .badge.timed_out, .badge.err { background:var(--err-weak); color:var(--err); }
.list-tools{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin:0 0 12px;font-size:13px}
.list-tools label{display:inline-flex;align-items:center;gap:6px;margin:0;color:var(--muted)}
.list-tools input[type=date],.list-tools select{padding:4px 8px;font-size:13px}
.lt-presets{display:inline-flex;gap:4px;flex-wrap:wrap}
.lt-p{padding:3px 10px;border:1px solid var(--border);border-radius:999px;text-decoration:none;color:var(--text)}
.lt-p.on{background:var(--accent);border-color:var(--accent);color:#fff}
.list-more{text-align:center;padding:12px;font-size:13px;cursor:pointer}
.badge.ok { background:var(--ok-weak); color:var(--ok); }
/* ---- Код / preformatted ---- */
@@ -981,6 +988,36 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
setInterval(tick, 8000);
if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500);
})();
// Списки: следующие строки подгружаются при прокрутке (тем же адресом с part=rows&offset=N).
(function(){
var els = document.querySelectorAll('[data-list-more]');
if (!els.length || !window.fetch) return;
els.forEach(function(el){
var body = document.getElementById(el.getAttribute('data-body')), busy = false;
if (!body) return;
function near(){ return el.getBoundingClientRect().top < window.innerHeight + 300; }
function load(){
if (busy || el.hidden) return;
busy = true;
var q = el.getAttribute('data-q') || '';
fetch(location.pathname + '?' + (q ? q + '&' : '') + 'part=rows&offset=' + el.getAttribute('data-next'), {cache:'no-store', headers:{'X-Requested-With':'fetch'}})
.then(function(r){ if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function(d){
var tmp = document.createElement('tbody');
tmp.innerHTML = (d.html || '').trim();
while (tmp.firstElementChild) body.appendChild(tmp.firstElementChild);
el.setAttribute('data-next', d.next);
if (!d.more) el.hidden = true;
busy = false;
if (!el.hidden && near()) load();
})
.catch(function(){ busy = false; el.textContent = 'Не удалось загрузить — нажмите, чтобы повторить'; });
}
el.addEventListener('click', load);
if ('IntersectionObserver' in window) new IntersectionObserver(function(es){ if (es[0].isIntersecting) load(); }, {rootMargin:'300px'}).observe(el);
else if (near()) load();
});
})();
// Журнал обмена: новые заявки подгружаются и всплывают сверху без перезагрузки страницы.
(function(){
var body = document.getElementById('journal-body');
@@ -1002,6 +1039,7 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
}
function tick(){
if (window.bjSelecting && window.bjSelecting()) return; // идёт выделение текста — новые строки подставим позже
if (location.search) return; // выбран период или сортировка — сверху не подставляем
fetch('/admin/journal/rows?since=' + encodeURIComponent(maxTime()), {cache:'no-store'})
.then(function(r){ return r.json(); })
.then(function(d){
@@ -0,0 +1,11 @@
{{/* Панель списка (период, сортировка) и подгрузка при прокрутке — «Исходящие», «Входящие», «Журнал обмена». */}}
{{define "listTools"}}<form method="get" action="{{.Path}}" class="list-tools">
<span class="lt-presets">{{range .Presets}}<a href="{{.Href}}" class="lt-p{{if .On}} on{{end}}">{{.Label}}</a>{{end}}</span>
<label>с <input type="date" name="from" value="{{.Q.FromS}}" onchange="this.form.submit()"></label>
<label>по <input type="date" name="to" value="{{.Q.ToS}}" onchange="this.form.submit()"></label>
<label>сортировка <select name="sort" onchange="this.form.submit()">{{range .Sorts}}<option value="{{.Key}}"{{if .On}} selected{{end}}>{{.Label}}</option>{{end}}</select></label>
<select name="dir" onchange="this.form.submit()" aria-label="Порядок"><option value="desc"{{if not .Q.Asc}} selected{{end}}>{{if eq .Q.Sort "time"}}сначала новые{{else}}Я → А{{end}}</option><option value="asc"{{if .Q.Asc}} selected{{end}}>{{if eq .Q.Sort "time"}}сначала старые{{else}}А → Я{{end}}</option></select>
<span class="muted lt-n">найдено: {{.Total}}</span>
</form>{{end}}
{{define "listMore"}}<div class="list-more muted" data-list-more data-body="{{.Body}}" data-q="{{.Q.Query}}" data-next="{{.Next}}"{{if not .More}} hidden{{end}}>Загружаю ещё…</div>{{end}}