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

«Исходящие», «Входящие», «Журнал обмена»: быстрые периоды (сегодня, 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
+43 -27
View File
@@ -45,30 +45,6 @@ var templateFuncs = template.FuncMap{
s := globalRC.Snapshot()
return map[string]string{"Name": orgName(s), "INN": orgINN(s), "Code": orgCode(s)}
},
// Карточка «Регистрация участника M2M» для «Исходящих»: когда отправили анкету и вердикт НРД.
"participantRegHTML": func() template.HTML {
reg := loadParticipantReg()
if reg.SentAt.IsZero() {
return ""
}
esc := template.HTMLEscapeString
var b strings.Builder
b.WriteString(`<div class="card"><h2>Регистрация участника M2M (анкета)</h2><p>Анкета отправлена ` + reg.SentAt.Format("02.01.2006 15:04") + `, пакет ` + esc(reg.PkgID))
if reg.Name != "" {
b.WriteString(`, наименование «` + esc(reg.Name) + `»`)
}
b.WriteString(`. `)
switch {
case reg.RespAt.IsZero():
b.WriteString(`Ответ НРД пока не получен.`)
case strings.EqualFold(reg.Status, "ERROR"):
b.WriteString(`<b style="color:var(--err,#c0392b)">НРД отклонил</b> ` + reg.RespAt.Format("02.01 15:04") + `: ` + esc(reg.RespText) + `.`)
default:
b.WriteString(`<b style="color:#1a7f37">НРД принял</b> ` + reg.RespAt.Format("02.01 15:04") + `. ` + esc(reg.RespText))
}
b.WriteString(` <a href="/admin/setup/participant-form">открыть анкету →</a></p></div>`)
return template.HTML(b.String())
},
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
@@ -538,10 +514,36 @@ type homeData struct {
// claimsData — данные журнала.
type claimsData struct {
page
Items []ClaimView
Rows []any // outRow
List listView
Flash string
}
// outRow — строка «Исходящих»: заявка на перевод или отправленная анкета участника.
type outRow struct {
C ClaimView
R *participantReg
}
// outgoingItems — строки «Исходящих» с ключами сортировки.
func outgoingItems(claims []ClaimView) []listItem {
items := make([]listItem, 0, len(claims)+2)
for _, c := range claims {
items = append(items, listItem{T: c.CreatedAt, V: outRow{C: c}, Keys: map[string]string{
"investor": c.Investor.LastName + " " + c.Investor.FirstName,
"party": m2mPartyName(c.ReceivingDepositoryINN) + " " + c.ReceivingDepositoryINN,
"status": russianState(c.Status),
}})
}
for _, rg := range loadParticipantRegs() {
rg := rg
items = append(items, listItem{T: rg.SentAt, V: outRow{R: &rg}, Keys: map[string]string{
"investor": "анкета участника", "party": "НРД", "status": rg.StatusText(),
}})
}
return items
}
// claimData — данные карточки.
type claimData struct {
page
@@ -756,12 +758,26 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
}
func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200})
q := parseListQ(r, []string{"time", "investor", "party", "status"})
f := m2mcore.Filter{Limit: 100000}
if !q.From.IsZero() {
f.CreatedFrom = &q.From
}
if !q.To.IsZero() {
f.CreatedTo = &q.To
}
pageData, err := svc.ListClaims(r.Context(), f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items, Flash: r.URL.Query().Get("flash")})
rows, total, more := listApply(outgoingItems(pageData.Items), q)
if r.URL.Query().Get("part") == "rows" {
listRowsPart(w, a.claims, "claimrow", rows, q, more)
return
}
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Rows: listVals(rows), Flash: r.URL.Query().Get("flash"),
List: newListView("/admin/claims", "claims-body", q, total, more, "time", "по времени", "investor", "по инвестору", "party", "по контрагенту", "status", "по статусу")})
}
// deleteDraft — POST /admin/claims/{id}/delete-draft: удалить черновик (заявка ещё не уходила в НРД).
+7 -2
View File
@@ -1657,10 +1657,15 @@ var authTemplateFuncs = template.FuncMap{
// renderFor — выполняет layout с функциями, привязанными к текущему пользователю.
func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
return userTemplate(w, t).ExecuteTemplate(w, "layout", data)
}
// userTemplate — копия шаблона с функциями, привязанными к текущему пользователю (curUser, devOn, can).
func userTemplate(w http.ResponseWriter, t *template.Template) *template.Template {
u := currentUser(w)
t2, err := t.Clone()
if err != nil {
return t.ExecuteTemplate(w, "layout", data)
return t
}
on := devOnFor(u)
t2.Funcs(template.FuncMap{
@@ -1677,7 +1682,7 @@ func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
return u.Can(perm)
},
})
return t2.ExecuteTemplate(w, "layout", data)
return t2
}
// ensureSubtle — используется для сравнения токенов в других местах.
+17 -2
View File
@@ -16,10 +16,25 @@ import (
// список, карточка с выбором по бумагам, отправка решения роботу.
func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
mux.HandleFunc("/admin/incoming", func(w http.ResponseWriter, r *http.Request) {
q := parseListQ(r, []string{"time", "party", "status"})
var items []listItem
for _, it := range deps.store.List() {
_, st, _ := incomingJournalStatus(it)
items = append(items, listItem{T: it.ReceivedAt, V: it, Keys: map[string]string{
"party": pick(m2mPartyName(it.SenderCode), m2mPartyName(it.SenderINN)) + " " + it.SenderCode, "status": st,
}})
}
rows, total, more := listApply(items, q)
if r.URL.Query().Get("part") == "rows" {
listRowsPart(w, a.incoming, "inrow", rows, q, more)
return
}
data := struct {
page
Items []*IncomingTransfer
}{nowPage("Входящие переводы", "incoming"), deps.store.List()}
Rows []any
List listView
}{nowPage("Входящие переводы", "incoming"), listVals(rows),
newListView("/admin/incoming", "in-body", q, total, more, "time", "по времени", "party", "по отправителю", "status", "по статусу")}
render(w, a.incoming, data)
})
+36 -7
View File
@@ -68,11 +68,15 @@ func incomingStatusOf(it *IncomingTransfer) incomingStatusVM {
}
// buildJournalRows — объединённая лента (исходящие + входящие), отсортированная от новых к старым.
func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore) []journalRow {
func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore, from time.Time) []journalRow {
var rows []journalRow
// Исходящие (наши заявки в НРД).
if page, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 100}); err == nil {
// Исходящие (наши заявки в НРД); from — с какого времени (нулевое — все).
f := m2mcore.Filter{Limit: 100000}
if !from.IsZero() {
f.CreatedFrom = &from
}
if page, err := svc.ListClaims(ctx, f); err == nil {
for _, c := range page.Items {
party := m2mPartyName(c.ReceivingDepositoryINN)
if party != "" {
@@ -109,6 +113,16 @@ func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore) [
LiveKey: "in:" + it.ID, Live: liveIncoming(it),
})
}
// Анкеты участника M2M — исходящие пакеты в НРД с ответом на них.
for _, rg := range loadParticipantRegs() {
rows = append(rows, journalRow{
Time: rg.SentAt, Dir: "Исходящий", DirKind: "out",
Party: "НРД · анкета участника M2M",
Status: rg.StatusClass(), StatusText: rg.StatusText(),
Href: "/admin/setup/participant-form",
LiveKey: "pf:" + rg.PkgID,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
return rows
}
@@ -117,22 +131,37 @@ func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore) [
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) {
rows := buildJournalRows(r.Context(), svc, store)
q := parseListQ(r, []string{"time", "dir", "party", "status"})
var items []listItem
for _, row := range buildJournalRows(r.Context(), svc, store, q.From) {
items = append(items, listItem{T: row.Time, V: row, Keys: map[string]string{"dir": row.Dir, "party": row.Party, "status": row.StatusText}})
}
rows, total, more := listApply(items, q)
if r.URL.Query().Get("part") == "rows" {
listRowsPart(w, a.journal, "journalrow", rows, q, more)
return
}
data := struct {
page
Rows []journalRow
}{nowPage("Журнал обмена", "journal"), rows}
Rows []any
List listView
}{nowPage("Журнал обмена", "journal"), listVals(rows),
newListView("/admin/journal", "journal-body", q, total, more, "time", "по времени", "dir", "по направлению", "party", "по контрагенту", "status", "по статусу")}
render(w, a.journal, data)
})
// /admin/journal/rows — только новые строки (появившиеся после since, unix-наносекунды).
// Страница подставляет их сверху без перезагрузки. count — общее число строк (для счётчика).
mux.HandleFunc("/admin/journal/rows", func(w http.ResponseWriter, r *http.Request) {
rows := buildJournalRows(r.Context(), svc, store)
var since int64
if s := r.URL.Query().Get("since"); s != "" {
since, _ = strconv.ParseInt(s, 10, 64)
}
var from time.Time
if since > 0 {
from = time.Unix(0, since)
}
rows := buildJournalRows(r.Context(), svc, store, from)
type rowJSON struct {
Key string `json:"key"`
T int64 `json:"t"`
+185
View File
@@ -0,0 +1,185 @@
package lkgateway
// listquery.go — списки «Исходящие», «Входящие», «Журнал обмена»: период (с–по, быстрые «сегодня /
// 7 / 30 дней»), сортировка и подгрузка частями при прокрутке. Страница отдаёт первые listPageSize
// строк; следующие браузер берёт тем же адресом с part=rows&offset=N (ответ — JSON с готовыми строками).
import (
"bytes"
"encoding/json"
"html/template"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const listPageSize = 50
var listMSK = time.FixedZone("МСК", 3*3600)
// listQ — что выбрал пользователь: период, сортировка, с какой строки.
type listQ struct {
From, To time.Time // To — начало дня после «по» (исключительно); нулевые — без границы
FromS, ToS string // как в полях (ГГГГ-ММ-ДД)
Sort string
Asc bool
Offset int
Query string // параметры без offset/part — для подгрузки и ссылок
}
// parseListQ — параметры списка из адреса. sorts — допустимые ключи сортировки, первый — по умолчанию.
func parseListQ(r *http.Request, sorts []string) listQ {
v := r.URL.Query()
q := listQ{Sort: sorts[0]}
if t, err := time.ParseInLocation("2006-01-02", v.Get("from"), listMSK); err == nil {
q.From, q.FromS = t, v.Get("from")
}
if t, err := time.ParseInLocation("2006-01-02", v.Get("to"), listMSK); err == nil {
q.To, q.ToS = t.AddDate(0, 0, 1), v.Get("to")
}
for _, s := range sorts {
if v.Get("sort") == s {
q.Sort = s
}
}
q.Asc = v.Get("dir") == "asc"
q.Offset, _ = strconv.Atoi(v.Get("offset"))
if q.Offset < 0 {
q.Offset = 0
}
keep := url.Values{}
for _, k := range []string{"from", "to", "sort", "dir"} {
if s := v.Get(k); s != "" {
keep.Set(k, s)
}
}
q.Query = keep.Encode()
return q
}
// in — время попадает в выбранный период.
func (q listQ) in(t time.Time) bool {
return (q.From.IsZero() || !t.Before(q.From)) && (q.To.IsZero() || t.Before(q.To))
}
// listItem — строка списка: время, ключи сортировки и данные для шаблона строки.
type listItem struct {
T time.Time
Keys map[string]string
V any
}
// listApply — отбор по периоду, сортировка и одна порция строк.
func listApply(items []listItem, q listQ) (page []listItem, total int, more bool) {
out := items[:0:0]
for _, it := range items {
if q.in(it.T) {
out = append(out, it)
}
}
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
if q.Sort != "time" {
ka, kb := strings.ToLower(a.Keys[q.Sort]), strings.ToLower(b.Keys[q.Sort])
if ka != kb {
if q.Asc {
return ka < kb
}
return ka > kb
}
return a.T.After(b.T) // при равенстве — новые выше
}
if q.Asc {
return a.T.Before(b.T)
}
return a.T.After(b.T)
})
total = len(out)
if q.Offset >= total {
return nil, total, false
}
end := q.Offset + listPageSize
if end > total {
end = total
}
return out[q.Offset:end], total, end < total
}
// listView — панель списка (период, сортировка) и состояние подгрузки — для шаблона "listTools".
type listView struct {
Q listQ
Path string
Body string // id <tbody>, куда дописываются строки
Total int
More bool
Next int
Sorts []listOpt
Presets []listOpt
}
type listOpt struct {
Key, Label, Href string
On bool
}
// newListView — панель: быстрые периоды и варианты сортировки (sorts — пары ключ, подпись).
func newListView(path, body string, q listQ, total int, more bool, sorts ...string) listView {
lv := listView{Q: q, Path: path, Body: body, Total: total, More: more, Next: q.Offset + listPageSize}
for i := 0; i+1 < len(sorts); i += 2 {
lv.Sorts = append(lv.Sorts, listOpt{Key: sorts[i], Label: sorts[i+1], On: q.Sort == sorts[i]})
}
today := time.Now().In(listMSK).Format("2006-01-02")
day := func(n int) string { return time.Now().In(listMSK).AddDate(0, 0, -n).Format("2006-01-02") }
href := func(from, to string) string {
v := url.Values{}
if from != "" {
v.Set("from", from)
v.Set("to", to)
}
if q.Sort != lv.Sorts[0].Key {
v.Set("sort", q.Sort)
}
if q.Asc {
v.Set("dir", "asc")
}
if s := v.Encode(); s != "" {
return path + "?" + s
}
return path
}
for _, p := range []struct{ label, from string }{{"Сегодня", today}, {"7 дней", day(6)}, {"30 дней", day(29)}, {"Всё время", ""}} {
to := today
if p.from == "" {
to = ""
}
lv.Presets = append(lv.Presets, listOpt{Label: p.label, Href: href(p.from, to), On: q.FromS == p.from && q.ToS == to})
}
return lv
}
// listRowsPart — ответ на part=rows: следующая порция строк готовым HTML (шаблон rowTpl на каждую строку).
func listRowsPart(w http.ResponseWriter, t *template.Template, rowTpl string, page []listItem, q listQ, more bool) {
tu := userTemplate(w, t)
var buf bytes.Buffer
for _, it := range page {
if err := tu.ExecuteTemplate(&buf, rowTpl, it.V); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"html": buf.String(), "more": more, "next": q.Offset + len(page)})
}
// listVals — данные строк порции для шаблона страницы.
func listVals(page []listItem) []any {
out := make([]any, 0, len(page))
for _, it := range page {
out = append(out, it.V)
}
return out
}
+63
View File
@@ -0,0 +1,63 @@
package lkgateway
import (
"bytes"
"html/template"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Период, сортировка и порции списка.
func TestListApply(t *testing.T) {
base := time.Date(2026, 10, 20, 12, 0, 0, 0, listMSK)
var items []listItem
for i := 0; i < 120; i++ {
items = append(items, listItem{T: base.Add(-time.Duration(i) * time.Hour), Keys: map[string]string{"party": string(rune('a' + i%26))}, V: i})
}
q := parseListQ(httptest.NewRequest("GET", "/x?from=2026-10-19&to=2026-10-20", nil), []string{"time", "party"})
page, total, more := listApply(items, q)
if total != 37 || len(page) != 37 || more { // 20.10 12:00 … 19.10 00:00
t.Fatalf("период: total=%d len=%d more=%v", total, len(page), more)
}
q = parseListQ(httptest.NewRequest("GET", "/x?offset=100", nil), []string{"time", "party"})
page, total, more = listApply(items, q)
if total != 120 || len(page) != 20 || more || page[0].V.(int) != 100 {
t.Fatalf("порция: total=%d len=%d more=%v", total, len(page), more)
}
q = parseListQ(httptest.NewRequest("GET", "/x?sort=party&dir=asc", nil), []string{"time", "party"})
page, _, more = listApply(items, q)
if page[0].Keys["party"] != "a" || page[0].V.(int) != 0 || !more {
t.Fatalf("сортировка: %v", page[0])
}
if q.Query != "dir=asc&sort=party" {
t.Fatalf("query: %q", q.Query)
}
}
// Строки «Исходящих» (заявка и анкета), «Входящих» и панель списка отрисовываются.
func TestListTemplates(t *testing.T) {
for _, c := range []struct {
page, row string
v any
want string
}{
{"admin_claims.html", "claimrow", outRow{R: &participantReg{SentAt: time.Now(), PkgID: "34", Name: "ООО", RespAt: time.Now(), Status: "ERROR", Codes: []string{"M2M37"}, RespText: "Некорректно"}}, "Отклонена НРД · M2M37"},
{"admin_claims.html", "claimrow", outRow{C: ClaimView{ID: "abc", Status: "draft"}}, "/admin/claims/abc"},
{"admin_incoming.html", "inrow", &IncomingTransfer{ID: "in1", Status: "pending"}, "/admin/incoming/in1"},
{"admin_claims.html", "listTools", newListView("/admin/claims", "b", listQ{Sort: "time"}, 3, true, "time", "по времени"), "7 дней"},
} {
tp, err := template.New("layout.html").Funcs(templateFuncs).Funcs(authTemplateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/part_*.html", "web/templates/"+c.page)
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
if err := tp.ExecuteTemplate(&b, c.row, c.v); err != nil {
t.Fatalf("%s: %v", c.row, err)
}
if !strings.Contains(b.String(), c.want) {
t.Errorf("%s: нет %q в %s", c.row, c.want, b.String())
}
}
}
+1 -3
View File
@@ -435,9 +435,7 @@ func registerParticipantForm(mux *http.ServeMux, rc *RuntimeConfig, igwClient *i
break
}
flash, flashKind = "Анкета отправлена через ИШ ("+string(pkg)+", пакет id="+id+"). Ждём подтверждения регистрации от НРД.", "ok"
saveParticipantReg(func(rg *participantReg) {
*rg = participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel}
})
addParticipantReg(participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel})
}
}
+77 -19
View File
@@ -31,35 +31,93 @@ type participantReg struct {
var participantRegMu sync.Mutex
func loadParticipantReg() participantReg {
participantRegMu.Lock()
defer participantRegMu.Unlock()
var r participantReg
if b, err := os.ReadFile(participantRegFile); err == nil {
_ = json.Unmarshal(b, &r)
// participantRegsFile — все отправки анкеты (строки в «Исходящих» и «Журнале»); participantRegFile —
// прежний файл с одной последней отправкой, из него история начинается при первом чтении.
const participantRegsFile = "/var/lib/bj/.bj/participant-regs.json"
func readParticipantRegs() []participantReg {
var list []participantReg
if b, err := os.ReadFile(participantRegsFile); err == nil {
_ = json.Unmarshal(b, &list)
return list
}
return r
var r participantReg
if b, err := os.ReadFile(participantRegFile); err == nil && json.Unmarshal(b, &r) == nil && !r.SentAt.IsZero() {
list = append(list, r)
}
return list
}
// saveParticipantReg читает текущую запись, даёт изменить и атомарно пишет назад.
func saveParticipantReg(mut func(*participantReg)) {
participantRegMu.Lock()
defer participantRegMu.Unlock()
var r participantReg
if b, err := os.ReadFile(participantRegFile); err == nil {
_ = json.Unmarshal(b, &r)
}
mut(&r)
b, err := json.MarshalIndent(&r, "", " ")
func writeParticipantRegs(list []participantReg) {
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return
}
tmp := participantRegFile + ".tmp"
tmp := participantRegsFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, participantRegFile)
_ = os.Rename(tmp, participantRegsFile)
}
}
// loadParticipantRegs — все отправки анкеты, старые первыми.
func loadParticipantRegs() []participantReg {
participantRegMu.Lock()
defer participantRegMu.Unlock()
return readParticipantRegs()
}
// loadParticipantReg — последняя отправка анкеты.
func loadParticipantReg() participantReg {
list := loadParticipantRegs()
if len(list) == 0 {
return participantReg{}
}
return list[len(list)-1]
}
// addParticipantReg — новая отправка анкеты.
func addParticipantReg(r participantReg) {
participantRegMu.Lock()
defer participantRegMu.Unlock()
writeParticipantRegs(append(readParticipantRegs(), r))
}
// saveParticipantReg — изменить последнюю отправку (ответ НРД приходит на неё).
func saveParticipantReg(mut func(*participantReg)) {
participantRegMu.Lock()
defer participantRegMu.Unlock()
list := readParticipantRegs()
if len(list) == 0 {
list = append(list, participantReg{})
}
mut(&list[len(list)-1])
writeParticipantRegs(list)
}
// StatusClass, StatusText — статус отправки анкеты для строк списков (класс бейджа и текст).
func (r participantReg) StatusClass() string {
switch {
case r.RespAt.IsZero():
return "awaiting_decision"
case strings.EqualFold(r.Status, "ERROR"):
return "rejected"
}
return "confirmed"
}
func (r participantReg) StatusText() string {
switch {
case r.RespAt.IsZero():
return "Ждёт ответа НРД"
case strings.EqualFold(r.Status, "ERROR"):
if len(r.Codes) > 0 {
return "Отклонена НРД · " + strings.Join(r.Codes, ", ")
}
return "Отклонена НРД"
}
return "Принята НРД"
}
// recordParticipantReply сохраняет ответ НРД на анкету (статус, коды, тексты, файл XML).
func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFile string) {
var codes []string
@@ -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}}