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

- 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
+2
View File
@@ -83,6 +83,8 @@ var templateFuncs = template.FuncMap{
},
// inStatus — статус входящего перевода (класс/текст/исход принято-отказано).
"inStatus": incomingStatusOf,
"liveOut": liveOutgoing,
"liveIn": liveIncoming,
// healthLabel — человекочитаемое имя фонового компонента для панели здоровья.
"healthLabel": HealthLabel,
// dur — округление длительности до секунд для вывода возраста отметки.
+7 -2
View File
@@ -24,6 +24,8 @@ type journalRow struct {
StatusText string // человекочитаемый текст статуса (русский)
Outcome string // для входящих: «Принято» / «Отказано» / «Частично …»
Href string
LiveKey string // out:<id> | in:<id> — для онлайн-обновления строки
Live LiveStatus // чего ждёт заявка
}
// incomingJournalStatus — статус входящего для журнала: класс бейджа (цвет),
@@ -63,6 +65,7 @@ func incomingStatusOf(it *IncomingTransfer) incomingStatusVM {
// registerJournal вешает /admin/journal — объединённая лента вход+исход.
func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) {
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) {
var rows []journalRow
@@ -79,7 +82,8 @@ func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *Incoming
Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out",
Party: party, SecN: len(c.Securities),
Status: c.Status, StatusText: russianState(c.Status),
Href: "/admin/claims/" + c.ID,
Href: "/admin/claims/" + c.ID,
LiveKey: "out:" + c.ID, Live: liveOutgoing(c),
})
}
}
@@ -99,7 +103,8 @@ func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *Incoming
Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in",
Party: party, SecN: len(it.Securities),
Status: cls, StatusText: txt, Outcome: outcome,
Href: "/admin/incoming/" + it.ID,
Href: "/admin/incoming/" + it.ID,
LiveKey: "in:" + it.ID, Live: liveIncoming(it),
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
+132
View File
@@ -0,0 +1,132 @@
package lkgateway
// livestatus.go — «чего ждёт заявка»: одна строка состояния для журналов и карточек.
//
// Статус сделки (черновик, отправлена, ожидает решение…) говорит, где заявка, но не говорит,
// чего мы ждём: черновика поручения от НРД, подачи, ответа MS010, решения оператора. Здесь это
// сводится в одну фразу с признаком «в работе». Пока заявка в работе, страницы обновляют
// строку по /admin/journal/live каждые несколько секунд; завершённые и отклонённые не
// опрашиваются — там ничего не изменится.
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// LiveStatus — состояние для показа.
type LiveStatus struct {
Text string `json:"text"` // чего ждём / чем закончилось
Kind string `json:"kind"` // wait | warn | ok | bad
Active bool `json:"active"` // в работе: страница продолжает опрашивать
}
// liveOutgoing — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
func liveOutgoing(c ClaimView) LiveStatus {
guid := string(c.M2MGUID)
var depo *OutgoingDeal
if guid != "" && globalOutgoingStore != nil {
if d, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
depo = d
}
}
var watch *OrderWatch
if guid != "" && globalOrderWatch != nil {
watch = globalOrderWatch.Get(guid)
}
switch c.Status {
case "draft", "validated":
return LiveStatus{Text: "ожидает отправки в НРД", Kind: "wait", Active: true}
case "submitted_to_nsd":
return LiveStatus{Text: "отправлена в НРД, ожидает подтверждения приёма", Kind: "wait", Active: true}
case "awaiting_decision":
return LiveStatus{Text: "ожидает решения принимающего депозитария", Kind: "wait", Active: true}
case "manual_approval":
return LiveStatus{Text: "ожидает решения оператора", Kind: "warn", Active: true}
case "awaiting_sub16":
return LiveStatus{Text: "принята, ожидает депозитарного исполнения", Kind: "wait", Active: true}
case "rejected":
return LiveStatus{Text: "отклонена", Kind: "bad"}
case "timed_out":
return LiveStatus{Text: "истёк срок ответа контрагента", Kind: "bad"}
case "done":
return LiveStatus{Text: "исполнена", Kind: "ok"}
case "confirmed":
switch {
case depo == nil:
return LiveStatus{Text: "принята контрагентом, ожидает черновика поручения 16 от НРД", Kind: "wait", Active: true}
case !depo.Submitted:
return LiveStatus{Text: "черновик поручения 16 получен, ожидает подачи в НРД", Kind: "warn", Active: true}
case depo.Done:
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"}
case watch != nil:
k := watch.Kind()
if k == "" {
k = "wait"
}
return LiveStatus{Text: "поручение 16 подано: " + watch.StatusRu(), Kind: k, Active: k == "wait" || k == "warn"}
default:
return LiveStatus{Text: "поручение 16 подано, ожидает ответа НРД (MS010)", Kind: "wait", Active: true}
}
}
return LiveStatus{Text: russianState(c.Status), Kind: "wait"}
}
// liveIncoming — состояние входящего перевода: наше решение, затем поручение 16/1.
func liveIncoming(it *IncomingTransfer) LiveStatus {
if it == nil {
return LiveStatus{}
}
if it.Status != "answered" {
return LiveStatus{Text: "ожидает нашего решения", Kind: "warn", Active: true}
}
_, _, outcome := incomingJournalStatus(it)
if outcome == "Отказано" {
return LiveStatus{Text: "отказано", Kind: "bad"}
}
if globalOrderWatch != nil {
if w := globalOrderWatch.Get(it.GUID); w != nil {
k := w.Kind()
return LiveStatus{Text: "поручение 16/1 подано: " + w.StatusRu(), Kind: k, Active: k == "wait" || k == "warn"}
}
}
if strings.HasPrefix(outcome, "Частично") {
return LiveStatus{Text: strings.ToLower(outcome) + "; ожидает поручения 16/1", Kind: "wait", Active: true}
}
return LiveStatus{Text: "принято, ожидает подачи поручения 16/1", Kind: "wait", Active: true}
}
// registerLiveStatus — GET /admin/journal/live?out=id,id&in=id,id → состояния для строк в работе.
func registerLiveStatus(mux *http.ServeMux, svc *Service, store *IncomingStore) {
mux.HandleFunc("/admin/journal/live", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
out := map[string]LiveStatus{}
for _, id := range splitIDs(r.URL.Query().Get("out")) {
if c, err := svc.GetClaim(ctx, id); err == nil {
out[id] = liveOutgoing(c)
}
}
in := map[string]LiveStatus{}
for _, id := range splitIDs(r.URL.Query().Get("in")) {
if it, ok := store.Get(id); ok {
in[id] = liveIncoming(it)
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"out": out, "in": in})
})
}
func splitIDs(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" && len(p) <= 64 && len(out) < 200 {
out = append(out, p)
}
}
return out
}
@@ -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>