From 683f516d07f5422c532e92ca5dce26fdd4c7b1d4 Mon Sep 17 00:00:00 2001 From: zuevav Date: Fri, 18 Sep 2026 18:45:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82:=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=81=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B0=D0=BC=D0=B8,=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2,=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск клиентов (режим сотрудника): - поле поиска вынесено наверх страницы, рядом «Найти» и «Сбросить»; - браузер сам подсказывает выпадающим списком по мере ввода — договоры депозитария отдаются страницей в (скриптов в кабинете нет, CSP остаётся строгой), список кэшируется в ядре на 10 минут; - по запросу фильтруются и учётки кабинета, и договоры депозитария, под формой показывается, сколько найдено; - шаблон LIKE экранируется, чтобы % и _ в номере договора означали себя. Документы: убраны пустое табло, «Мои заявки» и форма заказа документа — остался список готовых документов депозитария и пояснение, куда обращаться. Настройка: колонка сетки больше не растягивается широкой таблицей (min-width:0), у страницы пропал горизонтальный ползунок. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL --- internal/lkgateway/depodb.go | 58 ++++++++++++++++- internal/lkgateway/lkapi.go | 8 ++- internal/lkgateway/lkusers.go | 17 +++++ internal/lkgateway/web/templates/layout.html | 13 +++- modules/lk/lk.go | 20 +++++- modules/lk/lk_test.go | 51 +++++++++++++++ modules/lk/templates.go | 67 +++++++------------- 7 files changed, 184 insertions(+), 50 deletions(-) diff --git a/internal/lkgateway/depodb.go b/internal/lkgateway/depodb.go index 1c300cc..cbfc6dd 100644 --- a/internal/lkgateway/depodb.go +++ b/internal/lkgateway/depodb.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "time" ) @@ -170,7 +171,7 @@ func depoListContracts(ctx context.Context, s FancyMSSQLSettings, search string, JOIN OD_VALUES v ON v.ID = rst.VALUE_ID AND v.V_TYPE = 2 WHERE CAST(rst.K AS float) <> 0 AND (@p1 = '' OR p.PORT_NUM LIKE @p2) GROUP BY p.PORT_NUM ORDER BY p.PORT_NUM`, limit) - rows, err := conn.QueryContext(cctx, q, search, "%"+search+"%") + rows, err := conn.QueryContext(cctx, q, search, "%"+likeEscape(search)+"%") if err != nil { return nil, err } @@ -226,3 +227,58 @@ func likeEscape(v string) string { r := strings.NewReplacer("[", "[[]", "%", "[%]", "_", "[_]") return r.Replace(v) } + +// ---- подсказки в поиске клиентов (админка ЛК) ---- + +// Список договоров нужен на каждой загрузке страницы «Клиенты»: браузер сам +// показывает выпадающий список по мере ввода (), без скриптов на +// странице. Запрос дешёвый, но их 1300+, поэтому держим ответ в памяти. +var depoSuggestCache = struct { + mu sync.Mutex + list []string + at time.Time +}{} + +// depoSuggestContracts — все номера договоров депозитария для подсказок поиска. +func depoSuggestContracts(ctx context.Context, s FancyMSSQLSettings) []string { + if strings.TrimSpace(s.Server) == "" { + return nil + } + depoSuggestCache.mu.Lock() + if time.Since(depoSuggestCache.at) < 10*time.Minute && depoSuggestCache.list != nil { + list := depoSuggestCache.list + depoSuggestCache.mu.Unlock() + return list + } + depoSuggestCache.mu.Unlock() + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + rows, err := conn.QueryContext(cctx, + "SELECT DISTINCT TOP (5000) PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM <> '' ORDER BY PORT_NUM") + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var v string + if rows.Scan(&v) != nil { + break + } + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + if rows.Err() != nil && len(out) == 0 { + return nil + } + depoSuggestCache.mu.Lock() + depoSuggestCache.list, depoSuggestCache.at = out, time.Now() + depoSuggestCache.mu.Unlock() + return out +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index d898e57..3eca2f6 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -483,7 +483,13 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { // админ ищет по номеру договора/ФИО и открывает кабинет на просмотр. search := strings.TrimSpace(r.URL.Query().Get("search")) depo, derr := depoListContracts(r.Context(), fancy, search, 100) - resp := map[string]any{"clients": list, "depo": depo, "search": search} + // Учётки кабинета ищем тем же запросом: сотрудник вводит фамилию или + // номер договора, а где заведена учётка — ему помнить не нужно. + if search != "" { + list = lkFilterUsers(list, search) + } + resp := map[string]any{"clients": list, "depo": depo, "search": search, + "suggest": depoSuggestContracts(r.Context(), fancy)} if derr != nil { resp["depo_error"] = derr.Error() } diff --git a/internal/lkgateway/lkusers.go b/internal/lkgateway/lkusers.go index 520df5f..2a6a67d 100644 --- a/internal/lkgateway/lkusers.go +++ b/internal/lkgateway/lkusers.go @@ -197,3 +197,20 @@ func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, erro } return &u, nil } + +// lkFilterUsers — учётки кабинета, подходящие под строку поиска сотрудника +// (часть логина, номера договора, ФИО или почты; регистр не важен). +func lkFilterUsers(list []LKUser, search string) []LKUser { + q := strings.ToLower(strings.TrimSpace(search)) + if q == "" { + return list + } + out := make([]LKUser, 0, len(list)) + for _, u := range list { + hay := strings.ToLower(u.Login + " " + u.Contract + " " + u.FullName + " " + u.Email) + if strings.Contains(hay, q) { + out = append(out, u) + } + } + return out +} diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index 4337006..b68c572 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -191,10 +191,13 @@ input:focus, select:focus, textarea:focus { outline:2px solid var(--accent-weak) label { font-size:13px; font-weight:500; } /* Поле формы: подпись, ввод и пояснение — каждое со своей строки. Без этого label, input и подсказка идут в строку и «разъезжаются» на широком экране. */ -.fld { display:grid; gap:4px; align-content:start; } +/* min-width:0 обязателен: иначе длинная строка внутри (ключ, код, ссылка) не даёт + блоку сжаться и растягивает всю страницу — появляется горизонтальная прокрутка. */ +.fld { display:grid; gap:4px; align-content:start; min-width:0; max-width:100%; } +.fld > * { min-width:0; } .fld > label { font-weight:600; } .fld input[type=text], .fld input[type=password], .fld input[type=email], .fld select { width:100%; max-width:460px; } -.fld .muted { max-width:620px; line-height:1.45; } +.fld .muted { max-width:620px; line-height:1.45; overflow-wrap:anywhere; } /* ---- Баннер режима эмуляции ---- */ .banner-mock { background:var(--warn-weak); border-bottom:1px solid var(--warn); padding:10px 24px; display:flex; align-items:center; gap:12px; font-size:13px; } @@ -233,6 +236,12 @@ label { font-size:13px; font-weight:500; } /* ---- Админ-центр: боковые разделы + контент (macOS System Settings) ---- */ .settings { display:grid; grid-template-columns:236px 1fr; gap:26px; align-items:start; } +/* Колонка сетки по умолчанию не уже своего содержимого (min-width:auto), поэтому + широкая таблица растягивала всю страницу и внизу появлялся ползунок. */ +.settings > * { min-width:0; } +.settings-section, .settings-section .card { min-width:0; } +.settings-section table { max-width:100%; } +.tw { overflow-x:auto; max-width:100%; } .settings-nav { position:sticky; top:78px; display:flex; flex-direction:column; gap:2px; } .settings-nav button { display:flex; align-items:center; gap:11px; justify-content:flex-start; diff --git a/modules/lk/lk.go b/modules/lk/lk.go index 5ce9850..85fc5fb 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -72,6 +72,18 @@ func New(cfg Config) (*Server, error) { return strconv.FormatFloat(f, 'f', -1, 64) }, "fmtmoney": fmtMoney, + // Русское число: 1 договор, 2 договора, 5 договоров. + "plural": func(n int, one, few, many string) string { + w := many + switch m100 := n % 100; { + case m100 >= 11 && m100 <= 14: + case n%10 == 1: + w = one + case n%10 >= 2 && n%10 <= 4: + w = few + } + return strconv.Itoa(n) + " " + w + }, "fmtpct": func(f float64) string { s := strconv.FormatFloat(f, 'f', 2, 64) if f > 0 { @@ -911,7 +923,7 @@ func (s *Server) buildHandler() http.Handler { return } s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests, - "Kinds": out.Kinds, + "Kinds": out.Kinds, // форма заказа в клиентском виде скрыта; данные оставлены для API "From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")}) }) @@ -1043,7 +1055,8 @@ func (s *Server) buildHandler() http.Handler { Name string `json:"name"` Securities int `json:"securities"` } `json:"depo"` - DepoError string `json:"depo_error"` + DepoError string `json:"depo_error"` + Suggest []string `json:"suggest"` } search := strings.TrimSpace(r.URL.Query().Get("search")) path := "/api/lk/admin/clients" @@ -1055,7 +1068,8 @@ func (s *Server) buildHandler() http.Handler { return } s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients, - "Depo": out.Depo, "DepoError": out.DepoError, "Search": search}) + "Depo": out.Depo, "DepoError": out.DepoError, "Search": search, + "Suggest": out.Suggest, "Found": len(out.Clients) + len(out.Depo)}) }) // Администратор: войти в кабинет клиента diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 1e97f02..f39a1c7 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -47,6 +47,19 @@ func fakeBJ(t *testing.T) *httptest.Server { }}) } }) + mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) || r.Header.Get("X-BJ-Session") != "sess-1" { + return + } + search := r.URL.Query().Get("search") + clients := []any{map[string]any{"login": "bublikov", "contract_number": "БР-719", "full_name": "Аминов Вячеслав Маркович", "active": true}} + depo := []any{map[string]any{"contract": "БР-719 Аминов Вячеслав Маркович", "name": "Аминов Вячеслав Маркович", "securities": 14}} + if search != "" && !strings.Contains(strings.ToLower("БР-719 Аминов Вячеслав Маркович"), strings.ToLower(search)) { + clients, depo = nil, nil + } + _ = json.NewEncoder(w).Encode(map[string]any{"clients": clients, "depo": depo, "search": search, + "suggest": []string{"БР-719 Аминов Вячеслав Маркович", "БР-746 Бубликов Иван Петрович"}}) + }) mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"security": map[string]any{ @@ -510,3 +523,41 @@ func TestAgentMode(t *testing.T) { t.Fatalf("чужой ключ принят: код %d", rec.Code) } } + +// Поиск клиентов в режиме сотрудника: подсказки списком и результат под формой. +func TestClientsSearch(t *testing.T) { + bj := fakeBJ(t) + s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"}) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + get := func(path string) string { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: код %d", path, rec.Code) + } + return rec.Body.String() + } + page := get("/clients") + for _, want := range []string{`list="cl-suggest"`, ``, + ``, "Найти"} { + if !strings.Contains(page, want) { + t.Errorf("на странице клиентов нет %q", want) + } + } + found := get("/clients?search=" + url.QueryEscape("Аминов")) + if !strings.Contains(found, "найдено: 2") { + t.Errorf("не показан итог поиска: %s", found) + } + if !strings.Contains(found, "БР-719 Аминов Вячеслав Маркович") { + t.Error("найденный договор не выведен списком") + } + empty := get("/clients?search=" + url.QueryEscape("Петров")) + if !strings.Contains(empty, "договоров не найдено") { + t.Errorf("пустой результат не объяснён: %s", empty) + } +} diff --git a/modules/lk/templates.go b/modules/lk/templates.go index 1d24669..b189e84 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -84,6 +84,9 @@ label{display:block;font-family:var(--mono);font-size:10.5px;font-weight:500;let input,select,textarea{width:100%;min-height:44px;padding:10px 12px;border:1px solid var(--line-2);border-radius:2px;background:#fff;color:var(--ink);font:inherit;font-size:16px} input:focus,select:focus,textarea:focus{outline:2px solid var(--acc);outline-offset:1px} input[type=checkbox]{width:20px;min-height:20px;height:20px} +/* поиск клиентов: поле с подсказками браузера (datalist) + кнопки в строку */ +.cfind{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;max-width:720px} +.cfind .btn{flex:none} .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:46px;padding:0 18px;border:1px solid var(--line-2);border-radius:3px;background:#fff;color:var(--ink);font:inherit;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none} .btn:hover{border-color:var(--ink);color:var(--ink)} .btn.main{background:var(--amber);border-color:var(--amber);color:var(--ink)} @@ -445,24 +448,33 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{if .Error}}
{{.Error}}
{{end}} +
+
+
+
+ + {{if .Search}}Сбросить{{end}} +
+ {{range .Suggest}}{{end}} +

+ {{if .Search}}По запросу «{{.Search}}» найдено: {{.Found}}.{{else}}Подсказки — все договоры депозитария ({{plural (len .Suggest) "договор" "договора" "договоров"}}); выберите из списка или введите часть фамилии и нажмите «Найти».{{end}}

+
+ + {{if .Clients}}

Учётные записи кабинета

- {{if .Clients}}
+
{{range .Clients}}{{end}}
КлиентЛогинДоговорСостояние
{{.FullName}}{{.Login}}{{.Contract}} {{if .Active}}активна{{else}}заблокирована{{end}}
- {{else}}

Учётных записей кабинета пока нет.

{{end}} -
+ {{end}}
-

Клиенты депозитария

-
-
- -
+

Клиенты депозитария

+ {{if .Depo}}{{plural (len .Depo) "договор" "договора" "договоров"}}{{end}}
{{if .DepoError}}
{{.DepoError}}
{{end}} {{if .Depo}}
@@ -470,7 +482,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{end}}
КлиентДоговорБумаг
{{.Name}}{{.Contract}}{{.Securities}}
- {{else}}

Ничего не найдено.

{{end}} + {{else}}

{{if .Search}}По запросу «{{.Search}}» договоров не найдено.{{else}}Ничего не найдено.{{end}}

{{end}}

В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.

@@ -559,17 +571,12 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{end}} {{define "documents"}}{{template "head" .}}{{template "nav" .}} -
-
Документы депозитария -
{{len .Ready}}
-
готовых документов{{if .Requests}} · заявок {{len .Requests}}{{end}}
-
{{if .Flash}}
{{.Flash}}
{{end}} {{if .Error}}
{{.Error}}
{{end}}
-

Готовые документы

+

Документы депозитария

{{if .Ready}}{{plural (len .Ready) "документ" "документа" "документов"}}{{end}}
{{if .Ready}}
{{range .Ready}} @@ -579,34 +586,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{end}}
ДатаДокументПериодСчёт депоСостояние
{{.Account}} {{if eq .State "исполнено"}}готов{{else}}{{.State}}{{end}}
-

Файлы документов выдаёт депозитарий: бумажный экземпляр в офисе или по электронной почте. В кабинете виден факт подготовки и период документа.

- {{else}}

Готовых документов пока нет.

{{end}} +

Файлы документов выдаёт депозитарий: бумажный экземпляр в офисе или по электронной почте. За справкой, которой здесь нет, обратитесь к своему менеджеру.

+ {{else}}

Готовых документов пока нет. Выписки и отчёты появятся здесь, как только депозитарий их сформирует; за справкой обратитесь к своему менеджеру.

{{end}}
- -
-
-

Мои заявки

- {{if .Requests}}
{{range .Requests}} -
{{.Title}}{{.Date}}{{if .Period}} · {{.Period}}{{end}}{{with .Note}}{{.}}{{end}} - {{.State}}
- {{end}}
- {{else}}

Заявок пока нет.

{{end}} -
- -
-

Заказать документ

-
-
-
-
-
-
-
-
- -

Заявка уходит сотруднику депозитария. Срок подготовки и стоимость — по тарифам депозитария; готовый документ появится в списке слева.

-
-
-
{{end}} `