Кабинет: поиск клиентов с подсказками, чистая страница документов, ширина настроек
Поиск клиентов (режим сотрудника): - поле поиска вынесено наверх страницы, рядом «Найти» и «Сбросить»; - браузер сам подсказывает выпадающим списком по мере ввода — договоры депозитария отдаются страницей в <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:
+17
-3
@@ -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)})
|
||||
})
|
||||
|
||||
// Администратор: войти в кабинет клиента
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user