diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index 76f010f..34e2469 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -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(`
Регистрация участника M2M (анкета)
Анкета отправлена ` + 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(`НРД отклонил ` + reg.RespAt.Format("02.01 15:04") + `: ` + esc(reg.RespText) + `.`)
- default:
- b.WriteString(`НРД принял ` + reg.RespAt.Format("02.01 15:04") + `. ` + esc(reg.RespText))
- }
- b.WriteString(` открыть анкету →
`)
- 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: удалить черновик (заявка ещё не уходила в НРД).
diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go
index 2ea0953..57c4055 100644
--- a/internal/lkgateway/auth.go
+++ b/internal/lkgateway/auth.go
@@ -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 — используется для сравнения токенов в других местах.
diff --git a/internal/lkgateway/incoming_handlers.go b/internal/lkgateway/incoming_handlers.go
index e97c1af..ee380cf 100644
--- a/internal/lkgateway/incoming_handlers.go
+++ b/internal/lkgateway/incoming_handlers.go
@@ -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)
})
diff --git a/internal/lkgateway/journal.go b/internal/lkgateway/journal.go
index 30f3c5e..df28226 100644
--- a/internal/lkgateway/journal.go
+++ b/internal/lkgateway/journal.go
@@ -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"`
diff --git a/internal/lkgateway/listquery.go b/internal/lkgateway/listquery.go
new file mode 100644
index 0000000..0e4ab44
--- /dev/null
+++ b/internal/lkgateway/listquery.go
@@ -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 , куда дописываются строки
+ 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
+}
diff --git a/internal/lkgateway/listquery_test.go b/internal/lkgateway/listquery_test.go
new file mode 100644
index 0000000..5f988ea
--- /dev/null
+++ b/internal/lkgateway/listquery_test.go
@@ -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())
+ }
+ }
+}
diff --git a/internal/lkgateway/participant_form.go b/internal/lkgateway/participant_form.go
index 16233de..fe9e305 100644
--- a/internal/lkgateway/participant_form.go
+++ b/internal/lkgateway/participant_form.go
@@ -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})
}
}
diff --git a/internal/lkgateway/participant_reg.go b/internal/lkgateway/participant_reg.go
index deb53b3..4ee377e 100644
--- a/internal/lkgateway/participant_reg.go
+++ b/internal/lkgateway/participant_reg.go
@@ -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
diff --git a/internal/lkgateway/web/templates/admin_claims.html b/internal/lkgateway/web/templates/admin_claims.html
index 5602544..3315d35 100644
--- a/internal/lkgateway/web/templates/admin_claims.html
+++ b/internal/lkgateway/web/templates/admin_claims.html
@@ -1,13 +1,31 @@
{{define "content"}}
{{if .Flash}}{{.Flash}}
{{end}}
-{{participantRegHTML}}
-
Журнал заявок ({{len .Items}})
- {{if .Items}}
+
Журнал заявок ({{.List.Total}})
+ {{template "listTools" .List}}
| Создана | ID | GUID M2M | Инвестор | ЦБ | Передающий | Принимающий | Статус | |
-
- {{range .Items}}
+
+ {{range .Rows}}{{template "claimrow" .}}{{end}}
+
+
+ {{if not .Rows}}
{{if .List.Q.FromS}}За выбранный период ничего нет.{{else}}Пусто.{{end}}
{{end}}
+ {{template "listMore" .List}}
+
+{{end}}
+
+{{/* Строка «Исходящих»: заявка на перевод или анкета участника M2M (исходящий пакет в НРД и ответ на него). */}}
+{{define "claimrow"}}{{if .R}}{{with .R}}
+ | {{.SentAt.Format "02.01 15:04:05"}} |
+ {{.PkgID}} |
+ — |
+ Анкета участника M2M{{if .Name}} {{.Name}}{{end}} |
+ — |
+ {{index orgEff "Name"}} |
+ НРД |
+ {{.StatusText}}{{if .RespText}} {{.RespAt.Format "02.01 15:04"}} · {{.RespText}} {{end}} |
+ анкета → |
+
{{end}}{{else}}{{with .C}}
| {{.CreatedAt.Format "02.01 15:04:05"}} |
{{short 8 .ID}} |
@@ -18,12 +36,4 @@
{{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}{{$t}} {{end}}{{.ReceivingDepositoryINN}} |
{{$l := liveOut .}}{{ruState .Status}} {{$l.Text}} |
детали →{{if and (or (eq .Status "draft") (eq .Status "validated")) (can "operate")}}{{end}} |
-
- {{end}}
-
-
- {{else}}
- Пусто.
- {{end}}
-
-{{end}}
+ {{end}}{{end}}{{end}}
diff --git a/internal/lkgateway/web/templates/admin_incoming.html b/internal/lkgateway/web/templates/admin_incoming.html
index ede3dbd..2a18c61 100644
--- a/internal/lkgateway/web/templates/admin_incoming.html
+++ b/internal/lkgateway/web/templates/admin_incoming.html
@@ -5,26 +5,17 @@
-
Список ({{len .Items}})
- {{if .Items}}
+
Список ({{.List.Total}})
+ {{template "listTools" .List}}
| Получен | GUID | От кого | ЦБ | Статус | |
-
- {{range .Items}}
-
- | {{msk .ReceivedAt}} |
- {{short 8 .GUID}} |
- {{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}{{$nm}} {{end}}{{.SenderCode}}{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}} |
- {{len .Securities}} |
- {{$s := inStatus .}}{{$s.Text}}{{if $s.Outcome}} {{$s.Outcome}}{{end}}{{if .DepoSummary}} сверка: {{.DepoSummary}}{{end}}{{$l := liveIn .}}{{$l.Text}} |
- {{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} → |
-
- {{end}}
+
+ {{range .Rows}}{{template "inrow" .}}{{end}}
- {{else}}
-
Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.
- {{end}}
+ {{if not .Rows}}{{if .List.Q.FromS}}
За выбранный период ничего нет.
{{else}}
+
Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.
{{end}}{{end}}
+ {{template "listMore" .List}}
🔄 Страница обновляется автоматически при появлении нового входящего.
{{end}}
+
+{{define "inrow"}}
+ | {{msk .ReceivedAt}} |
+ {{short 8 .GUID}} |
+ {{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}{{$nm}} {{end}}{{.SenderCode}}{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}} |
+ {{len .Securities}} |
+ {{$s := inStatus .}}{{$s.Text}}{{if $s.Outcome}} {{$s.Outcome}}{{end}}{{if .DepoSummary}} сверка: {{.DepoSummary}}{{end}}{{$l := liveIn .}}{{$l.Text}} |
+ {{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} → |
+
{{end}}
diff --git a/internal/lkgateway/web/templates/admin_journal.html b/internal/lkgateway/web/templates/admin_journal.html
index 9c48d7f..c16769e 100644
--- a/internal/lkgateway/web/templates/admin_journal.html
+++ b/internal/lkgateway/web/templates/admin_journal.html
@@ -1,14 +1,16 @@
{{define "content"}}
-
Журнал обмена ({{len .Rows}})
+
Журнал обмена ({{.List.Total}})
Все переводы в одной ленте — входящие и исходящие. Отдельные виды: Исходящие · Входящие. новые заявки появляются автоматически
+ {{template "listTools" .List}}
| Время | Направление | Контрагент | ЦБ | Статус | |
{{range .Rows}}{{template "journalrow" .}}{{end}}
- | Переводов пока нет. |
+ | {{if .List.Q.FromS}}За выбранный период ничего нет.{{else}}Переводов пока нет.{{end}} |
+ {{template "listMore" .List}}
{{end}}
diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html
index 90cde01..6081049 100644
--- a/internal/lkgateway/web/templates/layout.html
+++ b/internal/lkgateway/web/templates/layout.html
@@ -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){
diff --git a/internal/lkgateway/web/templates/part_list.html b/internal/lkgateway/web/templates/part_list.html
new file mode 100644
index 0000000..533c02c
--- /dev/null
+++ b/internal/lkgateway/web/templates/part_list.html
@@ -0,0 +1,11 @@
+{{/* Панель списка (период, сортировка) и подгрузка при прокрутке — «Исходящие», «Входящие», «Журнал обмена». */}}
+{{define "listTools"}}{{end}}
+
+{{define "listMore"}}Загружаю ещё…
{{end}}