From 4ce33f4ea73e79bac4ace756fb0fe2868c62ed27 Mon Sep 17 00:00:00 2001
From: Evgeny Kuzmin <230209931+ht2473@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:39:40 +0300
Subject: [PATCH 01/20] =?UTF-8?q?=D0=A1=D1=86=D0=B5=D0=BD=D0=B0=D1=80?=
=?UTF-8?q?=D0=B8=D0=B9=20=D1=81=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=BC=20=D0=BE?=
=?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=BC:=20=D0=B2=D1=80?=
=?UTF-8?q?=D0=B5=D0=BC=D1=8F=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=B1=D0=B5?=
=?UTF-8?q?=D0=B7=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20?=
=?UTF-8?q?=D0=BD=D0=B5=20=D0=BB=D0=BE=D0=BC=D0=B0=D0=B5=D1=82=20=D0=BE?=
=?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/api/scenarios_test.go | 32 +++++++++++++++++++++++
internal/scenario/scenario.go | 4 +--
web/src/app/SectionPage.tsx | 22 ----------------
web/src/app/sections.ts | 46 ----------------------------------
4 files changed, 34 insertions(+), 70 deletions(-)
create mode 100644 internal/api/scenarios_test.go
delete mode 100644 web/src/app/SectionPage.tsx
delete mode 100644 web/src/app/sections.ts
diff --git a/internal/api/scenarios_test.go b/internal/api/scenarios_test.go
new file mode 100644
index 0000000..bf483e5
--- /dev/null
+++ b/internal/api/scenarios_test.go
@@ -0,0 +1,32 @@
+package api
+
+import (
+ "testing"
+
+ "freightflow/internal/scenario"
+)
+
+// Новый объект в базе не существует: времени базы нет, и итог сценария всё равно отдаётся
+// (null в baseMin не проходит схему ответа, и падал бы весь список сценариев).
+func TestToScenarioNewFacilityWithoutBase(t *testing.T) {
+ sm := 12.5
+ sc := &scenario.Scenario{
+ Status: "done",
+ Params: scenario.Params{Profile: "truck_20", Targets: []string{"mkad"}},
+ Summary: &scenario.Summary{
+ Targets: []scenario.TargetSummary{},
+ Closures: []scenario.ClosureInfo{},
+ NewFacility: []scenario.EntityTime{
+ {Kind: "mkad", ScenarioMin: &sm, BaseStatus: "unreachable", ScenarioStatus: "ok"},
+ },
+ },
+ }
+ out, err := toScenario(sc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nf := out.Summary.Value.NewFacility
+ if len(nf) != 1 || nf[0].BaseMin.Set || nf[0].ScenarioMin.Value != sm {
+ t.Fatalf("newFacility = %+v", nf)
+ }
+}
diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
index a9fa5f5..18fde01 100644
--- a/internal/scenario/scenario.go
+++ b/internal/scenario/scenario.go
@@ -277,8 +277,8 @@ type ClosureInfo struct {
// EntityTime — время до цели: база и сценарий (nil — недостижимо или в закрытой зоне).
type EntityTime struct {
Kind string `json:"kind"`
- BaseMin *float64 `json:"baseMin"`
- ScenarioMin *float64 `json:"scenarioMin"`
+ BaseMin *float64 `json:"baseMin,omitempty"`
+ ScenarioMin *float64 `json:"scenarioMin,omitempty"`
BaseStatus string `json:"baseStatus"`
ScenarioStatus string `json:"scenarioStatus"`
}
diff --git a/web/src/app/SectionPage.tsx b/web/src/app/SectionPage.tsx
deleted file mode 100644
index bdd259e..0000000
--- a/web/src/app/SectionPage.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Hourglass } from "lucide-react";
-import { EmptyState, PageHeader } from "@/shared/ui";
-import { Guard } from "./Guard";
-import s from "./pages.module.css";
-import type { Section } from "./sections";
-
-/** Раздел без данных: назначение и состав, без имитации содержимого. */
-export function SectionPage({ section }: { section: Section }) {
- return (
-
-
- } title="Данные ещё не загружены">
- Раздел заполнится после подключения источников и первых расчётов.
-
- {section.points.map((p) => (
- - {p}
- ))}
-
-
-
- );
-}
diff --git a/web/src/app/sections.ts b/web/src/app/sections.ts
deleted file mode 100644
index 47a75aa..0000000
--- a/web/src/app/sections.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { Permission } from "@/shared/auth";
-
-/** Раздел, который наполняется по мере подключения данных и расчётов. */
-export interface Section {
- path: string;
- title: string;
- permission: Permission;
- summary: string;
- points: string[];
-}
-
-export const sections: Section[] = [
- {
- path: "/operations",
- title: "Оперативная обстановка",
- permission: "map.view",
- summary: "Дорожные работы и ограничения движения с жизненным циклом записей.",
- points: [
- "Плановые и аварийные работы, ограничения на период работ",
- "Влияние на значимые участки, мосты и подъезды к объектам",
- "Лента изменений по ежедневным снимкам",
- ],
- },
- {
- path: "/alerts",
- title: "Алерты",
- permission: "alerts.own",
- summary: "Правила наблюдения и уведомления об изменениях.",
- points: [
- "Работы на коридоре, у объекта или в области",
- "Изменение ограничений и доступности объектов",
- "Каналы: в системе, почта, Telegram, вебхуки",
- ],
- },
- {
- path: "/reports",
- title: "Отчёты",
- permission: "reports.view",
- summary: "Отчёты и выгрузки по шаблонам.",
- points: [
- "Паспорт объекта, сводка по округу, проходимость, работы",
- "PDF, XLSX, CSV, GeoJSON, GeoPackage",
- "Расписание и рассылка",
- ],
- },
-];
From 37647ef1ce2d3d601d1b6e13651905e078f01543 Mon Sep 17 00:00:00 2001
From: Evgeny Kuzmin <230209931+ht2473@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:39:40 +0300
Subject: [PATCH 02/20] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?=
=?UTF-8?q?=D0=B0=20demo:=20=D0=B4=D0=B5=D0=BC=D0=BE=D0=BD=D1=81=D1=82?=
=?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BD?=
=?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=80=20=D0=B4=D0=BB=D1=8F=20=D1=83=D1=87?=
=?UTF-8?q?=D1=91=D1=82=D0=BD=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?=
=?UTF-8?q?=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/app/app.go | 1 +
internal/app/demo.go | 540 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 541 insertions(+)
create mode 100644 internal/app/demo.go
diff --git a/internal/app/app.go b/internal/app/app.go
index cd2d740..3bc6689 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -49,6 +49,7 @@ func commands() []command {
{"search", "поиск: reindex [-force] [вид...] (индекс документов)", runSearch},
{"migrate", "миграции базы данных: up | down | status", runMigrate},
{"admin", "администрирование: create-user | reset-2fa", runAdmin},
+ {"demo", "демонстрационный набор для учётной записи: -email ... (нужен запущенный сервер)", runDemo},
{"version", "версия сборки", runVersion},
}
}
diff --git a/internal/app/demo.go b/internal/app/demo.go
new file mode 100644
index 0000000..8f6e682
--- /dev/null
+++ b/internal/app/demo.go
@@ -0,0 +1,540 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "freightflow/internal/identity"
+)
+
+// runDemo наполняет учётную запись демонстрационным набором: дашборд, сохранённые виды,
+// правила алертов, сценарии, оценка локации, отчёты и расписание. Всё создаётся через API
+// запущенного сервера от имени пользователя по временному токену, который в конце отзывается;
+// значения считаются системой по загруженным данным. Повторный запуск добавляет только то,
+// чего ещё нет (по названию).
+func runDemo(ctx context.Context, env *Env, args []string) error {
+ fs := flag.NewFlagSet("demo", flag.ContinueOnError)
+ fs.SetOutput(env.Stderr)
+ email := fs.String("email", "", "адрес электронной почты пользователя")
+ base := fs.String("api", "http://127.0.0.1:8080/api/v1", "адрес API запущенного сервера")
+ if err := fs.Parse(args); err != nil || *email == "" {
+ fmt.Fprintln(env.Stderr, "Использование: freightflow demo -email ... [-api http://127.0.0.1:8080/api/v1]")
+ return ErrUsage
+ }
+ pool, err := openDatabase(ctx, env)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ ids, err := newIdentity(ctx, env, pool)
+ if err != nil {
+ return err
+ }
+ users, err := ids.ListUsers(ctx)
+ if err != nil {
+ return err
+ }
+ target := strings.ToLower(strings.TrimSpace(*email))
+ var p identity.Principal
+ for _, u := range users {
+ if u.Email == target {
+ if p, err = ids.Principal(ctx, u.ID); err != nil {
+ return err
+ }
+ }
+ }
+ if p.UserID == [16]byte{} {
+ return fmt.Errorf("user %s not found", target)
+ }
+ meta := identity.RequestMeta{UserAgent: "freightflow demo"}
+ expires := time.Now().Add(time.Hour)
+ plain, tok, err := ids.CreateAPIToken(ctx, p, "Демонстрационный набор", p.Permissions, &expires, meta)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := ids.RevokeAPIToken(context.WithoutCancel(ctx), p, tok.ID, meta); err != nil {
+ fmt.Fprintf(env.Stderr, "не удалось отозвать временный токен %s: %v\n", tok.ID, err)
+ }
+ }()
+ d := &demo{base: strings.TrimRight(*base, "/"), token: plain, out: env.Stdout, http: &http.Client{Timeout: 2 * time.Minute}}
+ return d.run(ctx)
+}
+
+type demo struct {
+ base, token string
+ out io.Writer
+ http *http.Client
+}
+
+type obj = map[string]any
+
+func (d *demo) call(ctx context.Context, method, path string, body, out any) error {
+ var rd io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rd = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, d.base+path, rd)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+d.token)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ res, err := d.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ data, err := io.ReadAll(res.Body)
+ if err != nil {
+ return err
+ }
+ if res.StatusCode >= 300 {
+ return fmt.Errorf("%s %s: %d %s", method, path, res.StatusCode, strings.TrimSpace(string(data)))
+ }
+ if out != nil && len(data) > 0 {
+ return json.Unmarshal(data, out)
+ }
+ return nil
+}
+
+func (d *demo) logf(format string, a ...any) {
+ fmt.Fprintf(d.out, format+"\n", a...)
+}
+
+// named — названия уже созданных записей: повторный запуск их не дублирует.
+func named(items []obj) map[string]string {
+ out := map[string]string{}
+ for _, it := range items {
+ name, _ := it["name"].(string)
+ if name == "" {
+ name, _ = it["title"].(string)
+ }
+ id, _ := it["id"].(string)
+ out[name] = id
+ }
+ return out
+}
+
+func (d *demo) run(ctx context.Context) error {
+ steps := []struct {
+ title string
+ fn func(context.Context) error
+ }{
+ {"Дашборд", d.dashboard},
+ {"Виды карты и реестра", d.views},
+ {"Правила алертов", d.rules},
+ {"Сценарии", d.scenarios},
+ {"Оценка локации", d.site},
+ {"Отчёты и расписания", d.reports},
+ }
+ var errs []error
+ for _, st := range steps {
+ d.logf("== %s", st.title)
+ if err := st.fn(ctx); err != nil {
+ d.logf(" ошибка: %v", err)
+ errs = append(errs, fmt.Errorf("%s: %w", st.title, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// dashboard — набор по умолчанию и рейтинги двух сводных индексов.
+func (d *demo) dashboard(ctx context.Context) error {
+ if err := d.call(ctx, http.MethodDelete, "/me/dashboard", nil, nil); err != nil {
+ return err
+ }
+ var dash struct {
+ Widgets []obj `json:"widgets"`
+ }
+ if err := d.call(ctx, http.MethodGet, "/me/dashboard", nil, &dash); err != nil {
+ return err
+ }
+ have := map[string]bool{}
+ for _, w := range dash.Widgets {
+ if ix, ok := w["index"].(string); ok {
+ have[ix] = true
+ }
+ }
+ for _, ix := range []string{"index.accessibility", "index.restrictions"} {
+ if !have[ix] {
+ dash.Widgets = append(dash.Widgets, obj{"kind": "index", "index": ix, "level": "district"})
+ }
+ }
+ if err := d.call(ctx, http.MethodPut, "/me/dashboard", obj{"widgets": dash.Widgets}, nil); err != nil {
+ return err
+ }
+ d.logf(" виджетов: %d", len(dash.Widgets))
+ return nil
+}
+
+func (d *demo) views(ctx context.Context) error {
+ views := []obj{
+ {"page": "map", "name": "Объекты и дорожные работы", "params": obj{
+ "c": "37.62000,55.75000", "z": 10, "l": "facilities,facility_areas,rings,road_works",
+ }},
+ {"page": "map", "name": "Ограничения для грузовиков в центре", "params": obj{
+ "c": "37.62000,55.75500", "z": 12.5, "l": "facilities,rings,restrictions,cargo_frame",
+ }},
+ {"page": "map", "name": "Критичные мосты и устойчивость сети", "params": obj{
+ "c": "37.62000,55.74000", "z": 10.5, "l": "rings,critical_elements,resilience",
+ }},
+ {"page": "map", "name": "Складские площади по ячейкам H3", "params": obj{
+ "c": "37.62000,55.73000", "z": 10, "l": "rings,h3_cells,industrial_zones",
+ }},
+ {"page": "facilities", "name": "Крупные логистические центры", "params": obj{
+ "type": "logistics_center,terminal,sorting_center", "amin": 20000, "sort": "-area",
+ }},
+ {"page": "facilities", "name": "Склады до 15 минут от МКАД", "params": obj{
+ "type": "warehouse,cold_storage", "mkad": 15, "conf": "high",
+ }},
+ {"page": "facilities", "name": "Объекты с ограничениями подъезда", "params": obj{
+ "restr": "yes", "sort": "-area",
+ }},
+ }
+ for _, page := range []string{"map", "facilities"} {
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/views?page="+page, nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, v := range views {
+ if v["page"] != page {
+ continue
+ }
+ if _, ok := names[v["name"].(string)]; ok {
+ continue
+ }
+ if err := d.call(ctx, http.MethodPost, "/views", v, nil); err != nil {
+ return err
+ }
+ d.logf(" вид «%s»", v["name"])
+ }
+ }
+ return nil
+}
+
+// topFacilities — крупнейшие по площади логистические центры реестра.
+func (d *demo) topFacilities(ctx context.Context, n int) ([]string, error) {
+ var page struct {
+ Items []obj `json:"items"`
+ }
+ q := url.Values{"type": {"logistics_center"}, "sort": {"-area"}, "limit": {fmt.Sprint(n)}}
+ if err := d.call(ctx, http.MethodGet, "/facilities?"+q.Encode(), nil, &page); err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(page.Items))
+ for _, it := range page.Items {
+ if id, ok := it["id"].(string); ok {
+ out = append(out, id)
+ }
+ }
+ return out, nil
+}
+
+func (d *demo) rules(ctx context.Context) error {
+ top, err := d.topFacilities(ctx, 8)
+ if err != nil {
+ return err
+ }
+ // Варшавское шоссе от ТТК до МКАД — коридор для правила по работам.
+ varshavka := obj{"type": "LineString", "coordinates": [][]float64{
+ {37.6195, 55.7065}, {37.6230, 55.6870}, {37.6250, 55.6600}, {37.6175, 55.6330}, {37.6060, 55.6000}, {37.5960, 55.5760},
+ }}
+ // Область в пределах Третьего транспортного кольца (контур по основным развязкам).
+ ttk := obj{"type": "Polygon", "coordinates": [][][]float64{{
+ {37.6170, 55.7920},
+ {37.6680, 55.7810},
+ {37.6870, 55.7600},
+ {37.6800, 55.7320},
+ {37.6400, 55.7080},
+ {37.5870, 55.7130},
+ {37.5560, 55.7440},
+ {37.5650, 55.7760},
+ {37.6170, 55.7920},
+ }}}
+ rules := []obj{
+ {
+ "name": "Перекрытия в пределах ТТК", "kind": "roadworks", "scope": obj{"type": "area", "polygon": ttk},
+ "severity": "critical",
+ "params": obj{"closureOnly": true, "changes": []string{"appeared", "started"}}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Работы у крупнейших логистических центров", "kind": "roadworks",
+ "scope": obj{"type": "facilities", "facilityIds": top, "radiusM": 1000}, "severity": "warning",
+ "params": obj{"changes": []string{"appeared", "changed", "started"}}, "channels": []string{"in_app", "email"},
+ },
+ {
+ "name": "Варшавское шоссе: работы на коридоре", "kind": "roadworks",
+ "scope": obj{"type": "corridor", "line": varshavka, "bufferM": 300}, "severity": "warning",
+ "params": obj{"changes": []string{"appeared", "started"}}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Доступность крупнейших центров до МКАД", "kind": "accessibility",
+ "scope": obj{"type": "facilities", "facilityIds": top, "radiusM": 500}, "severity": "critical",
+ "params": obj{"profile": "truck_20", "target": "mkad", "thresholdMin": 5}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Изменения ограничений для грузовиков в пределах ТТК", "kind": "restrictions",
+ "scope": obj{"type": "area", "polygon": ttk},
+ "severity": "info", "params": obj{}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Новые объекты в реестре", "kind": "facilities", "scope": obj{"type": "city"},
+ "severity": "info", "params": obj{}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Сбои и устаревание источников", "kind": "sources", "scope": obj{"type": "none"},
+ "severity": "warning", "params": obj{"staleFactor": 2}, "channels": []string{"in_app"},
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/alert-rules", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, r := range rules {
+ id, ok := names[r["name"].(string)]
+ if !ok {
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/alert-rules", r, &created); err != nil {
+ return err
+ }
+ id, _ = created["id"].(string)
+ }
+ var check struct {
+ Hits int `json:"hits"`
+ }
+ if err := d.call(ctx, http.MethodPost, "/alert-rules/"+id+"/check", nil, &check); err != nil {
+ return err
+ }
+ d.logf(" правило «%s»: срабатываний %d", r["name"], check.Hits)
+ }
+ return nil
+}
+
+func (d *demo) scenarios(ctx context.Context) error {
+ var elems []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios/elements?limit=3", nil, &elems); err != nil {
+ return err
+ }
+ if len(elems) == 0 {
+ return errors.New("нет рейтинга критичности: сначала выполните сетевой анализ")
+ }
+ first := elems[0]
+ list := []obj{
+ {
+ "name": fmt.Sprintf("Перекрытие: %v", first["name"]),
+ "description": "Выпадение элемента с наибольшим ущербом в рейтинге критичности: как меняется время грузовика 20 т до МКАД и ТТК.",
+ "params": obj{
+ "profile": "truck_20", "zones": true, "targets": []string{"mkad", "ttk"},
+ "closures": []obj{{"kind": "element", "element": first["id"], "label": first["name"]}},
+ },
+ },
+ {
+ "name": "Новый логистический центр в промзоне Бирюлёво",
+ "description": "Объект у Липецкой улицы: доступность до центра и МКАД, изохроны 15 и 30 минут.",
+ "params": obj{
+ "profile": "truck_20", "zones": true, "targets": []string{"center", "mkad"},
+ "newFacility": obj{"name": "Логистический центр (проект)", "lon": 37.6720, "lat": 55.5880},
+ "isochroneMinutes": []int{15, 30},
+ },
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, sc := range list {
+ id, ok := names[sc["name"].(string)]
+ if !ok {
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/scenarios", sc, &created); err != nil {
+ return err
+ }
+ id, _ = created["id"].(string)
+ }
+ status, err := d.waitScenario(ctx, id)
+ if err != nil {
+ return err
+ }
+ d.logf(" сценарий «%s»: %s", sc["name"], status)
+ }
+ return nil
+}
+
+// waitScenario ждёт окончания расчёта сценария (до 10 минут).
+func (d *demo) waitScenario(ctx context.Context, id string) (string, error) {
+ deadline := time.Now().Add(10 * time.Minute)
+ for {
+ var sc struct {
+ Status string `json:"status"`
+ Error string `json:"error"`
+ }
+ if err := d.call(ctx, http.MethodGet, "/scenarios/"+id, nil, &sc); err != nil {
+ return "", err
+ }
+ switch sc.Status {
+ case "done":
+ return sc.Status, nil
+ case "failed":
+ return "", fmt.Errorf("сценарий %s: %s", id, sc.Error)
+ }
+ if time.Now().After(deadline) {
+ return sc.Status, nil
+ }
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-time.After(5 * time.Second):
+ }
+ }
+}
+
+func (d *demo) site(ctx context.Context) error {
+ const name = "Распределительный центр последней мили"
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/site-selection/runs", nil, &have); err != nil {
+ return err
+ }
+ if _, ok := named(have)[name]; ok {
+ return nil
+ }
+ params := obj{
+ "resolution": 8,
+ "criteria": []obj{
+ {"metric": "h3.access_mkad_min", "benefit": false, "weight": 3},
+ {"metric": "h3.population_5km", "benefit": true, "weight": 3},
+ {"metric": "h3.share_industrial", "benefit": true, "weight": 2},
+ {"metric": "h3.infra", "benefit": true, "weight": 1},
+ {"metric": "h3.restricted_km", "benefit": false, "weight": 2},
+ },
+ "weighting": "direct",
+ "exclusions": []string{"residential", "heritage", "parks", "green", "water"},
+ "exclusionShare": 50,
+ "top": 10,
+ "spacingKm": 2,
+ }
+ if err := d.call(ctx, http.MethodPost, "/site-selection/runs", obj{"name": name, "params": params}, nil); err != nil {
+ return err
+ }
+ d.logf(" оценка «%s»", name)
+ return nil
+}
+
+func (d *demo) reports(ctx context.Context) error {
+ var territories []obj
+ if err := d.call(ctx, http.MethodGet, "/territories?kind=district", nil, &territories); err != nil {
+ return err
+ }
+ district := ""
+ for _, t := range territories {
+ if n, _ := t["name"].(string); strings.Contains(strings.ToLower(n), "нагатинский затон") {
+ district, _ = t["id"].(string)
+ }
+ }
+ top, err := d.topFacilities(ctx, 1)
+ if err != nil {
+ return err
+ }
+ var scenarios, runs []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios", nil, &scenarios); err != nil {
+ return err
+ }
+ if err := d.call(ctx, http.MethodGet, "/site-selection/runs", nil, &runs); err != nil {
+ return err
+ }
+ reports := []obj{
+ {"template": "roadworks", "format": "pdf", "params": obj{"days": "14"}},
+ {"template": "quality", "format": "pdf"},
+ }
+ if district != "" {
+ reports = append(reports,
+ obj{"template": "territory", "format": "pdf", "params": obj{"territoryId": district}},
+ obj{"template": "passability", "format": "pdf", "params": obj{"territoryId": district, "profile": "truck_20"}},
+ )
+ }
+ if len(top) > 0 {
+ reports = append(reports,
+ obj{"template": "facility", "format": "pdf", "params": obj{"facilityId": top[0]}},
+ obj{"template": "facility", "format": "xlsx", "params": obj{"facilityId": top[0]}},
+ )
+ }
+ for _, sc := range scenarios {
+ if sc["status"] == "done" {
+ reports = append(reports, obj{"template": "scenario", "format": "pdf", "params": obj{"scenarioId": sc["id"]}})
+ break
+ }
+ }
+ if len(runs) > 0 {
+ reports = append(reports, obj{"template": "site", "format": "pdf", "params": obj{"siteRunId": runs[0]["id"]}})
+ }
+ // Уже сформированные отчёты (тот же шаблон, формат и параметры) повторно не заказываются.
+ var existing []obj
+ if err := d.call(ctx, http.MethodGet, "/reports", nil, &existing); err != nil {
+ return err
+ }
+ key := func(r obj) string {
+ b, _ := json.Marshal(r["params"])
+ if string(b) == "null" {
+ b = []byte("{}")
+ }
+ return fmt.Sprint(r["template"], "|", r["format"], "|", string(b))
+ }
+ done := map[string]bool{}
+ for _, r := range existing {
+ done[key(r)] = true
+ }
+ for _, r := range reports {
+ if done[key(r)] {
+ continue
+ }
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/reports", r, &created); err != nil {
+ return err
+ }
+ d.logf(" отчёт «%v»", created["title"])
+ }
+
+ schedules := []obj{
+ {
+ "template": "roadworks", "format": "pdf", "params": obj{"days": "7"}, "title": "Дорожные работы на неделю",
+ "frequency": "weekly", "weekday": 1, "hour": 8,
+ },
+ {
+ "template": "quality", "format": "xlsx", "title": "Качество данных за месяц",
+ "frequency": "monthly", "monthDay": 1, "hour": 9,
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/report-schedules", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, sc := range schedules {
+ if _, ok := names[sc["title"].(string)]; ok {
+ continue
+ }
+ if err := d.call(ctx, http.MethodPost, "/report-schedules", sc, nil); err != nil {
+ return err
+ }
+ d.logf(" расписание «%s»", sc["title"])
+ }
+ return nil
+}
From b2ddbff93290d0cc02c2d2b5a7c5e4ac5a097d4e Mon Sep 17 00:00:00 2001
From: Evgeny Kuzmin <230209931+ht2473@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:39:55 +0300
Subject: [PATCH 03/20] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?=
=?UTF-8?q?=C2=AB=D0=9E=D0=B1=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?=
=?UTF-8?q?=D0=B0=C2=BB:=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B6=D0=BD=D1=8B?=
=?UTF-8?q?=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B,=20=D0=B4=D0=B8?=
=?UTF-8?q?=D0=BD=D0=B0=D0=BC=D0=B8=D0=BA=D0=B0,=20=D0=BF=D0=B5=D1=80?=
=?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=B4=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80?=
=?UTF-8?q?=D1=82=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/app/nav.test.ts | 14 +-
web/src/app/router.tsx | 21 +-
.../features/operations/OperationsPage.tsx | 216 ++++++++++++++++++
.../features/operations/operations.module.css | 69 ++++++
4 files changed, 291 insertions(+), 29 deletions(-)
create mode 100644 web/src/features/operations/OperationsPage.tsx
create mode 100644 web/src/features/operations/operations.module.css
diff --git a/web/src/app/nav.test.ts b/web/src/app/nav.test.ts
index 9c417f2..9ae7f0b 100644
--- a/web/src/app/nav.test.ts
+++ b/web/src/app/nav.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
-import { navGroups, navItemFor, titleFor } from "./nav";
-import { sections } from "./sections";
+import { navItemFor, titleFor } from "./nav";
describe("titleFor", () => {
it("находит раздел по адресу и вложенному адресу", () => {
@@ -15,14 +14,3 @@ describe("titleFor", () => {
expect(titleFor("/unknown")).toBe("FreightFlow");
});
});
-
-describe("sections", () => {
- it("у каждого раздела-заглушки есть пункт меню с тем же правом", () => {
- const items = navGroups.flatMap((g) => g.items);
- for (const sec of sections) {
- const item = items.find((it) => it.to === sec.path);
- expect(item, sec.path).toBeDefined();
- expect(item?.permission).toBe(sec.permission);
- }
- });
-});
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index d6e9622..1d6ae2b 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -9,8 +9,6 @@ import { reportsSearchSchema } from "@/features/reports/ReportsPage";
import { territorySearchSchema } from "@/features/territories/search";
import { ErrorPage, PendingPage } from "./ErrorPage";
import { NotFound } from "./NotFound";
-import { SectionPage } from "./SectionPage";
-import { sections } from "./sections";
import { AppShell } from "./shell/AppShell";
const rootRoute = createRootRoute({
@@ -26,19 +24,6 @@ const indexRoute = createRoute({
},
});
-/** Раздел, наполнение которого появляется вместе с данными; пока — описание и пустое состояние. */
-function sectionRoute
(path: P) {
- const section = sections.find((sec) => sec.path === path);
- if (!section) {
- throw new Error(`нет описания раздела ${path}`);
- }
- return createRoute({
- getParentRoute: () => rootRoute,
- path,
- component: () => ,
- });
-}
-
const routeTree = rootRoute.addChildren([
indexRoute,
createRoute({
@@ -52,7 +37,11 @@ const routeTree = rootRoute.addChildren([
validateSearch: (search: Record) => mapSearchSchema.parse(search),
component: lazyRouteComponent(() => import("@/features/map/MapPage"), "MapPage"),
}),
- sectionRoute("/operations"),
+ createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/operations",
+ component: lazyRouteComponent(() => import("@/features/operations/OperationsPage"), "OperationsPage"),
+ }),
createRoute({
getParentRoute: () => rootRoute,
path: "/api-docs",
diff --git a/web/src/features/operations/OperationsPage.tsx b/web/src/features/operations/OperationsPage.tsx
new file mode 100644
index 0000000..aebf7b9
--- /dev/null
+++ b/web/src/features/operations/OperationsPage.tsx
@@ -0,0 +1,216 @@
+import { Link } from "@tanstack/react-router";
+import { FileText, Map as MapIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+import { Guard } from "@/app/Guard";
+import { $api, problemMessage } from "@/shared/api/client";
+import { date, num } from "@/shared/format";
+import { Badge, cellClass, EmptyState, PageHeader, Section, Skeleton, TextField, tableClass } from "@/shared/ui";
+import { Chart } from "@/shared/ui/Chart";
+import { KpiTile } from "@/shared/ui/KpiTile";
+import { SegmentedControl } from "@/shared/ui/navigation";
+import { workKindLabel } from "../facilities/labels";
+import { formatCenter } from "../map/state";
+import s from "./operations.module.css";
+
+/** Сводка работ — тот же ответ, что у отчёта «Дорожные работы» (/report-data/roadworks). */
+interface Roadworks {
+ days: number;
+ active: number;
+ planned: number;
+ closures: number;
+ byKind: Record;
+ works: Work[];
+ series: { day: string; value: number }[];
+}
+
+interface Work {
+ id: string;
+ title: string;
+ kind: "repair" | "emergency" | "closure";
+ place: string;
+ district: string;
+ state: "active" | "planned";
+ closure: boolean;
+ start?: string;
+ end?: string;
+ facilities: number;
+ impactKm: number;
+ geometry?: { type: string; coordinates: unknown };
+}
+
+type StateFilter = "all" | "active" | "planned" | "closure";
+
+const stateFilters: { id: StateFilter; label: string }[] = [
+ { id: "all", label: "Все" },
+ { id: "active", label: "Идут" },
+ { id: "planned", label: "Начнутся" },
+ { id: "closure", label: "С перекрытием" },
+];
+
+export function OperationsPage() {
+ return (
+
+
+
+ );
+}
+
+function Operations() {
+ const days = 14;
+ const q = $api.useQuery("get", "/report-data/roadworks", { params: { query: { days } } });
+ const d = q.data as Roadworks | undefined;
+ const [filter, setFilter] = useState("all");
+ const [text, setText] = useState("");
+ const shown = useMemo(() => {
+ const needle = text.trim().toLowerCase();
+ return (d?.works ?? []).filter(
+ (w) =>
+ (filter === "all" || (filter === "closure" ? w.closure : w.state === filter)) &&
+ (!needle || `${w.title} ${w.place} ${w.district}`.toLowerCase().includes(needle)),
+ );
+ }, [d, filter, text]);
+
+ return (
+ <>
+
+
+ На карте
+
+
+ Отчёт
+
+ >
+ }
+ />
+ {q.isPending ? (
+
+ ) : q.error || !d ? (
+
+ {problemMessage(q.error)}
+
+ ) : (
+
+
+
+
+
+
+
+ {d.series.length > 1 && (
+
+ date(p.day))}
+ series={[{ name: "Идущие работы", kind: "line", color: "acc", data: d.series.map((p) => p.value) }]}
+ height={220}
+ exportName="works-active"
+ compact
+ />
+
+ )}
+
+
+ setFilter(v as StateFilter)}
+ options={stateFilters}
+ />
+
+ Показано: {num(shown.length)}
+
+ {shown.length === 0 ? (
+ Измените фильтр или запрос.
+ ) : (
+
+
+
+
+ | Работа |
+ Вид |
+ Район |
+ Сроки |
+ Объектов в 500 м |
+ Затронуто сети, км |
+
+ Действия
+ |
+
+
+
+ {shown.slice(0, 200).map((w) => (
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+ >
+ );
+}
+
+function WorkRow({ w }: { w: Work }) {
+ const center = firstPoint(w.geometry);
+ return (
+
+ |
+ {w.title}
+ {w.place && {w.place} }
+ |
+
+
+
+ {workKindLabel[w.kind]}
+
+ {w.closure && перекрытие}
+
+ |
+ {w.district || "—"} |
+
+ {w.state === "active" ? "идёт" : "начнётся"}
+
+ {w.start || w.end ? `${w.start ? date(w.start) : "…"} – ${w.end ? date(w.end) : "…"}` : "сроки не указаны"}
+
+ |
+ {num(w.facilities)} |
+ {num(w.impactKm, 2)} |
+
+ {center && (
+
+ На карте
+
+ )}
+ |
+
+ );
+}
+
+/** Первая точка геометрии работы — центр для перехода на карту. */
+function firstPoint(g: Work["geometry"]): [number, number] | null {
+ let c: unknown = g?.coordinates;
+ while (Array.isArray(c) && Array.isArray(c[0])) {
+ c = c[0];
+ }
+ return Array.isArray(c) && typeof c[0] === "number" && typeof c[1] === "number" ? [c[0], c[1]] : null;
+}
diff --git a/web/src/features/operations/operations.module.css b/web/src/features/operations/operations.module.css
new file mode 100644
index 0000000..80adb08
--- /dev/null
+++ b/web/src/features/operations/operations.module.css
@@ -0,0 +1,69 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.page > * {
+ margin-top: 0;
+}
+
+.kpis {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+@media (max-width: 1000px) {
+ .kpis {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px 16px;
+ padding: 0 20px 16px;
+}
+
+.filters > :global(.field) {
+ min-width: 260px;
+}
+
+.count {
+ margin-left: auto;
+ padding-bottom: 8px;
+ color: var(--tx2);
+ font-size: 12.5px;
+}
+
+.scroll {
+ overflow-x: auto;
+ border-top: 1px solid var(--line);
+}
+
+.title {
+ font-weight: 500;
+}
+
+.muted {
+ color: var(--tx2);
+ font-size: 12.5px;
+}
+
+.badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.nowrap {
+ white-space: nowrap;
+}
+
+.mapLink {
+ white-space: nowrap;
+ font-size: 13px;
+}
From 1329eabaf59304e7388f959b9ca167a0cbc9d833 Mon Sep 17 00:00:00 2001
From: Evgeny Kuzmin <230209931+ht2473@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:39:55 +0300
Subject: [PATCH 04/20] =?UTF-8?q?=D0=98=D0=BD=D1=82=D0=B5=D1=80=D1=84?=
=?UTF-8?q?=D0=B5=D0=B9=D1=81:=20=D1=81=D0=B5=D1=82=D0=BA=D0=B0=20=D0=B8?=
=?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B2=D0=B8?=
=?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B4=D0=B0=D1=88=D0=B1?=
=?UTF-8?q?=D0=BE=D1=80=D0=B4=D0=B0,=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D1=86=D1=8B=20=D0=B8=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?=
=?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?=
=?UTF-8?q?,=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2=D0=BD=D0=B8=D0=B2=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=BE?=
=?UTF-8?q?=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/tiles/layers.go | 9 +-
internal/web/help.templ | 6 +-
internal/web/help_templ.go | 68 ++--
web/e2e/analytics-section.spec.ts | 18 +
web/e2e/facilities.spec.ts | 2 +-
web/e2e/map.spec.ts | 35 ++
web/src/app/shell/shell.module.css | 17 +-
web/src/features/alerts/AlertsPage.tsx | 6 +-
web/src/features/alerts/alerts.module.css | 7 +-
web/src/features/analytics/IndexPanel.tsx | 1 +
.../features/analytics/analytics.module.css | 16 +-
web/src/features/apidocs/apidocs.module.css | 5 +
web/src/features/dashboard/DashboardPage.tsx | 315 +++++++++++++-----
.../features/dashboard/dashboard.module.css | 182 ++++++++++
.../features/facilities/FacilitiesPage.tsx | 24 +-
.../features/facilities/facilities.module.css | 20 +-
web/src/features/map/AreaTool.tsx | 13 +-
web/src/features/map/MapPage.tsx | 33 +-
.../features/regulation/regulation.module.css | 10 +
web/src/features/reports/ReportsPage.tsx | 4 +-
web/src/features/scenarios/ScenarioMap.tsx | 5 +-
web/src/features/site-selection/SitePage.tsx | 1 +
web/src/shared/map/instance.ts | 15 +-
web/src/shared/ui/fields.tsx | 5 +-
web/src/shared/ui/index.tsx | 2 +-
web/src/shared/ui/navigation.module.css | 5 +-
web/src/shared/ui/ui.module.css | 16 +-
web/src/styles/base.css | 5 +
web/src/styles/components.css | 5 +
web/src/styles/help.css | 61 +++-
web/tsconfig.json | 3 +-
31 files changed, 734 insertions(+), 180 deletions(-)
create mode 100644 web/src/features/dashboard/dashboard.module.css
diff --git a/internal/tiles/layers.go b/internal/tiles/layers.go
index e963479..7b2d93a 100644
--- a/internal/tiles/layers.go
+++ b/internal/tiles/layers.go
@@ -18,7 +18,8 @@ type Layer struct {
// Domains — домены данных: их версии входят в адрес тайлов и ключ кеша.
Domains []string
Attribution string
- // Visible — включён по умолчанию.
+ // Visible — включён по умолчанию. При открытии карты — объекты, их контуры, кольца и работы на
+ // сегодня; остальное включается в «Слоях», чтобы карта не была перегружена.
Visible bool
Style Style
Properties []Property
@@ -137,8 +138,8 @@ var Registry = []Layer{
Code: "boundaries", Group: "territory", Title: "Округа и районы",
Description: "Границы Москвы, административных округов и районов", Geometry: "polygon",
MinZoom: 7, MaxZoom: 14, Permission: identity.PermMapView, Domains: []string{"territory"},
- Attribution: osmAttribution, Visible: true,
- Style: Style{Kind: "single", Color: "n10"},
+ Attribution: osmAttribution,
+ Style: Style{Kind: "single", Color: "n10"},
Properties: []Property{
{Name: "name", Title: "Название", Kind: "text"},
{Name: "kind", Title: "Уровень", Kind: "enum", Labels: map[string]string{
@@ -294,7 +295,7 @@ var Registry = []Layer{
Code: "restrictions", Group: "network", Title: "Ограничения для грузовиков",
Description: "Запреты и пределы массы и габаритов: знаки Москвы и разметка OSM", Geometry: "line",
MinZoom: 10, MaxZoom: 15, Permission: identity.PermMapView, Domains: []string{"network"},
- Attribution: mixedAttribution, Visible: true,
+ Attribution: mixedAttribution,
Style: Style{
Kind: "category", Property: "severity", DashedBy: "conditional",
Note: "Пунктир — ограничение действует по времени",
diff --git a/internal/web/help.templ b/internal/web/help.templ
index 481e81b..09440cc 100644
--- a/internal/web/help.templ
+++ b/internal/web/help.templ
@@ -23,7 +23,11 @@ templ helpLayout(p page, current string) {
К содержанию
diff --git a/internal/web/help_templ.go b/internal/web/help_templ.go
index 31111ce..2723224 100644
--- a/internal/web/help_templ.go
+++ b/internal/web/help_templ.go
@@ -83,7 +83,7 @@ func helpLayout(p page, current string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">
К содержанию
* {
+/* Поля панели — не уже 200 px; кнопки-иконки (справка «?») — своего размера, по центру поля. */
+.toolbar > :not(button) {
min-width: 200px;
}
+.toolbar > button {
+ margin-bottom: 1px;
+}
+
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 520px);
@@ -125,8 +130,8 @@
padding: 0;
border: 0;
display: grid;
- grid-template-columns: minmax(0, 1fr) 110px;
- gap: 8px 12px;
+ grid-template-columns: minmax(0, 1fr) 120px;
+ gap: 14px 16px;
align-items: center;
margin-bottom: 12px;
}
@@ -209,3 +214,8 @@
text-decoration: underline;
text-underline-offset: 3px;
}
+
+/* Карточки в одном ряду сетки — без отступа сверху, чтобы верхние края совпадали. */
+.layout > * {
+ margin-top: 0;
+}
diff --git a/web/src/features/apidocs/apidocs.module.css b/web/src/features/apidocs/apidocs.module.css
index f18f8a0..e33fc79 100644
--- a/web/src/features/apidocs/apidocs.module.css
+++ b/web/src/features/apidocs/apidocs.module.css
@@ -164,3 +164,8 @@
.columns > div {
min-width: 0;
}
+
+/* Карточки в одном ряду сетки — без отступа сверху, чтобы верхние края совпадали. */
+.columns > * {
+ margin-top: 0;
+}
diff --git a/web/src/features/dashboard/DashboardPage.tsx b/web/src/features/dashboard/DashboardPage.tsx
index 0a5af35..95b0b3b 100644
--- a/web/src/features/dashboard/DashboardPage.tsx
+++ b/web/src/features/dashboard/DashboardPage.tsx
@@ -1,17 +1,37 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { Plus, RotateCcw, Settings2, X } from "lucide-react";
+import {
+ ChevronLeft,
+ ChevronRight,
+ GripVertical,
+ Plus,
+ RotateCcw,
+ Settings2,
+ SlidersHorizontal,
+ X,
+} from "lucide-react";
import { useState } from "react";
import { Guard } from "@/app/Guard";
import { $api, problemMessage, type Schemas } from "@/shared/api/client";
import { dateTime, num, plural } from "@/shared/format";
-import { Button, EmptyState, IconButton, Modal, PageHeader, Section, Select, Skeleton } from "@/shared/ui";
+import {
+ Button,
+ EmptyState,
+ IconButton,
+ iconButtonClass,
+ Modal,
+ PageHeader,
+ Section,
+ Select,
+ Skeleton,
+} from "@/shared/ui";
import { KpiTile } from "@/shared/ui/KpiTile";
+import { Tooltip } from "@/shared/ui/overlays";
import { toast } from "@/shared/ui/toast";
-import s from "../analytics/analytics.module.css";
import { MetricHelp } from "../analytics/MetricHelp";
import { type Level, levelTitles, metricValue, useMetrics } from "../analytics/metrics";
import { SeriesView } from "../analytics/SeriesPanel";
+import s from "./dashboard.module.css";
type Widget = Schemas["Widget"];
@@ -28,6 +48,7 @@ const widgetKey = (w: Widget) => `${w.kind}:${w.metric ?? ""}:${w.index ?? ""}:$
function Dashboard() {
const qc = useQueryClient();
const dash = $api.useQuery("get", "/me/dashboard");
+ const metrics = useMetrics();
const [editing, setEditing] = useState(false);
const [adding, setAdding] = useState(false);
const refresh = () => qc.invalidateQueries({ queryKey: ["get", "/me/dashboard"] });
@@ -41,8 +62,31 @@ function Dashboard() {
void refresh();
},
});
- const widgets = dash.data?.widgets ?? [];
- const store = (next: Widget[]) => save.mutate({ body: { widgets: next } });
+ // Порядок показывается сразу, не дожидаясь ответа сервера.
+ const [pending, setPending] = useState(null);
+ const widgets = (save.isPending && pending) || dash.data?.widgets || [];
+ const store = (next: Widget[]) => {
+ setPending(next);
+ save.mutate({ body: { widgets: next } });
+ };
+ const move = (from: number, to: number) => {
+ if (from === to || to < 0 || to >= widgets.length) {
+ return;
+ }
+ const next = [...widgets];
+ const [w] = next.splice(from, 1);
+ if (w) {
+ next.splice(to, 0, w);
+ store(next);
+ }
+ };
+ // Перетаскивание в режиме настройки: откуда взят виджет и над каким он сейчас.
+ const [dragFrom, setDragFrom] = useState(null);
+ const [dragOver, setDragOver] = useState(null);
+ const endDrag = () => {
+ setDragFrom(null);
+ setDragOver(null);
+ };
return (
<>
@@ -50,7 +94,7 @@ function Dashboard() {
title="Дашборд"
subtitle="Логистическая инфраструктура Москвы: показатели, динамика, рейтинги, обновления данных"
actions={
-
+ <>
{editing && (
<>
+ >
}
/>
+ {editing && (
+
+ Порядок карточек меняется перетаскиванием или стрелками на панели над карточкой. Изменения сохраняются сразу.
+
+ )}
{dash.isPending ? (
) : (
-
+
{widgets.map((w, i) => (
- store(widgets.filter((_, j) => j !== i)) : undefined}
- />
+ className={[
+ s.cell,
+ w.kind === "series" ? s.wide : "",
+ editing ? s.editing : "",
+ dragFrom === i ? s.dragging : "",
+ dragOver === i && dragFrom !== i ? s.dropTarget : "",
+ ].join(" ")}
+ draggable={editing}
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", String(i));
+ setDragFrom(i);
+ }}
+ onDragOver={(e) => {
+ if (dragFrom !== null) {
+ e.preventDefault();
+ setDragOver(i);
+ }
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ if (dragFrom !== null) {
+ move(dragFrom, i);
+ }
+ endDrag();
+ }}
+ onDragEnd={endDrag}
+ >
+
+ {editing && (
+ move(i, i + d)}
+ onRemove={() => store(widgets.filter((_, j) => j !== i))}
+ />
+ )}
+
))}
)}
-
-
-
+
{adding && (
setAdding(false)}
onAdd={(w) => {
setAdding(false);
@@ -99,38 +190,79 @@ function Dashboard() {
);
}
+/**
+ * Кнопки виджета в режиме настройки — панелью на верхней кромке карточки, чтобы не теснить
+ * заголовок: перетаскивание, сдвиг раньше и позже (с клавиатуры), удаление.
+ */
+function EditTools({
+ title,
+ first,
+ last,
+ onMove,
+ onRemove,
+}: {
+ title: string;
+ first: boolean;
+ last: boolean;
+ onMove: (delta: number) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+
+ onMove(-1)}>
+
+
+ onMove(1)}>
+
+
+
+
+
+
+ );
+}
+
+const kindTitles: Record = {
+ kpi: "Плитка",
+ series: "Ряд",
+ rating: "Рейтинг",
+ index: "Индекс",
+};
+
+/** Подпись виджета для панели настройки: вид и название показателя. */
+function widgetTitle(w: Widget, defs: Map): string {
+ const name = w.metric ? (defs.get(w.metric)?.title ?? w.metric) : (w.index ?? "");
+ return `${kindTitles[w.kind]}: ${name}`;
+}
+
/** Значения показателей города для плиток. */
function useCity() {
return $api.useQuery("get", "/metrics/entity", { params: { query: { level: "city", entity: "moscow" } } });
}
-function WidgetCard({ w, onRemove }: { w: Widget; onRemove?: () => void }) {
+function WidgetCard({ w }: { w: Widget }) {
const metrics = useMetrics();
const def = w.metric ? metrics.byCode.get(w.metric) : undefined;
- const remove = onRemove && (
-
-
-
- );
if (w.kind === "kpi") {
- return ;
+ return ;
}
if (w.kind === "series") {
return (
-
-
-
+
);
}
if (w.kind === "index") {
- return ;
+ return ;
}
- return ;
+ return ;
}
-function KpiWidget({ code, remove }: { code: string; remove?: React.ReactNode }) {
+function KpiWidget({ code }: { code: string }) {
const metrics = useMetrics();
const city = useCity();
const def = metrics.byCode.get(code);
@@ -140,22 +272,19 @@ function KpiWidget({ code, remove }: { code: string; remove?: React.ReactNode })
}
const model = code === "territory.population_coverage" || code.startsWith("h3.population");
return (
-
-
- {remove &&
{remove}
}
-
+
);
}
-function RatingWidget({ code, level, remove }: { code: string; level: Level; remove?: React.ReactNode }) {
+function RatingWidget({ code, level }: { code: string; level: Level }) {
const metrics = useMetrics();
const def = metrics.byCode.get(code);
const q = $api.useQuery("get", "/metrics/{code}", { params: { path: { code }, query: { level, limit: 8 } } });
@@ -164,12 +293,7 @@ function RatingWidget({ code, level, remove }: { code: string; level: Level; rem
-
- {remove}
- >
- }
+ actions={}
>
{q.isPending ? (
@@ -180,7 +304,7 @@ function RatingWidget({ code, level, remove }: { code: string; level: Level; rem
{values.map((v) => (
- {metricValue(v.value, def, false)}
+ {metricValue(v.value, def, false)}
))}
@@ -207,7 +331,7 @@ function EntityLink({ level, entity, label }: { level: string; entity: string; l
return {label};
}
-function IndexWidget({ code, level, remove }: { code: string; level: "district" | "h3_8"; remove?: React.ReactNode }) {
+function IndexWidget({ code, level }: { code: string; level: "district" | "h3_8" }) {
const list = $api.useQuery("get", "/indexes", {}, { staleTime: 60_000 });
const ix = list.data?.find((x) => x.code === code);
const q = $api.useQuery("post", "/indexes/{code}/compute", { params: { path: { code } }, body: { level } });
@@ -216,12 +340,16 @@ function IndexWidget({ code, level, remove }: { code: string; level: "district"
title={ix?.title ?? "Индекс"}
description={`${levelTitles[level]}, 0–100`}
actions={
- <>
-
- Настроить веса
+
+
+
- {remove}
- >
+
}
>
{q.isPending ? (
@@ -231,7 +359,7 @@ function IndexWidget({ code, level, remove }: { code: string; level: "district"
{(q.data?.scores ?? []).slice(0, 8).map((x) => (
- {num(x.score, 1)}
+ {num(x.score, 1)}
))}
@@ -250,12 +378,16 @@ function Attention() {
body: { level: "district" },
});
const crit = $api.useQuery("get", "/metrics/{code}", {
- params: { path: { code: "segment.criticality" }, query: { level: "segment", limit: 5 } },
+ params: { path: { code: "segment.criticality" }, query: { level: "segment", limit: 20 } },
});
const busy = (works.data?.values ?? []).filter((v) => v.value > 0);
+ // Участки одной улицы (две проезжие части, соседние перегоны) — одной строкой с наибольшим ростом.
+ const critical = [...new Map([...(crit.data?.values ?? [])].reverse().map((v) => [v.label, v])).values()]
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 5);
return (
- Объекты с идущими дорожными работами в 500 м
+ Объекты с идущими дорожными работами в 500 м
{busy.length === 0 ? (
Сейчас таких нет.
) : (
@@ -263,26 +395,26 @@ function Attention() {
{busy.map((v) => (
- {plural(v.value, "работа", "работы", "работ")}
+ {plural(v.value, "работа", "работы", "работ")}
))}
)}
- Районы с наибольшим индексом ограничений
+ Районы с наибольшим индексом ограничений
{(restr.data?.scores ?? []).slice(0, 5).map((x) => (
-
- {num(x.score, 1)}
+ {num(x.score, 1)}
))}
- Критичные мосты и участки (рост времени доступа при выпадении)
+ Критичные мосты и участки — рост времени доступа при выпадении
- {(crit.data?.values ?? []).map((v) => (
+ {critical.map((v) => (
-
{v.label}
- {num(v.value)} мин
+ {num(v.value)} мин
))}
@@ -299,16 +431,16 @@ function Updates() {
) : (q.data ?? []).length === 0 ? (
Загрузки ещё не выполнялись.
) : (
-
+
{(q.data ?? []).map((u) => (
- -
+
-
{u.title}
· {u.publisher}
{dateTime(u.finishedAt)}
- {u.records !== undefined && ` · ${num(u.records)} записей`}
+ {u.records !== undefined && ` · ${plural(u.records, "запись", "записи", "записей")}`}
))}
@@ -325,7 +457,16 @@ const kinds = [
{ id: "index", label: "Рейтинг индекса" },
];
-function AddWidget({ onAdd, onClose }: { onAdd: (w: Widget) => void; onClose: () => void }) {
+function AddWidget({
+ present,
+ onAdd,
+ onClose,
+}: {
+ /** Ключи виджетов, уже стоящих на дашборде: повторно не добавляются. */
+ present: Set;
+ onAdd: (w: Widget) => void;
+ onClose: () => void;
+}) {
const metrics = useMetrics();
const indexes = $api.useQuery("get", "/indexes", {}, { staleTime: 60_000 });
const [kind, setKind] = useState("kpi");
@@ -333,15 +474,27 @@ function AddWidget({ onAdd, onClose }: { onAdd: (w: Widget) => void; onClose: ()
const [index, setIndex] = useState(null);
const [level, setLevel] = useState("district");
const all = metrics.data ?? [];
- const metricOptions =
+ const candidate = (): Widget =>
+ kind === "index"
+ ? { kind, index: index ?? "", level }
+ : kind === "rating"
+ ? { kind, metric: metric ?? "", level }
+ : { kind, metric: metric ?? "" };
+ const isPresent = (w: Widget) => present.has(widgetKey(w));
+ const metricOptions = (
kind === "kpi"
? all.filter((m) => m.levels.includes("city") && !m.code.startsWith("city."))
: kind === "series"
? all.filter((m) => m.code.startsWith("city."))
- : all.filter((m) => m.levels.includes(level) && !m.code.startsWith("city."));
+ : all.filter((m) => m.levels.includes(level) && !m.code.startsWith("city."))
+ ).filter((m) => !isPresent(kind === "rating" ? { kind, metric: m.code, level } : { kind, metric: m.code }));
+ const indexOptions = (indexes.data ?? []).filter((x) => !isPresent({ kind: "index", index: x.code, level }));
const levels: Level[] =
kind === "index" ? ["district", "h3_8"] : ["district", "okrug", "facility", "h3_8", "segment"];
- const ok = kind === "index" ? !!index : !!metric && metricOptions.some((m) => m.code === metric);
+ const ok =
+ kind === "index"
+ ? indexOptions.some((x) => x.code === index)
+ : !!metric && metricOptions.some((m) => m.code === metric);
return (
void; onClose: ()
actions={
<>
Отмена
-
- onAdd(
- kind === "index"
- ? { kind, index: index ?? "", level }
- : kind === "rating"
- ? { kind, metric: metric ?? "", level }
- : { kind, metric: metric ?? "" },
- )
- }
- >
+ onAdd(candidate())}>
Добавить
>
}
>
-
+