diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 37190b8..116b4cf 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -627,6 +627,9 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service return } view.Timeline = buildClaimTimeline(r.Context(), view) + if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) { + view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки + } var depo *OutgoingDeal if view.M2MGUID != "" && globalOutgoingStore != nil { if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok { @@ -689,46 +692,7 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent { } } - // Происхождение из ЛК ЕСИА Финанс (путь напрямую через коннектор, минуя - // Fancy): показываем стартовый шаг с номером заявки в ЛК. - if v.M2MGUID != "" { - if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok { - add("Заявка из ЛК ЕСИА Финанс", "db", - fmt.Sprintf("Забрана коннектором из личного кабинета (заявка ЛК #%d).", lkID), - v.CreatedAt, time.Time{}) - } - } - - // Отправлена в шлюз НРД (Deal создаётся в момент отправки). - add("Отправлена в интеграционный шлюз НРД", "gateway", "Подписана ключом и передана в ИШ.", v.CreatedAt, time.Time{}) - - // Ответ НРД (если получен). - if v.M2MDecision != nil { - conf, rej := 0, 0 - for _, s := range v.M2MDecision.Securities { - if s.Outcome == "confirmed" { - conf++ - } else { - rej++ - } - } - title := "Получено решение контрагента (НРД)" - if devMode(globalRC) { - title = "Получено решение НРД (робот)" - } - add(title, "response", - fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt) - } else if v.M2MResponse != nil { - var codes string - for _, e := range v.M2MResponse.Responses { - if e.Code != "" { - codes += e.Code + " " - } - } - add("Ответ сервиса МОСТ (НРД)", "response", - "Статус: "+v.M2MResponse.StatusCode+". "+strings.TrimSpace(codes), v.UpdatedAt, v.CreatedAt) - } - return tl + return claimTimelineFull(ctx, v) } // formatSince — человекочитаемая длительность между шагами таймлайна. diff --git a/internal/lkgateway/claimtimeline.go b/internal/lkgateway/claimtimeline.go new file mode 100644 index 0000000..d2d03a7 --- /dev/null +++ b/internal/lkgateway/claimtimeline.go @@ -0,0 +1,276 @@ +package lkgateway + +// Путь заявки (исходящий перевод) во времени — из всех источников, а не только из состояний сделки: +// сделка M2M (создание, отправка, решение контрагента), блокировка/подпись в QUIK МНП, события обмена +// с DEPO (FTV_M2M_EVENTS: время, обработано/ошибка), поручение 16 и все документы НРД по нему +// (регистрация, «нет встречного поручения» GS036, исполнение MS010), недоставленные отправки. +// Шаги сортируются по времени, между соседними — длительность. Последним — чего ждём сейчас. + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" +) + +var mskZone = time.FixedZone("МСК", 3*3600) + +type tlItem struct { + at time.Time + title, kind, detail string +} + +// depoEventRow — строка FTV_M2M_EVENTS по переводу. +type depoEventRow struct { + At time.Time + Type string + Ref string + State string + Comment string +} + +// depoEventsFor — события обмена с DEPO по переводу (EVENT_DATE пишется временем МСК без зоны). +func depoEventsFor(ctx context.Context, rc *RuntimeConfig, guid string) []depoEventRow { + if rc == nil || guid == "" { + return nil + } + db, err := depoEventsDB(rc) + if err != nil { + return nil + } + defer db.Close() + cctx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + rows, err := db.QueryContext(cctx, `SELECT EVENT_TYPE, EVENT_DATE, ISNULL(REFERENCE,''), ISNULL(STATE,''), ISNULL(COMMENT,'') + FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g ORDER BY EVENT_DATE`, sql.Named("g", strings.ToUpper(guid))) + if err != nil { + return nil + } + defer rows.Close() + var out []depoEventRow + for rows.Next() { + var e depoEventRow + var at time.Time + if rows.Scan(&e.Type, &at, &e.Ref, &e.State, &e.Comment) != nil { + continue + } + // datetime без зоны: драйвер отдаёт стенные часы в UTC — это время МСК + e.At = time.Date(at.Year(), at.Month(), at.Day(), at.Hour(), at.Minute(), at.Second(), at.Nanosecond(), mskZone) + out = append(out, e) + } + return out +} + +var depoEventTitles = map[string]string{ + "BJ_SENT": "DEPO: перевод отправлен в НРД", + "BJ_GATEWAY_REJECT": "DEPO: заявка отклонена шлюзом bj", + "BJ_SERVICE_ERROR": "DEPO: сервис M2M отверг перевод", + "BJ_CP_CONFIRM": "DEPO: контрагент согласился", + "BJ_CP_REJECT": "DEPO: контрагент отказал", + "BJ_ORDER_SENT": "DEPO: поручение 16 подано в НРД", + "BJ_ORDER_LATE": "DEPO: поручение не исполнено за 5 минут", + "BJ_ORDER_ACCEPTED": "DEPO: НРД исполнил поручение (MS010)", + "BJ_ORDER_REJECTED": "DEPO: НРД отверг поручение (MS010)", + "BJ_EXECUTED": "DEPO: перевод исполнен", + "BJ_CANCELLED": "DEPO: поручение отменено (срок 30 дней)", + "BJ_QUIK_BLOCKED": "DEPO: бумаги заблокированы в QUIK", + "BJ_QUIK_SIGNED": "DEPO: поручение подписано в QUIK", + "BJ_QUIK_RELEASED": "DEPO: блокировка в QUIK снята", + "BJ_QUIK_DEPOSITED": "DEPO: бумаги зачислены в QUIK", + "BJ_RECEIVED": "DEPO: входящий перевод получен", + "BJ_DECISION_CONFIRM": "DEPO: наше согласие отправлено", + "BJ_DECISION_REJECT": "DEPO: наш отказ отправлен", +} + +func depoEventDetail(e depoEventRow) (string, string) { + kind := "db" + var parts []string + if e.Ref != "" { + parts = append(parts, "строка "+e.Ref) + } + switch strings.ToUpper(e.State) { + case "ERROR": + kind = "reject" + c := e.Comment + if i := strings.Index(c, "//"); i > 0 { // служебный хвост обработчика DEPO + c = c[:i] + } + parts = append(parts, "ошибка обработки в DEPO: "+strings.TrimSpace(c)) + case "DONE", "OK", "SUCCESS": + parts = append(parts, "обработано DEPO") + case "", "TODO", "NEW": + parts = append(parts, "ждёт обработки DEPO") + default: + parts = append(parts, "DEPO: "+e.State) + } + return strings.Join(parts, " · "), kind +} + +var quikActionTitles = map[string]string{ + "register": "QUIK МНП: вывод зарегистрирован, бумаги заблокированы", + "sign": "QUIK МНП: поручение подписано, бумаги списаны", + "deny": "QUIK МНП: поручение снято, блокировка снята", + "deposit": "QUIK МНП: бумаги зачислены", + "recipient": "QUIK МНП: получатель указан", +} + +func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent { + guid := string(v.M2MGUID) + var items []tlItem + add := func(at time.Time, title, kind, detail string) { + items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail}) + } + + // создание и откуда пришла + origin := "Перевод M2M создан в bj." + if guid != "" { + if lkID, ok := LKClaimIDByGUID(guid); ok { + origin = fmt.Sprintf("Заявка из личного кабинета ЕСИА Финанс (заявка ЛК #%d).", lkID) + } + } + add(v.CreatedAt, "Заявка создана", "db", origin) + + // состояния сделки M2M + decided := false + for _, st := range v.Stages { + switch st.State { + case "submitted_to_nsd": + add(st.EnteredAt, "Запрос перевода отправлен в НРД", "gateway", "M2MTransferRequest подписан шлюзом, НРД принял пакет.") + case "manual_approval": + add(st.EnteredAt, "Передана на решение оператора", "warn", st.Reason) + case "confirmed", "rejected": + decided = true + title, kind := "Контрагент согласился на перевод", "response" + if st.State == "rejected" { + title, kind = "Перевод отклонён", "reject" + } + detail := st.Reason + if v.M2MDecision != nil { + conf, rej := 0, 0 + for _, s := range v.M2MDecision.Securities { + if s.Outcome == "confirmed" { + conf++ + } else { + rej++ + } + } + detail = fmt.Sprintf("Решение контрагента (M2MTransferDecision): принято бумаг %d, отклонено %d.", conf, rej) + } else if v.M2MResponse != nil { + var codes []string + for _, e := range v.M2MResponse.Responses { + if e.Code != "" { + codes = append(codes, e.Code) + } + } + detail = "Ответ сервиса M2M: " + v.M2MResponse.StatusCode + " " + strings.Join(codes, " ") + } + add(st.EnteredAt, title, kind, strings.TrimSpace(detail)) + case "timed_out": + add(st.EnteredAt, "Истёк срок ответа контрагента", "reject", st.Reason) + case "done": + add(st.EnteredAt, "Перевод завершён", "ok", "") + } + } + if !decided && v.M2MResponse != nil { // ответ сервиса M2M без смены состояния + add(v.UpdatedAt, "Ответ сервиса M2M (НРД)", "response", "Статус: "+v.M2MResponse.StatusCode) + } + if guid == "" { + return timelineFinish(items, v) + } + + // недоставленная отправка + if f := sendFailGet(guid); f != nil { + add(f.At, "НРД не принял пакет", "reject", fmt.Sprintf("%s. Попыток: %d; повтор — автоматически, когда НРД ответит.", sendFailText(f), f.Attempts)) + } + + // QUIK МНП + for _, l := range globalQuikLinks.ForGUID(guid) { + for _, st := range l.Steps { + title := quikActionTitles[st.Action] + if title == "" { + title = "QUIK МНП: " + st.Action + } + kind := "quik" + if !st.OK { + kind = "reject" + } + add(st.At, title, kind, fmt.Sprintf("%s × %v. %s", l.ISIN, l.Qty, st.Reply)) + } + } + + // DEPO: номер поручения и события обмена + if dl := globalDepoLinks.Get(guid); dl != nil && dl.DocNo != "" { + add(dl.DocAt, "DEPO создала поручение №"+dl.DocNo, "db", "Поручение в учёте депозитария.") + } + for _, e := range depoEventsFor(ctx, globalRC, guid) { + title := depoEventTitles[e.Type] + if title == "" { + title = "DEPO: " + e.Type + } + detail, kind := depoEventDetail(e) + add(e.At, title, kind, detail) + } + + // поручение 16 и документы НРД по нему + if globalOutgoingStore != nil { + if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok { + if !od.DraftAt.IsZero() { + add(od.DraftAt, "НРД прислал черновик поручения 16", "response", fmt.Sprintf("Черновик MF010 по %d бумагам.", len(od.Secs))) + } + if od.Submitted { + add(od.OrderAt, "Поручение 16 подано в НРД", "gateway", "Пакеты ИШ "+od.Pkgs+", номера поручений "+strings.Join(od.ContrDIDs, ", ")+".") + for _, d := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) { + kind := "response" + switch { + case d.Rejected || d.Code == "REG_REJECT" || d.Code == "ERROR": + kind = "reject" + case d.Code == "GS036": + kind = "warn" + case d.Done: + kind = "ok" + } + add(d.Time, d.Title, kind, d.About) + } + } + } + } + if w := globalOrderWatch.Get(guid); w != nil && !w.LateAt.IsZero() { + add(w.LateAt, "Нет отчёта MS010 за 5 минут", "warn", "Регламентный срок исполнения прошёл; ожидаем исполнения до "+w.OrderAt.Add(orderExecWithin).In(mskZone).Format("02.01.2006")+".") + } + return timelineFinish(items, v) +} + +// timelineFinish — сортировка по времени, длительности между шагами и «чего ждём» последним. +func timelineFinish(items []tlItem, v ClaimView) []TimelineEvent { + sort.SliceStable(items, func(i, j int) bool { return items[i].at.Before(items[j].at) }) + var out []TimelineEvent + var prev time.Time + for _, it := range items { + if it.at.IsZero() { + continue + } + ev := TimelineEvent{Title: it.title, Kind: it.kind, Detail: it.detail, Time: it.at.In(mskZone)} + if !prev.IsZero() && it.at.After(prev) { + ev.Since = formatSince(it.at.Sub(prev)) + } + out = append(out, ev) + prev = it.at + } + if l := liveOutgoingState(v); l.Active { + out = append(out, TimelineEvent{Title: "Сейчас: " + l.Text, Kind: "wait"}) + } + return out +} + +// timelineLast — время последнего шага (для «Обновлена»). +func timelineLast(tl []TimelineEvent) time.Time { + var last time.Time + for _, e := range tl { + if e.Time.After(last) { + last = e.Time + } + } + return last +} diff --git a/internal/lkgateway/livestatus.go b/internal/lkgateway/livestatus.go index 22b0a31..9d39d1e 100644 --- a/internal/lkgateway/livestatus.go +++ b/internal/lkgateway/livestatus.go @@ -140,6 +140,9 @@ func liveOutgoingState(c ClaimView) LiveStatus { case depo.Done && depo.Rejected: return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"} case depo.Done: + if watch != nil && !watch.MS010At.IsZero() { + return LiveStatus{Text: "поручение 16 исполнено: MS010 получен " + watch.MS010At.In(mskZone).Format("02.01.2006 15:04"), Kind: "ok"} + } return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"} case watch != nil: k := watch.Kind() diff --git a/internal/lkgateway/nocounter_test.go b/internal/lkgateway/nocounter_test.go index 6609b4b..fe1d1b8 100644 --- a/internal/lkgateway/nocounter_test.go +++ b/internal/lkgateway/nocounter_test.go @@ -39,7 +39,16 @@ func TestNoCounterNotice(t *testing.T) { t.Fatalf("входящий: %q", s) } w.Direction, w.MS010At = "out", at.Add(53*time.Minute) - if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "задержка на стороне контрагента") { - t.Fatalf("исполнено с задержкой: %q", s) + if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "подано не сразу") { + t.Fatalf("исполнено, встречное не сразу: %q", s) + } + // сквитовано через минуту, исполнение НРД через 52 мин — задержка у НРД, не у контрагента + w.MatchedAt = at.Add(time.Minute) + if s := w.StatusRu(); strings.Contains(s, "не сразу") || !strings.Contains(s, "сквитовано со встречным в 17:51, исполнение НРД — через 52 мин") { + t.Fatalf("исполнено после сквитовки: %q", s) + } + w.MS010At = time.Time{} + if s := w.StatusRu(); !strings.Contains(s, "сквитовано со встречным") || !strings.Contains(s, "ждём исполнения НРД") { + t.Fatalf("сквитовано, ждём: %q", s) } } diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go index 090eea1..277d411 100644 --- a/internal/lkgateway/orderwatch.go +++ b/internal/lkgateway/orderwatch.go @@ -38,6 +38,8 @@ type OrderWatch struct { // NoCounterAt — НРД прислал уведомление GS036 «об отсутствии встречного поручения»: контрагент // ещё не подал своё поручение (к нашей 16 — его 16/1, к нашей 16/1 — его 16). Сбрасывается MS010. NoCounterAt time.Time `json:"no_counter_at,omitempty"` + // MatchedAt — НРД сквитовал поручения со встречными (GS116, MACH) по всем строкам: дальше — исполнение. + MatchedAt time.Time `json:"matched_at,omitempty"` // Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага), // ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO // по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных: @@ -53,6 +55,7 @@ type OrderLineWatch struct { Rejected bool `json:"rejected,omitempty"` ExpiredAt time.Time `json:"expired_at,omitempty"` NoCounterAt time.Time `json:"no_counter_at,omitempty"` // GS036 по этой строке + MatchedAt time.Time `json:"matched_at,omitempty"` // GS116 MACH по этой строке } // counterName — чьё встречное поручение ждём: к нашей 16 — 16/1 принимающего, к нашей 16/1 — 16 передающего. @@ -120,14 +123,19 @@ func (w *OrderWatch) StatusRu() string { case !w.MS010At.IsZero(): late := "" if w.MS010At.Sub(w.OrderAt) > orderMS010Within { - late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute)) + late = ", с опозданием " + humanDur(w.MS010At.Sub(w.OrderAt)) } - if !w.NoCounterAt.IsZero() { - late += "; задержка на стороне контрагента — встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")" + switch { + case !w.MatchedAt.IsZero() && w.MS010At.Sub(w.MatchedAt) > orderMS010Within: + late += "; сквитовано со встречным в " + w.MatchedAt.Local().Format("15:04") + ", исполнение НРД — через " + humanDur(w.MS010At.Sub(w.MatchedAt)) + case w.MatchedAt.IsZero() && !w.NoCounterAt.IsZero(): + late += "; встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")" } return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late case !w.ExpiredAt.IsZero(): return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым" + case !w.MatchedAt.IsZero(): + return "поручение сквитовано со встречным (НРД " + w.MatchedAt.Local().Format("02.01 15:04") + "), ждём исполнения НРД (MS010)" case !w.NoCounterAt.IsZero(): return "НРД: контрагент ещё не подал встречное " + w.counterName() + " (уведомление НРД " + w.NoCounterAt.Local().Format("02.01 15:04") + "); наше поручение принято, ждём исполнения" case !w.LateAt.IsZero(): @@ -341,7 +349,9 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str } if fresh { why := "отчёт MS010 не получен" - if !w.NoCounterAt.IsZero() { + if !w.MatchedAt.IsZero() { + why = "поручения сквитованы со встречными (" + w.MatchedAt.Local().Format("15:04") + "), ждём исполнения НРД" + } else if !w.NoCounterAt.IsZero() { why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента" } raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут", @@ -489,6 +499,36 @@ func orderWatchRefs(guid string) []string { func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) { for _, ln := range lines { st := w.Lines[ln.ContrDID] + // отчёт GS116 «сквитовано» (MACH) — по номеру поручения + if st == nil || st.MatchedAt.IsZero() { + if at, ok := findMatchedNotice(ln.ContrDID); ok { + globalOrderWatch.update(guid, func(w *OrderWatch) { + if w.Lines == nil { + w.Lines = map[string]*OrderLineWatch{} + } + l := w.Lines[ln.ContrDID] + if l == nil { + l = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN} + w.Lines[ln.ContrDID] = l + } + l.MatchedAt = at + all, last := true, time.Time{} + for _, x := range lines { + lx := w.Lines[x.ContrDID] + if lx == nil || lx.MatchedAt.IsZero() { + all = false + break + } + if lx.MatchedAt.After(last) { + last = lx.MatchedAt + } + } + if all { + w.MatchedAt = last + } + }) + } + } // уведомление «нет встречного поручения» (GS036) — по REFERENCE строки if (st == nil || st.NoCounterAt.IsZero()) && ln.Ref != "" { if at, ok := findNoCounterNotice(ln.Ref); ok { @@ -511,3 +551,16 @@ func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) { } } } + +// findMatchedNotice — отчёт НРД GS116 «сквитовано со встречным» () по номеру поручения. +func findMatchedNotice(contrDID string) (time.Time, bool) { + if contrDID == "" { + return time.Time{}, false + } + for _, pk := range pkgIndex() { + if strings.Contains(pk.body, "") && strings.Contains(pk.body, "GS116") && strings.Contains(pk.body, ">"+contrDID+"<") { + return pk.mt, true + } + } + return time.Time{}, false +} diff --git a/internal/lkgateway/tl_live_test.go b/internal/lkgateway/tl_live_test.go new file mode 100644 index 0000000..30772d8 --- /dev/null +++ b/internal/lkgateway/tl_live_test.go @@ -0,0 +1,53 @@ +//go:build live + +package lkgateway + +import ( + "bytes" + "context" + "fmt" + "os" + "regexp" + "strings" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// Путь заявки на живых данных: go test -c -tags live; запуск от bj с TL_ID=. +func TestTimelineLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + globalOutgoingStore = NewOutgoingStore() + repo, err := m2mcore.NewPostgresRepository(context.Background(), rc.Snapshot().Postgres.DSN) + if err != nil { + t.Fatal(err) + } + svc := NewService(Config{Repository: repo}) + for _, id := range strings.Split(os.Getenv("TL_ID"), ",") { + v, err := svc.GetClaim(context.Background(), id) + if err != nil { + t.Fatal(err) + } + v.Timeline = buildClaimTimeline(context.Background(), v) + fmt.Printf("=== %s создана %s обновлена(сделка) %s, последнее событие %s\n", id[:8], v.CreatedAt.Format("15:04:05 MST"), v.UpdatedAt.Format("15:04:05 MST"), timelineLast(v.Timeline).Format("15:04:05")) + for _, e := range v.Timeline { + fmt.Printf(" %-9s %-8s [%s] %s — %s\n", e.Time.Format("15:04:05"), e.Since, e.Kind, e.Title, e.Detail) + } + if os.Getenv("TL_HTML") != "" { + a, _ := newAdmin() + var b bytes.Buffer + if err := a.claim.ExecuteTemplate(&b, "layout", claimData{page: page{Title: "Заявка"}, Claim: v, Watch: globalOrderWatch.Get(string(v.M2MGUID))}); err != nil { + t.Fatal(err) + } + txt := regexp.MustCompile(`(?s)|`).ReplaceAllString(b.String(), "") + txt = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(txt, " ") + txt = regexp.MustCompile(`\s+`).ReplaceAllString(txt, " ") + i := strings.Index(txt, "Заявка ") + fmt.Println(txt[i:min(len(txt), i+1500)]) + } + } +} diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index f9ed8ca..78f946c 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -22,6 +22,7 @@ border-left:5px solid transparent; border-right:5px solid transparent; border-top:7px solid var(--c) } .tl-arrow .dt { font-size:10.5px; color:var(--muted,#999) } .tl-db{--c:#2563eb} .tl-crane{--c:#16a34a} .tl-gateway{--c:#7c3aed} .tl-response{--c:#0891b2} .tl-reject{--c:#dc2626} + .tl-quik{--c:#b45309} .tl-ok{--c:#16a34a} .tl-warn{--c:#d97706} .tl-wait{--c:#94a3b8} /* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */ @media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } }