Журнал: онлайн-статус «чего ждёт заявка», ссылка из ручного ввода в журнал, правки формы

- livestatus.go: одна фраза состояния для исходящих (сделка M2M + черновик/подача/MS010
  поручения 16) и входящих (наше решение → поручение 16/1); признак «в работе»
- строки журналов и карточки показывают её и обновляют по /admin/journal/live каждые 8 с,
  пока заявка в работе; завершённые и отклонённые не опрашиваются
- ручной ввод: у отправленного поручения ссылка «в журнале →» и тот же онлайн-статус;
  вид документа занимает две колонки
- выбор участника: по ИНН и коду с кнопки, а не по номеру строки; Enter выбирает только
  при единственном совпадении

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 11:55:39 +03:00
parent 7ac41c0335
commit 46e55e1277
11 changed files with 190 additions and 14 deletions
@@ -31,6 +31,7 @@
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span></h2>
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}"{{if $l.Active}} data-active="1"{{end}}>{{$l.Text}}</div>
<table>
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
<tr><td class="muted">Обновлена</td><td>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
@@ -14,7 +14,7 @@
<td>{{len .Securities}}</td>
<td>{{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}<span style="font-size:11px">{{$f}}</span><br>{{end}}<code style="font-size:11px">{{.TransferringDepositoryINN}}</code></td>
<td>{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}<span style="font-size:11px">{{$t}}</span><br>{{end}}<code style="font-size:11px">{{.ReceivingDepositoryINN}}</code></td>
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></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"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/claims/{{.ID}}">детали →</a></td>
</tr>
{{end}}
@@ -16,7 +16,7 @@
<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}}</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"{{end}}>{{$l.Text}}</div></td>
<td><a href="/admin/incoming/{{.ID}}">{{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →</a></td>
</tr>
{{end}}
@@ -21,6 +21,7 @@
<h2>Входящий перевод <code>{{short 8 .It.GUID}}</code>
{{if $answered}}<span class="badge confirmed">отвечено</span>{{else}}<span class="badge awaiting_decision">ждёт решения</span>{{end}}
</h2>
{{$l := liveIn .It}}<div class="live live-big live-{{$l.Kind}}" data-live="in:{{.It.ID}}"{{if $l.Active}} data-active="1"{{end}}>{{$l.Text}}</div>
<table>
<tr><td style="width:220px" class="muted">Запрос сформирован (отправитель)</td><td>{{msk .It.RequestCreated}}</td></tr>
<tr><td class="muted">Забрали из шлюза (получено нами)</td><td>{{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} <span class="muted" style="font-size:12px">· прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}</span>{{end}}</td></tr>
@@ -12,7 +12,7 @@
<td>{{if eq .DirKind "out"}}<span class="badge" style="background:#e7f0fb;color:#1f4e79">↗ Исходящий</span>{{else}}<span class="badge" style="background:#e6f4ea;color:#1a7f37">↘ Входящий</span>{{end}}</td>
<td>{{.Party}}</td>
<td>{{.SecN}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}<div class="live live-{{.Live.Kind}}" data-live="{{.LiveKey}}"{{if .Live.Active}} data-active="1"{{end}}>{{.Live.Text}}</div></td>
<td><a href="{{.Href}}" class="muted">открыть →</a></td>
</tr>
{{end}}
@@ -7,6 +7,8 @@
.mf-grid label{display:block;font-size:12px;color:var(--muted);margin-bottom:3px}
.mf-grid input,.mf-grid select,.mf-grid textarea{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px;box-sizing:border-box}
.mf-grid .wide{grid-column:1/-1}
.mf-grid .w2{grid-column:span 2}
@media (max-width:520px){.mf-grid .w2{grid-column:auto}}
.mf-h{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:18px 0 8px}
.mf-sec{display:grid;grid-template-columns:1.2fr .9fr 2fr .7fr auto;gap:8px;align-items:center;margin-bottom:6px}
.mf-sec input{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px;box-sizing:border-box}
@@ -82,8 +84,8 @@
<td>{{.O.FullName}}</td>
<td class="num">{{len .O.Securities}}</td>
<td>{{if .O.RecvName}}{{.O.RecvName}}{{else}}ИНН {{.O.RecvINN}}{{end}}</td>
<td><span class="badge {{if eq .State "done"}}done{{else if or (eq .State "rejected") (eq .State "declined")}}rejected{{else if eq .State "sent"}}awaiting_decision{{else}}draft{{end}}">{{.StateRu}}</span></td>
<td><a href="/admin/manual/{{.O.ID}}">открыть →</a></td>
<td><span class="badge {{if eq .State "done"}}done{{else if or (eq .State "rejected") (eq .State "declined")}}rejected{{else if eq .State "sent"}}awaiting_decision{{else}}draft{{end}}">{{.StateRu}}</span>{{if .O.DealID}}<div class="live live-wait" data-live="out:{{.O.DealID}}" data-active="1">…</div>{{end}}</td>
<td style="white-space:nowrap"><a href="/admin/manual/{{.O.ID}}">открыть →</a>{{if .O.DealID}} · <a href="/admin/claims/{{.O.DealID}}">в журнале →</a>{{end}}</td>
</tr>
{{end}}
</tbody>
@@ -317,7 +319,7 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
</div>
{{end}}
<div class="mf-grid">
<div><label class="mf-req">Вид документа</label>
<div class="w2"><label class="mf-req">Вид документа</label>
<select name="doc_type">{{$dt := .O.DocType}}{{range .DocTypes}}<option value="{{.Code}}" {{if eq $dt .Code}}selected{{end}}>{{.Code}} — {{.Title}}</option>{{end}}</select></div>
<div><label>Серия</label><input name="doc_series" value="{{.O.DocSeries}}" placeholder="4 цифры для паспорта РФ"></div>
<div><label class="mf-req">Номер</label><input name="doc_number" value="{{.O.DocNumber}}"></div>
@@ -406,14 +408,16 @@ function hbBind(inp,box,list,onPick){
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let hits=[],picked=inp.dataset.picked==='1';
const q=()=>picked?'':inp.value;
const draw=()=>{hits=hbFind(list,q());box.innerHTML=(hits.length?'<div class="hb-m" style="padding:2px 4px">'+(q().trim()?'Найдено: ':'Все участники: ')+hits.length+'</div>'+hits.map((p,i)=>'<button type="button" class="hb-hit" data-i="'+i+'"><b>'+esc(p.name)+'</b> <span class="hb-m">ИНН '+esc(p.inn)+' · '+esc(p.code)+(p.kind==='broker'?' · брокер':'')+'</span></button>').join(''):'<div class="hb-m" style="padding:6px 2px">В справочнике не найдено — введите ИНН и наименование в поля ниже.</div>');box.classList.add('open')};
const draw=()=>{hits=hbFind(list,q());box.innerHTML=(hits.length?'<div class="hb-m" style="padding:2px 4px">'+(q().trim()?'Найдено: ':'Все участники: ')+hits.length+'</div>'+hits.map((p,i)=>'<button type="button" class="hb-hit" data-i="'+i+'" data-inn="'+esc(p.inn)+'" data-code="'+esc(p.code)+'"><b>'+esc(p.name)+'</b> <span class="hb-m">ИНН '+esc(p.inn)+' · '+esc(p.code)+(p.kind==='broker'?' · брокер':'')+'</span></button>').join(''):'<div class="hb-m" style="padding:6px 2px">В справочнике не найдено — введите ИНН и наименование в поля ниже.</div>');box.classList.add('open')};
const close=()=>{box.innerHTML='';box.classList.remove('open')};
inp.addEventListener('focus',()=>{if(picked)inp.select();draw()});
inp.addEventListener('input',()=>{picked=false;draw()});
inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();if(hits[0]){pick(hits[0])}}if(e.key==='Escape')close()});
// Enter выбирает только при единственном совпадении: иначе молча ушёл бы первый попавшийся.
inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();if(hits.length===1)pick(hits[0]);else if(hits.length>1){const m=box.querySelector('.hb-m');if(m)m.textContent='Найдено: '+hits.length+' — уточните запрос или выберите мышью'}}if(e.key==='Escape')close()});
const pick=p=>{onPick(p);picked=true;close()};
box.addEventListener('mousedown',e=>e.preventDefault());
box.addEventListener('click',e=>{const b=e.target.closest('.hb-hit');if(b)pick(hits[+b.dataset.i])});
// Участник — по ИНН и коду с самой кнопки, а не по номеру строки: список мог перерисоваться между показом и нажатием.
box.addEventListener('click',e=>{const b=e.target.closest('.hb-hit');if(!b)return;const p=list.find(x=>String(x.inn)===b.dataset.inn&&String(x.code)===b.dataset.code)||hits[+b.dataset.i];if(p)pick(p)});
inp.addEventListener('blur',()=>setTimeout(close,150));
const tg=inp.parentNode.querySelector('.hb-toggle');
if(tg)tg.addEventListener('click',()=>{if(box.classList.contains('open'))close();else{inp.focus();draw()}});
@@ -520,14 +520,16 @@ function hbBind(inp,box,list,onPick){
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let hits=[],picked=inp.dataset.picked==='1';
const q=()=>picked?'':inp.value;
const draw=()=>{hits=hbFind(list,q());box.innerHTML=(hits.length?'<div class="hb-m" style="padding:2px 4px">'+(q().trim()?'Найдено: ':'Все участники: ')+hits.length+'</div>'+hits.map((p,i)=>'<button type="button" class="hb-hit" data-i="'+i+'"><b>'+esc(p.name)+'</b> <span class="hb-m">ИНН '+esc(p.inn)+' · '+esc(p.code)+(p.kind==='broker'?' · брокер':'')+'</span></button>').join(''):'<div class="hb-m" style="padding:6px 2px">В справочнике не найдено — введите ИНН и наименование в поля ниже.</div>');box.classList.add('open')};
const draw=()=>{hits=hbFind(list,q());box.innerHTML=(hits.length?'<div class="hb-m" style="padding:2px 4px">'+(q().trim()?'Найдено: ':'Все участники: ')+hits.length+'</div>'+hits.map((p,i)=>'<button type="button" class="hb-hit" data-i="'+i+'" data-inn="'+esc(p.inn)+'" data-code="'+esc(p.code)+'"><b>'+esc(p.name)+'</b> <span class="hb-m">ИНН '+esc(p.inn)+' · '+esc(p.code)+(p.kind==='broker'?' · брокер':'')+'</span></button>').join(''):'<div class="hb-m" style="padding:6px 2px">В справочнике не найдено — введите ИНН и наименование в поля ниже.</div>');box.classList.add('open')};
const close=()=>{box.innerHTML='';box.classList.remove('open')};
inp.addEventListener('focus',()=>{if(picked)inp.select();draw()});
inp.addEventListener('input',()=>{picked=false;draw()});
inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();if(hits[0]){pick(hits[0])}}if(e.key==='Escape')close()});
// Enter выбирает только при единственном совпадении: иначе молча ушёл бы первый попавшийся.
inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();if(hits.length===1)pick(hits[0]);else if(hits.length>1){const m=box.querySelector('.hb-m');if(m)m.textContent='Найдено: '+hits.length+' — уточните запрос или выберите мышью'}}if(e.key==='Escape')close()});
const pick=p=>{onPick(p);picked=true;close()};
box.addEventListener('mousedown',e=>e.preventDefault());
box.addEventListener('click',e=>{const b=e.target.closest('.hb-hit');if(b)pick(hits[+b.dataset.i])});
// Участник — по ИНН и коду с самой кнопки, а не по номеру строки: список мог перерисоваться между показом и нажатием.
box.addEventListener('click',e=>{const b=e.target.closest('.hb-hit');if(!b)return;const p=list.find(x=>String(x.inn)===b.dataset.inn&&String(x.code)===b.dataset.code)||hits[+b.dataset.i];if(p)pick(p)});
inp.addEventListener('blur',()=>setTimeout(close,150));
const tg=inp.parentNode.querySelector('.hb-toggle');
if(tg)tg.addEventListener('click',()=>{if(box.classList.contains('open'))close();else{inp.focus();draw()}});
@@ -150,6 +150,14 @@ tbody tr:hover td { background:var(--accent-weak); }
/* ---- Бейджи статусов ---- */
.badge { display:inline-block; padding:3px 9px; border-radius:20px; font-size:11px; font-weight:600; }
.live { font-size:11.5px; margin-top:4px; color:var(--muted); display:flex; align-items:center; gap:6px; line-height:1.3 }
.live::before { content:''; width:7px; height:7px; border-radius:50%; background:var(--muted); flex:none }
.live-wait::before { background:var(--accent); animation:pulse 2.4s var(--ease) infinite }
.live-warn { color:var(--warn) } .live-warn::before { background:var(--warn); animation:pulse 2.4s var(--ease) infinite }
.live-ok { color:var(--ok) } .live-ok::before { background:var(--ok) }
.live-bad { color:var(--err) } .live-bad::before { background:var(--err) }
.live-big { font-size:14px; margin:-6px 0 12px; font-weight:500 }
.live[data-active] { color:var(--text-2) } .live-warn[data-active] { color:var(--warn) }
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background:var(--accent-weak); color:var(--accent); }
.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); }
@@ -524,6 +532,27 @@ label { font-size:13px; font-weight:500; }
}
poll();
setInterval(poll, 12000); // каждые 12 сек
// Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут.
(function(){
function tick(){
var els = document.querySelectorAll('.live[data-live][data-active]');
if (!els.length) return;
var out = [], inn = [];
els.forEach(function(e){ var k = e.getAttribute('data-live').split(':'); (k[0]==='out'?out:inn).push(k[1]); });
var q = 'out=' + encodeURIComponent(out.join(',')) + '&in=' + encodeURIComponent(inn.join(','));
fetch('/admin/journal/live?' + q, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
els.forEach(function(e){
var k = e.getAttribute('data-live').split(':'), st = (d[k[0]]||{})[k[1]];
if (!st) return;
e.textContent = st.text;
e.className = 'live ' + (e.classList.contains('live-big') ? 'live-big ' : '') + 'live-' + st.kind;
if (!st.active) e.removeAttribute('data-active');
});
}).catch(function(){});
}
setInterval(tick, 8000);
if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500);
})();
})();
})();
</script>