Журнал: онлайн-статус «чего ждёт заявка», ссылка из ручного ввода в журнал, правки формы
- 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:
@@ -83,6 +83,8 @@ var templateFuncs = template.FuncMap{
|
||||
},
|
||||
// inStatus — статус входящего перевода (класс/текст/исход принято-отказано).
|
||||
"inStatus": incomingStatusOf,
|
||||
"liveOut": liveOutgoing,
|
||||
"liveIn": liveIncoming,
|
||||
// healthLabel — человекочитаемое имя фонового компонента для панели здоровья.
|
||||
"healthLabel": HealthLabel,
|
||||
// dur — округление длительности до секунд для вывода возраста отметки.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +83,7 @@ func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *Incoming
|
||||
Party: party, SecN: len(c.Securities),
|
||||
Status: c.Status, StatusText: russianState(c.Status),
|
||||
Href: "/admin/claims/" + c.ID,
|
||||
LiveKey: "out:" + c.ID, Live: liveOutgoing(c),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,6 +104,7 @@ func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *Incoming
|
||||
Party: party, SecN: len(it.Securities),
|
||||
Status: cls, StatusText: txt, Outcome: outcome,
|
||||
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) })
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
|
||||
Reference in New Issue
Block a user