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}} - - {{range .Items}} + + {{range .Rows}}{{template "claimrow" .}}{{end}} + +
СозданаIDGUID M2MИнвесторЦБПередающийПринимающийСтатус
+ {{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}} - - {{range .Items}} - - - - - - - - - {{end}} + + {{range .Rows}}{{template "inrow" .}}{{end}}
ПолученGUIDОт когоЦБСтатус
{{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}} →
- {{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"}}
+ {{range .Presets}}{{.Label}}{{end}} + + + + + найдено: {{.Total}} +
{{end}} + +{{define "listMore"}}
Загружаю ещё…
{{end}}