Кабинет: поиск клиентов с подсказками, чистая страница документов, ширина настроек

Поиск клиентов (режим сотрудника):
- поле поиска вынесено наверх страницы, рядом «Найти» и «Сбросить»;
- браузер сам подсказывает выпадающим списком по мере ввода — договоры
  депозитария отдаются страницей в <datalist> (скриптов в кабинете нет,
  CSP остаётся строгой), список кэшируется в ядре на 10 минут;
- по запросу фильтруются и учётки кабинета, и договоры депозитария,
  под формой показывается, сколько найдено;
- шаблон LIKE экранируется, чтобы % и _ в номере договора означали себя.

Документы: убраны пустое табло, «Мои заявки» и форма заказа документа —
остался список готовых документов депозитария и пояснение, куда обращаться.

Настройка: колонка сетки больше не растягивается широкой таблицей
(min-width:0), у страницы пропал горизонтальный ползунок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 18:45:54 +03:00
parent 4b80d030ab
commit 683f516d07
7 changed files with 184 additions and 50 deletions
+57 -1
View File
@@ -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)
}
// ---- подсказки в поиске клиентов (админка ЛК) ----
// Список договоров нужен на каждой загрузке страницы «Клиенты»: браузер сам
// показывает выпадающий список по мере ввода (<datalist>), без скриптов на
// странице. Запрос дешёвый, но их 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
}
+7 -1
View File
@@ -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()
}
+17
View File
@@ -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
}
+11 -2
View File
@@ -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;
+17 -3
View File
@@ -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)})
})
// Администратор: войти в кабинет клиента
+51
View File
@@ -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"`, `<datalist id="cl-suggest">`,
`<option value="БР-746 Бубликов Иван Петрович"></option>`, "Найти"} {
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)
}
}
+24 -43
View File
@@ -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}
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<section class="sec">
<form method="get" action="/clients" class="cfind">
<div style="flex:1 1 320px;min-width:0"><label for="s">Договор, фамилия или номер клиента</label>
<input id="s" name="search" value="{{.Search}}" list="cl-suggest" autocomplete="off" placeholder="начните вводить — появятся подсказки"></div>
<button class="btn main" type="submit">Найти</button>
{{if .Search}}<a class="btn" href="/clients">Сбросить</a>{{end}}
</form>
<datalist id="cl-suggest">{{range .Suggest}}<option value="{{.}}"></option>{{end}}</datalist>
<p class="mut" style="margin:10px 0 0;font-size:12.5px">
{{if .Search}}По запросу «{{.Search}}» найдено: {{.Found}}.{{else}}Подсказки — все договоры депозитария ({{plural (len .Suggest) "договор" "договора" "договоров"}}); выберите из списка или введите часть фамилии и нажмите «Найти».{{end}}</p>
</section>
{{if .Clients}}<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Учётные записи кабинета</h2>
{{if .Clients}}<div class="tw"><table>
<div class="tw"><table>
<thead><tr><th>Клиент</th><th>Логин</th><th>Договор</th><th>Состояние</th><th></th></tr></thead>
<tbody>{{range .Clients}}<tr>
<td><b>{{.FullName}}</b></td><td class="n">{{.Login}}</td><td class="n">{{.Contract}}</td>
<td>{{if .Active}}<span class="chip ok">активна</span>{{else}}<span class="chip err">заблокирована</span>{{end}}</td>
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="login" value="{{.Login}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Учётных записей кабинета пока нет.</p>{{end}}
</section>
</section>{{end}}
<section class="sec">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Клиенты депозитария</h2></div>
<form method="get" action="/clients" style="display:flex;gap:10px;align-items:flex-end;max-width:560px;margin-bottom:14px">
<div style="flex-grow:1"><label for="s">Поиск</label><input id="s" name="search" value="{{.Search}}" placeholder="фамилия или номер договора"></div>
<button class="btn" type="submit">Найти</button>
</form>
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Клиенты депозитария</h2>
{{if .Depo}}<span class="mut" style="font-size:12.5px">{{plural (len .Depo) "договор" "договора" "договоров"}}</span>{{end}}</div>
{{if .DepoError}}<div class="msg err">{{.DepoError}}</div>{{end}}
{{if .Depo}}<div class="tw"><table>
<thead><tr><th>Клиент</th><th>Договор</th><th class="num">Бумаг</th><th></th></tr></thead>
@@ -470,7 +482,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<td><b>{{.Name}}</b></td><td class="n">{{.Contract}}</td><td class="num">{{.Securities}}</td>
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="contract" value="{{.Contract}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
</tr>{{end}}</tbody></table></div>
{{else}}<p class="empty">Ничего не найдено.</p>{{end}}
{{else}}<p class="empty">{{if .Search}}По запросу «{{.Search}}» договоров не найдено.{{else}}Ничего не найдено.{{end}}</p>{{end}}
</section>
<p class="mut">В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.</p>
@@ -559,17 +571,12 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</main></body></html>{{end}}
{{define "documents"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Документы депозитария</span>
<div class="big" style="margin-top:8px;font-size:32px">{{len .Ready}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">готовых документов{{if .Requests}} · заявок {{len .Requests}}{{end}}</div></div>
</div></section>
<main>
{{if .Flash}}<div class="msg ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Готовые документы</h2>
<div class="sec-h"><h1 style="font-size:19px;margin:0">Документы депозитария</h1>{{if .Ready}}<span class="mut" style="font-size:12.5px">{{plural (len .Ready) "документ" "документа" "документов"}}</span>{{end}}</div>
{{if .Ready}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Документ</th><th>Период</th><th>Счёт депо</th><th>Состояние</th></tr></thead>
<tbody>{{range .Ready}}<tr>
@@ -579,34 +586,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<td class="n" style="font-size:13px">{{.Account}}</td>
<td>{{if eq .State "исполнено"}}<span class="chip ok">готов</span>{{else}}<span class="chip go">{{.State}}</span>{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:14px">Файлы документов выдаёт депозитарий: бумажный экземпляр в офисе или по электронной почте. В кабинете виден факт подготовки и период документа.</p>
{{else}}<p class="empty">Готовых документов пока нет.</p>{{end}}
<p class="mut" style="margin-top:14px">Файлы документов выдаёт депозитарий: бумажный экземпляр в офисе или по электронной почте. За справкой, которой здесь нет, обратитесь к своему менеджеру.</p>
{{else}}<p class="empty">Готовых документов пока нет. Выписки и отчёты появятся здесь, как только депозитарий их сформирует; за справкой обратитесь к своему менеджеру.</p>{{end}}
</section>
<div class="grid2">
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Мои заявки</h2>
{{if .Requests}}<div class="rows">{{range .Requests}}
<div class="row"><span>{{.Title}}<span class="n mut" style="display:block">{{.Date}}{{if .Period}} · {{.Period}}{{end}}</span>{{with .Note}}<span class="mut" style="display:block">{{.}}</span>{{end}}</span>
<span class="chip {{if eq .State "готов"}}ok{{else if eq .State "отклонён"}}err{{else}}go{{end}}" style="align-self:center">{{.State}}</span></div>
{{end}}</div>
{{else}}<p class="empty">Заявок пока нет.</p>{{end}}
</section>
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Заказать документ</h2>
<form method="post" action="/documents" class="sheet" style="display:grid;gap:12px;padding:18px 20px"><input type="hidden" name="csrf" value="{{$.CSRF}}">
<div><label for="kind">Вид документа</label>
<select id="kind" name="kind">{{range .Kinds}}<option>{{.}}</option>{{end}}</select></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}"></div>
</div>
<div><label for="comment">Комментарий</label><input id="comment" name="comment" placeholder="необязательно"></div>
<button class="btn main" type="submit">Отправить заявку</button>
<p class="mut" style="margin:0">Заявка уходит сотруднику депозитария. Срок подготовки и стоимость — по тарифам депозитария; готовый документ появится в списке слева.</p>
</form>
</section>
</div>
</main></body></html>{{end}}
`