Списки: период, сортировка, подгрузка; анкета участника — строкой исходящих
«Исходящие», «Входящие», «Журнал обмена»: быстрые периоды (сегодня, 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:
+43
-27
@@ -45,30 +45,6 @@ var templateFuncs = template.FuncMap{
|
|||||||
s := globalRC.Snapshot()
|
s := globalRC.Snapshot()
|
||||||
return map[string]string{"Name": orgName(s), "INN": orgINN(s), "Code": orgCode(s)}
|
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,
|
"ru": russianText,
|
||||||
"ruState": russianState,
|
"ruState": russianState,
|
||||||
"ruOutcome": russianOutcome,
|
"ruOutcome": russianOutcome,
|
||||||
@@ -538,10 +514,36 @@ type homeData struct {
|
|||||||
// claimsData — данные журнала.
|
// claimsData — данные журнала.
|
||||||
type claimsData struct {
|
type claimsData struct {
|
||||||
page
|
page
|
||||||
Items []ClaimView
|
Rows []any // outRow
|
||||||
|
List listView
|
||||||
Flash string
|
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 — данные карточки.
|
// claimData — данные карточки.
|
||||||
type claimData struct {
|
type claimData struct {
|
||||||
page
|
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) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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: удалить черновик (заявка ещё не уходила в НРД).
|
// deleteDraft — POST /admin/claims/{id}/delete-draft: удалить черновик (заявка ещё не уходила в НРД).
|
||||||
|
|||||||
@@ -1657,10 +1657,15 @@ var authTemplateFuncs = template.FuncMap{
|
|||||||
|
|
||||||
// renderFor — выполняет layout с функциями, привязанными к текущему пользователю.
|
// renderFor — выполняет layout с функциями, привязанными к текущему пользователю.
|
||||||
func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
|
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)
|
u := currentUser(w)
|
||||||
t2, err := t.Clone()
|
t2, err := t.Clone()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t.ExecuteTemplate(w, "layout", data)
|
return t
|
||||||
}
|
}
|
||||||
on := devOnFor(u)
|
on := devOnFor(u)
|
||||||
t2.Funcs(template.FuncMap{
|
t2.Funcs(template.FuncMap{
|
||||||
@@ -1677,7 +1682,7 @@ func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
|
|||||||
return u.Can(perm)
|
return u.Can(perm)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return t2.ExecuteTemplate(w, "layout", data)
|
return t2
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureSubtle — используется для сравнения токенов в других местах.
|
// ensureSubtle — используется для сравнения токенов в других местах.
|
||||||
|
|||||||
@@ -16,10 +16,25 @@ import (
|
|||||||
// список, карточка с выбором по бумагам, отправка решения роботу.
|
// список, карточка с выбором по бумагам, отправка решения роботу.
|
||||||
func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
|
func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
|
||||||
mux.HandleFunc("/admin/incoming", func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
data := struct {
|
||||||
page
|
page
|
||||||
Items []*IncomingTransfer
|
Rows []any
|
||||||
}{nowPage("Входящие переводы", "incoming"), deps.store.List()}
|
List listView
|
||||||
|
}{nowPage("Входящие переводы", "incoming"), listVals(rows),
|
||||||
|
newListView("/admin/incoming", "in-body", q, total, more, "time", "по времени", "party", "по отправителю", "status", "по статусу")}
|
||||||
render(w, a.incoming, data)
|
render(w, a.incoming, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ func incomingStatusOf(it *IncomingTransfer) incomingStatusVM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildJournalRows — объединённая лента (исходящие + входящие), отсортированная от новых к старым.
|
// 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
|
var rows []journalRow
|
||||||
|
|
||||||
// Исходящие (наши заявки в НРД).
|
// Исходящие (наши заявки в НРД); from — с какого времени (нулевое — все).
|
||||||
if page, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 100}); err == nil {
|
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 {
|
for _, c := range page.Items {
|
||||||
party := m2mPartyName(c.ReceivingDepositoryINN)
|
party := m2mPartyName(c.ReceivingDepositoryINN)
|
||||||
if party != "" {
|
if party != "" {
|
||||||
@@ -109,6 +113,16 @@ func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore) [
|
|||||||
LiveKey: "in:" + it.ID, Live: liveIncoming(it),
|
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) })
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
|
||||||
return rows
|
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) {
|
func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) {
|
||||||
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
|
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
|
||||||
mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
data := struct {
|
||||||
page
|
page
|
||||||
Rows []journalRow
|
Rows []any
|
||||||
}{nowPage("Журнал обмена", "journal"), rows}
|
List listView
|
||||||
|
}{nowPage("Журнал обмена", "journal"), listVals(rows),
|
||||||
|
newListView("/admin/journal", "journal-body", q, total, more, "time", "по времени", "dir", "по направлению", "party", "по контрагенту", "status", "по статусу")}
|
||||||
render(w, a.journal, data)
|
render(w, a.journal, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
// /admin/journal/rows — только новые строки (появившиеся после since, unix-наносекунды).
|
// /admin/journal/rows — только новые строки (появившиеся после since, unix-наносекунды).
|
||||||
// Страница подставляет их сверху без перезагрузки. count — общее число строк (для счётчика).
|
// Страница подставляет их сверху без перезагрузки. count — общее число строк (для счётчика).
|
||||||
mux.HandleFunc("/admin/journal/rows", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/journal/rows", func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows := buildJournalRows(r.Context(), svc, store)
|
|
||||||
var since int64
|
var since int64
|
||||||
if s := r.URL.Query().Get("since"); s != "" {
|
if s := r.URL.Query().Get("since"); s != "" {
|
||||||
since, _ = strconv.ParseInt(s, 10, 64)
|
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 {
|
type rowJSON struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
T int64 `json:"t"`
|
T int64 `json:"t"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -435,9 +435,7 @@ func registerParticipantForm(mux *http.ServeMux, rc *RuntimeConfig, igwClient *i
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
flash, flashKind = "Анкета отправлена через ИШ ("+string(pkg)+", пакет id="+id+"). Ждём подтверждения регистрации от НРД.", "ok"
|
flash, flashKind = "Анкета отправлена через ИШ ("+string(pkg)+", пакет id="+id+"). Ждём подтверждения регистрации от НРД.", "ok"
|
||||||
saveParticipantReg(func(rg *participantReg) {
|
addParticipantReg(participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel})
|
||||||
*rg = participantReg{SentAt: time.Now(), PkgID: id, Name: d.FullName, Channel: igwChannel}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,35 +31,93 @@ type participantReg struct {
|
|||||||
|
|
||||||
var participantRegMu sync.Mutex
|
var participantRegMu sync.Mutex
|
||||||
|
|
||||||
func loadParticipantReg() participantReg {
|
// participantRegsFile — все отправки анкеты (строки в «Исходящих» и «Журнале»); participantRegFile —
|
||||||
participantRegMu.Lock()
|
// прежний файл с одной последней отправкой, из него история начинается при первом чтении.
|
||||||
defer participantRegMu.Unlock()
|
const participantRegsFile = "/var/lib/bj/.bj/participant-regs.json"
|
||||||
var r participantReg
|
|
||||||
if b, err := os.ReadFile(participantRegFile); err == nil {
|
func readParticipantRegs() []participantReg {
|
||||||
_ = json.Unmarshal(b, &r)
|
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 writeParticipantRegs(list []participantReg) {
|
||||||
func saveParticipantReg(mut func(*participantReg)) {
|
b, err := json.MarshalIndent(list, "", " ")
|
||||||
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, "", " ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmp := participantRegFile + ".tmp"
|
tmp := participantRegsFile + ".tmp"
|
||||||
if os.WriteFile(tmp, b, 0o640) == nil {
|
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).
|
// recordParticipantReply сохраняет ответ НРД на анкету (статус, коды, тексты, файл XML).
|
||||||
func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFile string) {
|
func recordParticipantReply(status string, resp *m2m.M2MTransferResponse, rawFile string) {
|
||||||
var codes []string
|
var codes []string
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
|
||||||
{{participantRegHTML}}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Журнал заявок ({{len .Items}})</h2>
|
<h2>Журнал заявок <span class="muted">({{.List.Total}})</span></h2>
|
||||||
{{if .Items}}
|
{{template "listTools" .List}}
|
||||||
<table>
|
<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>
|
<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>
|
<tbody id="claims-body">
|
||||||
{{range .Items}}
|
{{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>
|
<tr>
|
||||||
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
|
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
|
||||||
<td><code>{{short 8 .ID}}</code></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>{{$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>{{$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>
|
<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>
|
</tr>{{end}}{{end}}{{end}}
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">Пусто.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -5,26 +5,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Список ({{len .Items}})</h2>
|
<h2>Список <span class="muted">({{.List.Total}})</span></h2>
|
||||||
{{if .Items}}
|
{{template "listTools" .List}}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Получен</th><th>GUID</th><th>От кого</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>Получен</th><th>GUID</th><th>От кого</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody id="in-body">
|
||||||
{{range .Items}}
|
{{range .Rows}}{{template "inrow" .}}{{end}}
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{if not .Rows}}{{if .List.Q.FromS}}<p class="muted">За выбранный период ничего нет.</p>{{else}}
|
||||||
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.</p>
|
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if devOn}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.</p>{{end}}{{end}}
|
||||||
{{end}}
|
{{template "listMore" .List}}
|
||||||
<p class="muted" style="font-size:12px;margin-top:10px">🔄 Страница обновляется автоматически при появлении нового входящего.</p>
|
<p class="muted" style="font-size:12px;margin-top:10px">🔄 Страница обновляется автоматически при появлении нового входящего.</p>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -49,3 +40,12 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{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"}}
|
{{define "content"}}
|
||||||
<div class="card">
|
<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>
|
<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>
|
<table>
|
||||||
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody id="journal-body">
|
<tbody id="journal-body">
|
||||||
{{range .Rows}}{{template "journalrow" .}}{{end}}
|
{{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{template "listMore" .List}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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.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.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); }
|
.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); }
|
.badge.ok { background:var(--ok-weak); color:var(--ok); }
|
||||||
|
|
||||||
/* ---- Код / preformatted ---- */
|
/* ---- Код / preformatted ---- */
|
||||||
@@ -981,6 +988,36 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
|
|||||||
setInterval(tick, 8000);
|
setInterval(tick, 8000);
|
||||||
if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500);
|
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(){
|
(function(){
|
||||||
var body = document.getElementById('journal-body');
|
var body = document.getElementById('journal-body');
|
||||||
@@ -1002,6 +1039,7 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
|
|||||||
}
|
}
|
||||||
function tick(){
|
function tick(){
|
||||||
if (window.bjSelecting && window.bjSelecting()) return; // идёт выделение текста — новые строки подставим позже
|
if (window.bjSelecting && window.bjSelecting()) return; // идёт выделение текста — новые строки подставим позже
|
||||||
|
if (location.search) return; // выбран период или сортировка — сверху не подставляем
|
||||||
fetch('/admin/journal/rows?since=' + encodeURIComponent(maxTime()), {cache:'no-store'})
|
fetch('/admin/journal/rows?since=' + encodeURIComponent(maxTime()), {cache:'no-store'})
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
.then(function(d){
|
.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}}
|
||||||
Reference in New Issue
Block a user